From f105645db566a4ad71e32b5ba40476f56b5762ce Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 9 Jul 2026 13:47:00 -0500 Subject: [PATCH 1/2] RIC-T40 More updates --- .../react-native-best-practices/POWER.md | 161 ++++ .../react-native-best-practices/SKILL.md | 253 +++++ .../agents/openai.yaml | 4 + .../references/bundle-analyze-app.md | 211 +++++ .../references/bundle-analyze-js.md | 262 ++++++ .../references/bundle-barrel-exports.md | 248 +++++ .../references/bundle-code-splitting.md | 247 +++++ .../references/bundle-hermes-mmap.md | 167 ++++ .../references/bundle-library-size.md | 177 ++++ .../references/bundle-native-assets.md | 214 +++++ .../references/bundle-r8-android.md | 225 +++++ .../references/bundle-tree-shaking.md | 214 +++++ .../bundle-treemap-source-map-explorer.png | Bin 0 -> 667493 bytes .../images/controlled-textinput-pingpong.png | Bin 0 -> 127223 bytes .../references/images/devtools-flamegraph.png | Bin 0 -> 520655 bytes .../references/images/emerge-xray-ios.png | Bin 0 -> 439669 bytes .../references/images/expo-atlas-treemap.png | Bin 0 -> 618904 bytes .../flashlight-flatlist-vs-flashlist.png | Bin 0 -> 313215 bytes .../references/images/fps-drop-graph.png | Bin 0 -> 152493 bytes .../images/memory-heap-snapshot.png | Bin 0 -> 308032 bytes .../images/tti-warm-start-diagram.png | Bin 0 -> 127254 bytes .../images/view-hierarchy-flattening.png | Bin 0 -> 2587021 bytes .../images/xcode-instruments-templates.png | Bin 0 -> 408360 bytes .../references/images/xcode-thread-view.png | Bin 0 -> 178440 bytes .../references/js-animations-reanimated.md | 255 +++++ .../references/js-atomic-state.md | 246 +++++ .../references/js-bottomsheet.md | 325 +++++++ .../references/js-concurrent-react.md | 236 +++++ .../references/js-lists-flatlist-flashlist.md | 244 +++++ .../references/js-measure-fps.md | 178 ++++ .../references/js-memory-leaks.md | 205 ++++ .../references/js-profile-react.md | 174 ++++ .../references/js-react-compiler.md | 368 ++++++++ .../references/js-uncontrolled-components.md | 216 +++++ .../native-android-16kb-alignment.md | 113 +++ .../references/native-measure-tti.md | 262 ++++++ .../references/native-memory-leaks.md | 240 +++++ .../references/native-memory-patterns.md | 274 ++++++ .../references/native-platform-setup.md | 110 +++ .../references/native-profiling.md | 176 ++++ .../references/native-sdks-over-polyfills.md | 192 ++++ .../references/native-threading-model.md | 234 +++++ .../references/native-turbo-modules.md | 292 ++++++ .../references/native-view-flattening.md | 201 ++++ .../skills/upgrading-react-native/SKILL.md | 82 ++ .../upgrading-react-native/agents/openai.yaml | 4 + .../references/expo-sdk-upgrade.md | 65 ++ .../monorepo-singlerepo-targeting.md | 34 + .../references/react.md | 42 + .../references/upgrade-helper-core.md | 122 +++ .../references/upgrade-verification.md | 43 + .../references/upgrading-dependencies.md | 41 + .../references/upgrading-react-native.md | 52 ++ .gitignore | 1 + CLAUDE.md | 48 + src/api/command/__tests__/board.test.ts | 64 ++ src/api/command/__tests__/sync.test.ts | 34 + src/api/command/board.ts | 186 ++++ src/api/command/sync.ts | 16 + src/api/routes/routes.ts | 211 ----- src/app/(app)/_layout.tsx | 26 +- src/app/(app)/incidents.tsx | 73 ++ src/app/(app)/index.tsx | 116 +-- src/app/(app)/routes.tsx | 7 - src/app/_layout.tsx | 1 - src/app/incident/[id].tsx | 75 ++ src/app/routes/_layout.tsx | 26 - src/app/routes/active.tsx | 432 --------- src/app/routes/directions.tsx | 874 ------------------ src/app/routes/history/[planId].tsx | 181 ---- src/app/routes/history/instance/[id].tsx | 468 ---------- src/app/routes/index.tsx | 7 - src/app/routes/poi/[id].tsx | 157 ---- src/app/routes/start.tsx | 375 -------- src/app/routes/stop/[id].tsx | 383 -------- src/app/routes/stop/contact.tsx | 299 ------ .../command/__tests__/command-board.test.tsx | 46 + .../command/__tests__/incident-card.test.tsx | 56 ++ src/components/command/command-board.tsx | 98 ++ src/components/command/incident-card.tsx | 60 ++ src/components/maps/pin-detail-modal.tsx | 14 - src/components/routes/active-routes-list.tsx | 215 ----- src/components/routes/filter-context.tsx | 71 -- src/components/routes/poi-card.tsx | 66 -- src/components/routes/poi-list-content.tsx | 176 ---- src/components/routes/route-card.tsx | 104 --- .../routes/route-deviation-banner.tsx | 53 -- src/components/routes/routes-home.tsx | 136 --- src/components/routes/stop-card.tsx | 102 -- src/components/routes/stop-marker.tsx | 49 - .../v4/incidentCommand/commandLogEntry.ts | 16 + .../incidentCommand/commandStructureNode.ts | 26 + .../v4/incidentCommand/commandTransfer.ts | 13 + .../incidentCommand/incidentAdHocPersonnel.ts | 25 + .../v4/incidentCommand/incidentAdHocUnit.ts | 23 + .../v4/incidentCommand/incidentCommand.ts | 24 + .../incidentCommand/incidentCommandBoard.ts | 25 + .../incidentCommand/incidentCommandBundle.ts | 17 + .../incidentCommand/incidentCommandEnums.ts | 161 ++++ .../incidentCommand/incidentCommandResults.ts | 72 ++ .../incidentCommand/incidentMapAnnotation.ts | 21 + .../incidentCommand/incidentRoleAssignment.ts | 22 + .../v4/incidentCommand/incidentTimer.ts | 27 + .../personnelCallCheckInStatus.ts | 21 + .../v4/incidentCommand/resourceAssignment.ts | 22 + .../v4/incidentCommand/tacticalObjective.ts | 20 + src/models/v4/routes/directionsResultData.ts | 17 - .../v4/routes/routeDeviationResultData.ts | 20 - src/models/v4/routes/routeInputs.ts | 48 - .../v4/routes/routeInstanceResultData.ts | 44 - .../v4/routes/routeInstanceStopResultData.ts | 30 - src/models/v4/routes/routePlanResultData.ts | 37 - src/models/v4/routes/routeResults.ts | 54 -- .../command/__tests__/board-store.test.ts | 98 ++ .../command/__tests__/incidents-store.test.ts | 66 ++ src/stores/command/board-store.ts | 55 ++ src/stores/command/incidents-store.ts | 51 + src/stores/routes/store.ts | 393 -------- src/translations/ar.json | 23 + src/translations/de.json | 23 + src/translations/en.json | 23 + src/translations/es.json | 23 + src/translations/fr.json | 23 + src/translations/it.json | 23 + src/translations/pl.json | 23 + src/translations/sv.json | 23 + src/translations/uk.json | 23 + 127 files changed, 9193 insertions(+), 5177 deletions(-) create mode 100644 .claude/skills/react-native-best-practices/POWER.md create mode 100644 .claude/skills/react-native-best-practices/SKILL.md create mode 100644 .claude/skills/react-native-best-practices/agents/openai.yaml create mode 100644 .claude/skills/react-native-best-practices/references/bundle-analyze-app.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-analyze-js.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-barrel-exports.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-code-splitting.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-hermes-mmap.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-library-size.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-native-assets.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-r8-android.md create mode 100644 .claude/skills/react-native-best-practices/references/bundle-tree-shaking.md create mode 100644 .claude/skills/react-native-best-practices/references/images/bundle-treemap-source-map-explorer.png create mode 100644 .claude/skills/react-native-best-practices/references/images/controlled-textinput-pingpong.png create mode 100644 .claude/skills/react-native-best-practices/references/images/devtools-flamegraph.png create mode 100644 .claude/skills/react-native-best-practices/references/images/emerge-xray-ios.png create mode 100644 .claude/skills/react-native-best-practices/references/images/expo-atlas-treemap.png create mode 100644 .claude/skills/react-native-best-practices/references/images/flashlight-flatlist-vs-flashlist.png create mode 100644 .claude/skills/react-native-best-practices/references/images/fps-drop-graph.png create mode 100644 .claude/skills/react-native-best-practices/references/images/memory-heap-snapshot.png create mode 100644 .claude/skills/react-native-best-practices/references/images/tti-warm-start-diagram.png create mode 100644 .claude/skills/react-native-best-practices/references/images/view-hierarchy-flattening.png create mode 100644 .claude/skills/react-native-best-practices/references/images/xcode-instruments-templates.png create mode 100644 .claude/skills/react-native-best-practices/references/images/xcode-thread-view.png create mode 100644 .claude/skills/react-native-best-practices/references/js-animations-reanimated.md create mode 100644 .claude/skills/react-native-best-practices/references/js-atomic-state.md create mode 100644 .claude/skills/react-native-best-practices/references/js-bottomsheet.md create mode 100644 .claude/skills/react-native-best-practices/references/js-concurrent-react.md create mode 100644 .claude/skills/react-native-best-practices/references/js-lists-flatlist-flashlist.md create mode 100644 .claude/skills/react-native-best-practices/references/js-measure-fps.md create mode 100644 .claude/skills/react-native-best-practices/references/js-memory-leaks.md create mode 100644 .claude/skills/react-native-best-practices/references/js-profile-react.md create mode 100644 .claude/skills/react-native-best-practices/references/js-react-compiler.md create mode 100644 .claude/skills/react-native-best-practices/references/js-uncontrolled-components.md create mode 100644 .claude/skills/react-native-best-practices/references/native-android-16kb-alignment.md create mode 100644 .claude/skills/react-native-best-practices/references/native-measure-tti.md create mode 100644 .claude/skills/react-native-best-practices/references/native-memory-leaks.md create mode 100644 .claude/skills/react-native-best-practices/references/native-memory-patterns.md create mode 100644 .claude/skills/react-native-best-practices/references/native-platform-setup.md create mode 100644 .claude/skills/react-native-best-practices/references/native-profiling.md create mode 100644 .claude/skills/react-native-best-practices/references/native-sdks-over-polyfills.md create mode 100644 .claude/skills/react-native-best-practices/references/native-threading-model.md create mode 100644 .claude/skills/react-native-best-practices/references/native-turbo-modules.md create mode 100644 .claude/skills/react-native-best-practices/references/native-view-flattening.md create mode 100644 .claude/skills/upgrading-react-native/SKILL.md create mode 100644 .claude/skills/upgrading-react-native/agents/openai.yaml create mode 100644 .claude/skills/upgrading-react-native/references/expo-sdk-upgrade.md create mode 100644 .claude/skills/upgrading-react-native/references/monorepo-singlerepo-targeting.md create mode 100644 .claude/skills/upgrading-react-native/references/react.md create mode 100644 .claude/skills/upgrading-react-native/references/upgrade-helper-core.md create mode 100644 .claude/skills/upgrading-react-native/references/upgrade-verification.md create mode 100644 .claude/skills/upgrading-react-native/references/upgrading-dependencies.md create mode 100644 .claude/skills/upgrading-react-native/references/upgrading-react-native.md create mode 100644 src/api/command/__tests__/board.test.ts create mode 100644 src/api/command/__tests__/sync.test.ts create mode 100644 src/api/command/board.ts create mode 100644 src/api/command/sync.ts delete mode 100644 src/api/routes/routes.ts create mode 100644 src/app/(app)/incidents.tsx delete mode 100644 src/app/(app)/routes.tsx create mode 100644 src/app/incident/[id].tsx delete mode 100644 src/app/routes/_layout.tsx delete mode 100644 src/app/routes/active.tsx delete mode 100644 src/app/routes/directions.tsx delete mode 100644 src/app/routes/history/[planId].tsx delete mode 100644 src/app/routes/history/instance/[id].tsx delete mode 100644 src/app/routes/index.tsx delete mode 100644 src/app/routes/poi/[id].tsx delete mode 100644 src/app/routes/start.tsx delete mode 100644 src/app/routes/stop/[id].tsx delete mode 100644 src/app/routes/stop/contact.tsx create mode 100644 src/components/command/__tests__/command-board.test.tsx create mode 100644 src/components/command/__tests__/incident-card.test.tsx create mode 100644 src/components/command/command-board.tsx create mode 100644 src/components/command/incident-card.tsx delete mode 100644 src/components/routes/active-routes-list.tsx delete mode 100644 src/components/routes/filter-context.tsx delete mode 100644 src/components/routes/poi-card.tsx delete mode 100644 src/components/routes/poi-list-content.tsx delete mode 100644 src/components/routes/route-card.tsx delete mode 100644 src/components/routes/route-deviation-banner.tsx delete mode 100644 src/components/routes/routes-home.tsx delete mode 100644 src/components/routes/stop-card.tsx delete mode 100644 src/components/routes/stop-marker.tsx create mode 100644 src/models/v4/incidentCommand/commandLogEntry.ts create mode 100644 src/models/v4/incidentCommand/commandStructureNode.ts create mode 100644 src/models/v4/incidentCommand/commandTransfer.ts create mode 100644 src/models/v4/incidentCommand/incidentAdHocPersonnel.ts create mode 100644 src/models/v4/incidentCommand/incidentAdHocUnit.ts create mode 100644 src/models/v4/incidentCommand/incidentCommand.ts create mode 100644 src/models/v4/incidentCommand/incidentCommandBoard.ts create mode 100644 src/models/v4/incidentCommand/incidentCommandBundle.ts create mode 100644 src/models/v4/incidentCommand/incidentCommandEnums.ts create mode 100644 src/models/v4/incidentCommand/incidentCommandResults.ts create mode 100644 src/models/v4/incidentCommand/incidentMapAnnotation.ts create mode 100644 src/models/v4/incidentCommand/incidentRoleAssignment.ts create mode 100644 src/models/v4/incidentCommand/incidentTimer.ts create mode 100644 src/models/v4/incidentCommand/personnelCallCheckInStatus.ts create mode 100644 src/models/v4/incidentCommand/resourceAssignment.ts create mode 100644 src/models/v4/incidentCommand/tacticalObjective.ts delete mode 100644 src/models/v4/routes/directionsResultData.ts delete mode 100644 src/models/v4/routes/routeDeviationResultData.ts delete mode 100644 src/models/v4/routes/routeInputs.ts delete mode 100644 src/models/v4/routes/routeInstanceResultData.ts delete mode 100644 src/models/v4/routes/routeInstanceStopResultData.ts delete mode 100644 src/models/v4/routes/routePlanResultData.ts delete mode 100644 src/models/v4/routes/routeResults.ts create mode 100644 src/stores/command/__tests__/board-store.test.ts create mode 100644 src/stores/command/__tests__/incidents-store.test.ts create mode 100644 src/stores/command/board-store.ts create mode 100644 src/stores/command/incidents-store.ts delete mode 100644 src/stores/routes/store.ts diff --git a/.claude/skills/react-native-best-practices/POWER.md b/.claude/skills/react-native-best-practices/POWER.md new file mode 100644 index 0000000..bedabb4 --- /dev/null +++ b/.claude/skills/react-native-best-practices/POWER.md @@ -0,0 +1,161 @@ +--- +name: react-native-best-practices +description: Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops. +license: MIT +author: Callstack +keywords: ["react-native", "expo", "performance", "optimization", "profiling"] +--- + +# Onboarding + +## Step 1: Validate React Native Setup + +Before applying performance optimizations, ensure: +- **Expo CLI** or **React Native CLI** is installed + - Verify with: `npx expo --version` and `npx react-native --version` +- Metro bundler is running (**apply only for** bundle analysis) +- React Native DevTools is available (**apply only for** profiling) + - Press 'j' in Metro terminal or shake device → "Open DevTools" + +## Security Guardrails + +- Review shell commands before running them and prefer version-pinned tooling from trusted sources. +- Do not pipe remote install scripts directly into a shell. +- Treat third-party packages as normal supply-chain dependencies that require provenance and version review. +- If using Re.Pack code splitting, only load first-party chunks from trusted HTTPS origins tied to the current release. + +# When to Load Reference Files + +Load specific reference files from `references/` based on the task: + +## JavaScript/React Performance (`js-*`) + +- **Debugging slow/janky UI or animations** → `references/js-measure-fps.md` +- **Investigating re-render issues** → `references/js-profile-react.md` → `references/js-react-compiler.md` +- **Optimizing list scrolling** → `references/js-lists-flatlist-flashlist.md` +- **Reducing re-renders with state management** → `references/js-atomic-state.md` +- **Using Concurrent React features** → `references/js-concurrent-react.md` +- **Enabling automatic memoization** → `references/js-react-compiler.md` +- **Optimizing animations** → `references/js-animations-reanimated.md` +- **Fixing TextInput lag** → `references/js-uncontrolled-components.md` +- **Hunting JavaScript memory leaks** → `references/js-memory-leaks.md` + +## Native Performance (`native-*`) + +- **Measuring startup time (TTI)** → `references/native-measure-tti.md` +- **Building native modules** → `references/native-turbo-modules.md` +- **Understanding native threading** → `references/native-threading-model.md` +- **Profiling native code** → `references/native-profiling.md` +- **Setting up native tooling** → `references/native-platform-setup.md` +- **Debugging view hierarchy** → `references/native-view-flattening.md` +- **Native memory patterns** → `references/native-memory-patterns.md` +- **Hunting native memory leaks** → `references/native-memory-leaks.md` +- **Choosing native SDKs vs polyfills** → `references/native-sdks-over-polyfills.md` +- **Fixing Android 16KB alignment** → `references/native-android-16kb-alignment.md` + +## Bundle & App Size (`bundle-*`) + +- **Analyzing bundle size** → `references/bundle-analyze-js.md` +- **Analyzing app size** → `references/bundle-analyze-app.md` +- **Fixing barrel imports** → `references/bundle-barrel-exports.md` +- **Enabling tree shaking** → `references/bundle-tree-shaking.md` +- **Android code shrinking** → `references/bundle-r8-android.md` +- **Optimizing Hermes bundle loading** → `references/bundle-hermes-mmap.md` +- **Managing native assets** → `references/bundle-native-assets.md` +- **Evaluating library size** → `references/bundle-library-size.md` +- **Code splitting** → `references/bundle-code-splitting.md` + +## Problem → Reference Mapping + +Use this quick lookup when debugging specific issues: + +| Problem | Start With | +|---------|-----------| +| App feels slow/janky | `references/js-measure-fps.md` → `references/js-profile-react.md` | +| Too many re-renders | `references/js-profile-react.md` → `references/js-react-compiler.md` | +| Slow startup (TTI) | `references/native-measure-tti.md` → `references/bundle-analyze-js.md` | +| Large app size | `references/bundle-analyze-app.md` → `references/bundle-r8-android.md` | +| Memory growing | `references/js-memory-leaks.md` or `references/native-memory-leaks.md` | +| Animation drops frames | `references/js-animations-reanimated.md` | +| List scroll jank | `references/js-lists-flatlist-flashlist.md` | +| TextInput lag | `references/js-uncontrolled-components.md` | +| Native module slow | `references/native-turbo-modules.md` → `references/native-threading-model.md` | +| Native library alignment issue | `references/native-android-16kb-alignment.md` | + +## Quick Reference Commands + +### FPS & Re-renders +```bash +# Open React Native DevTools +# Press 'j' in Metro, or shake device → "Open DevTools" +``` + +Baseline runtime metrics should come from the target interaction itself: +- Capture commit timeline, re-render counts, slow components, and heaviest-commit breakdown. +- Treat component tree depth and count as supporting context only. + +**Common fixes:** +- Replace ScrollView with FlatList/FlashList for lists +- Use React Compiler for automatic memoization +- Use atomic state (Jotai/Zustand) to reduce re-renders +- Use `useDeferredValue` for expensive computations + +**Review guardrails:** +- Check library versions before suggesting API-specific fixes. FlashList v2 deprecates `estimatedItemSize`. +- Do not suggest `useMemo` or `useCallback` dependency changes without a reproducible correctness issue or profiling evidence. +- Do not report stale closures unless the stale read path or repro is clear. + +### Analyze Bundle Size +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true + +npx source-map-explorer output.js --no-border-checks +``` + +**Common fixes:** +- Avoid barrel imports (import directly from source) +- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage +- Enable tree shaking (Expo SDK 52+ or Re.Pack) +- Enable R8 for Android native code shrinking + +### Measure TTI +- Use `react-native-performance` for markers +- Only measure cold starts (exclude warm/hot/prewarm) + +**Common fixes:** +- Disable JS bundle compression on Android (enables Hermes mmap) +- Use native navigation (react-native-screens) +- Preload commonly-used expensive screens before navigating to them + +### Native Performance + +**Profile native:** +- iOS: Xcode Instruments → Time Profiler +- Android: Android Studio → CPU Profiler + +**Common fixes:** +- Use background threads for heavy native work +- Prefer async over sync Turbo Module methods +- Use C++ for cross-platform performance-critical code + +## Priority Guidelines + +Apply optimizations in this order: + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | FPS & Re-renders | CRITICAL | `js-*` | +| 2 | Bundle Size | CRITICAL | `bundle-*` | +| 3 | TTI Optimization | HIGH | `native-*`, `bundle-*` | +| 4 | Native Performance | HIGH | `native-*` | +| 5 | Memory Management | MEDIUM-HIGH | `js-*`, `native-*` | +| 6 | Animations | MEDIUM | `js-*` | + +## Attribution + +Based on "The Ultimate Guide to React Native Optimization" by Callstack. diff --git a/.claude/skills/react-native-best-practices/SKILL.md b/.claude/skills/react-native-best-practices/SKILL.md new file mode 100644 index 0000000..bde6607 --- /dev/null +++ b/.claude/skills/react-native-best-practices/SKILL.md @@ -0,0 +1,253 @@ +--- +name: react-native-best-practices +description: Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops. +license: MIT +metadata: + author: Callstack + tags: react-native, expo, performance, optimization, profiling +--- + +# React Native Best Practices + +## Overview + +Performance optimization guide for React Native applications, covering JavaScript/React, Native (iOS/Android), and bundling optimizations. Based on Callstack's "Ultimate Guide to React Native Optimization". + +## Skill Format + +Each reference file follows a hybrid format for fast lookup and deep understanding: + +- **Quick Pattern**: Incorrect/Correct code snippets for immediate pattern matching +- **Quick Command**: Shell commands for process/measurement skills +- **Quick Config**: Configuration snippets for setup-focused skills +- **Quick Reference**: Summary tables for conceptual skills +- **Deep Dive**: Full context with When to Use, Prerequisites, Step-by-Step, Common Pitfalls + +**Impact ratings**: CRITICAL (fix immediately), HIGH (significant improvement), MEDIUM (worthwhile optimization) + +## When to Apply + +Reference these guidelines when: +- Debugging slow/janky UI or animations +- Investigating memory leaks (JS or native) +- Optimizing app startup time (TTI) +- Reducing bundle or app size +- Writing native modules (Turbo Modules) +- Profiling React Native performance +- Reviewing React Native code for performance + +## Security Notes + +- Treat shell commands in these references as local developer operations. Review them before running, prefer version-pinned tooling, and avoid piping remote scripts directly to a shell. +- Treat third-party libraries and plugins as dependencies that still require normal supply-chain controls: pin versions, verify provenance, and update through your standard review process. +- Treat Re.Pack code splitting as first-party artifact delivery only. Remote chunks must come from trusted HTTPS origins you control and be pinned to the current app release. + +## Priority-Ordered Guidelines + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | FPS & Re-renders | CRITICAL | `js-*` | +| 2 | Bundle Size | CRITICAL | `bundle-*` | +| 3 | TTI Optimization | HIGH | `native-*`, `bundle-*` | +| 4 | Native Performance | HIGH | `native-*` | +| 5 | Memory Management | MEDIUM-HIGH | `js-*`, `native-*` | +| 6 | Animations | MEDIUM | `js-*` | + +## Quick Reference + +### Optimization Workflow + +Follow this cycle for any performance issue: **Measure → Optimize → Re-measure → Validate** + +1. **Measure**: Capture baseline metrics before changes. For runtime issues, prefer commit timeline, re-render counts, slow components, heaviest-commit breakdown, and startup/TTI when available. Component tree depth or count are optional context, not substitutes. +2. **Optimize**: Apply the targeted fix from the relevant reference +3. **Re-measure**: Run the same measurement to get updated metrics +4. **Validate**: Confirm improvement (e.g., FPS 45→60, TTI 3.2s→1.8s, bundle 2.1MB→1.6MB) + +If metrics did not improve, revert and try the next suggested fix. + +### Review Guardrails + +- Check library versions before suggesting API-specific fixes. Example: FlashList v2 deprecates `estimatedItemSize`, so do not flag it as missing there. +- Do not suggest `useMemo` or `useCallback` dependency changes unless behavior is demonstrably incorrect or profiling shows wasted work tied to that value. +- Do not report stale closures speculatively. Show the stale read path, a repro, or profiler evidence before calling it out. +- When profiling a flow, measure the target interaction itself. Do not treat component tree depth or component count as the main performance evidence. + +### Critical: FPS & Re-renders + +**Profile first:** +```bash +# Open React Native DevTools +# Press 'j' in Metro, or shake device → "Open DevTools" +``` + +**Common fixes:** +- Replace ScrollView with FlatList/FlashList for lists +- Use React Compiler for automatic memoization +- Use atomic state (Jotai/Zustand) to reduce re-renders +- Use `useDeferredValue` for expensive computations + +### Critical: Bundle Size + +**Analyze bundle:** +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true + +npx source-map-explorer output.js --no-border-checks +``` + +**Verify improvement after optimization:** +```bash +# Record baseline size before changes +ls -lh output.js # e.g., Before: 2.1 MB + +# After applying fixes, re-bundle and compare +npx react-native bundle --entry-file index.js --bundle-output output.js \ + --platform ios --dev false --minify true +ls -lh output.js # e.g., After: 1.6 MB (24% reduction) +``` + +**Common fixes:** +- Avoid barrel imports (import directly from source) +- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage +- Enable tree shaking (Expo SDK 52+ or Re.Pack) +- Enable R8 for Android native code shrinking + +### High: TTI Optimization + +**Measure TTI:** +- Use `react-native-performance` for markers +- Only measure cold starts (exclude warm/hot/prewarm) + +**Common fixes:** +- Disable JS bundle compression on Android (enables Hermes mmap) +- Use native navigation (react-native-screens) +- Preload commonly-used expensive screens before navigating to them + +### High: Native Performance + +**Profile native:** +- iOS: Xcode Instruments → Time Profiler +- Android: Android Studio → CPU Profiler + +**Common fixes:** +- Use background threads for heavy native work +- Prefer async over sync Turbo Module methods +- Use C++ for cross-platform performance-critical code + +## References + +Full documentation with code examples in [references/][references]: + +### JavaScript/React (`js-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] | CRITICAL | Replace ScrollView with virtualized lists | +| [js-profile-react.md][js-profile-react] | MEDIUM | React DevTools profiling | +| [js-measure-fps.md][js-measure-fps] | HIGH | FPS monitoring and measurement | +| [js-memory-leaks.md][js-memory-leaks] | MEDIUM | JS memory leak hunting | +| [js-atomic-state.md][js-atomic-state] | HIGH | Jotai/Zustand patterns | +| [js-concurrent-react.md][js-concurrent-react] | HIGH | useDeferredValue, useTransition | +| [js-react-compiler.md][js-react-compiler] | HIGH | Automatic memoization | +| [js-animations-reanimated.md][js-animations-reanimated] | MEDIUM | Reanimated worklets | +| [js-bottomsheet.md][js-bottomsheet] | HIGH | Bottom sheet optimization | +| [js-uncontrolled-components.md][js-uncontrolled-components] | HIGH | TextInput optimization | + +### Native (`native-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [native-turbo-modules.md][native-turbo-modules] | HIGH | Building fast native modules | +| [native-sdks-over-polyfills.md][native-sdks-over-polyfills] | HIGH | Native vs JS libraries | +| [native-measure-tti.md][native-measure-tti] | HIGH | TTI measurement setup | +| [native-threading-model.md][native-threading-model] | HIGH | Turbo Module threads | +| [native-profiling.md][native-profiling] | MEDIUM | Xcode/Android Studio profiling | +| [native-platform-setup.md][native-platform-setup] | MEDIUM | iOS/Android tooling guide | +| [native-view-flattening.md][native-view-flattening] | MEDIUM | View hierarchy debugging | +| [native-memory-patterns.md][native-memory-patterns] | MEDIUM | C++/Swift/Kotlin memory | +| [native-memory-leaks.md][native-memory-leaks] | MEDIUM | Native memory leak hunting | +| [native-android-16kb-alignment.md][native-android-16kb-alignment] | CRITICAL | Third-party library alignment for Google Play | + +### Bundling (`bundle-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [bundle-barrel-exports.md][bundle-barrel-exports] | CRITICAL | Avoid barrel imports | +| [bundle-analyze-js.md][bundle-analyze-js] | CRITICAL | JS bundle visualization | +| [bundle-tree-shaking.md][bundle-tree-shaking] | HIGH | Dead code elimination | +| [bundle-analyze-app.md][bundle-analyze-app] | HIGH | App size analysis | +| [bundle-r8-android.md][bundle-r8-android] | HIGH | Android code shrinking | +| [bundle-hermes-mmap.md][bundle-hermes-mmap] | HIGH | Disable bundle compression | +| [bundle-native-assets.md][bundle-native-assets] | HIGH | Asset catalog setup | +| [bundle-library-size.md][bundle-library-size] | MEDIUM | Evaluate dependencies | +| [bundle-code-splitting.md][bundle-code-splitting] | MEDIUM | Re.Pack code splitting | + + +## Searching References + +```bash +# Find patterns by keyword +grep -l "reanimated" references/ +grep -l "flatlist" references/ +grep -l "memory" references/ +grep -l "profil" references/ +grep -l "tti" references/ +grep -l "bundle" references/ +``` + +## Problem → Skill Mapping + +| Problem | Start With | +|---------|------------| +| App feels slow/janky | [js-measure-fps.md][js-measure-fps] → [js-profile-react.md][js-profile-react] | +| Too many re-renders | [js-profile-react.md][js-profile-react] → [js-react-compiler.md][js-react-compiler] | +| Slow startup (TTI) | [native-measure-tti.md][native-measure-tti] → [bundle-analyze-js.md][bundle-analyze-js] | +| Large app size | [bundle-analyze-app.md][bundle-analyze-app] → [bundle-r8-android.md][bundle-r8-android] | +| Memory growing | [js-memory-leaks.md][js-memory-leaks] or [native-memory-leaks.md][native-memory-leaks] | +| Animation drops frames | [js-animations-reanimated.md][js-animations-reanimated] | +| Bottom sheet jank/re-renders | [js-bottomsheet.md][js-bottomsheet] → [js-animations-reanimated.md][js-animations-reanimated] | +| List scroll jank | [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] | +| TextInput lag | [js-uncontrolled-components.md][js-uncontrolled-components] | +| Native module slow | [native-turbo-modules.md][native-turbo-modules] → [native-threading-model.md][native-threading-model] | +| Native library alignment issue | [native-android-16kb-alignment.md][native-android-16kb-alignment] | + +[references]: references/ +[js-lists-flatlist-flashlist]: references/js-lists-flatlist-flashlist.md +[js-profile-react]: references/js-profile-react.md +[js-measure-fps]: references/js-measure-fps.md +[js-memory-leaks]: references/js-memory-leaks.md +[js-atomic-state]: references/js-atomic-state.md +[js-concurrent-react]: references/js-concurrent-react.md +[js-react-compiler]: references/js-react-compiler.md +[js-animations-reanimated]: references/js-animations-reanimated.md +[js-bottomsheet]: references/js-bottomsheet.md +[js-uncontrolled-components]: references/js-uncontrolled-components.md +[native-turbo-modules]: references/native-turbo-modules.md +[native-sdks-over-polyfills]: references/native-sdks-over-polyfills.md +[native-measure-tti]: references/native-measure-tti.md +[native-threading-model]: references/native-threading-model.md +[native-profiling]: references/native-profiling.md +[native-platform-setup]: references/native-platform-setup.md +[native-view-flattening]: references/native-view-flattening.md +[native-memory-patterns]: references/native-memory-patterns.md +[native-memory-leaks]: references/native-memory-leaks.md +[native-android-16kb-alignment]: references/native-android-16kb-alignment.md +[bundle-barrel-exports]: references/bundle-barrel-exports.md +[bundle-analyze-js]: references/bundle-analyze-js.md +[bundle-tree-shaking]: references/bundle-tree-shaking.md +[bundle-analyze-app]: references/bundle-analyze-app.md +[bundle-r8-android]: references/bundle-r8-android.md +[bundle-hermes-mmap]: references/bundle-hermes-mmap.md +[bundle-native-assets]: references/bundle-native-assets.md +[bundle-library-size]: references/bundle-library-size.md +[bundle-code-splitting]: references/bundle-code-splitting.md + +## Attribution + +Based on "The Ultimate Guide to React Native Optimization" by Callstack. diff --git a/.claude/skills/react-native-best-practices/agents/openai.yaml b/.claude/skills/react-native-best-practices/agents/openai.yaml new file mode 100644 index 0000000..48c283d --- /dev/null +++ b/.claude/skills/react-native-best-practices/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "React Native Best Practices" + short_description: "React Native performance optimization guide" + default_prompt: "Use $react-native-best-practices to diagnose and improve React Native performance." diff --git a/.claude/skills/react-native-best-practices/references/bundle-analyze-app.md b/.claude/skills/react-native-best-practices/references/bundle-analyze-app.md new file mode 100644 index 0000000..7113685 --- /dev/null +++ b/.claude/skills/react-native-best-practices/references/bundle-analyze-app.md @@ -0,0 +1,211 @@ +--- +title: Analyze App Bundle Size +impact: HIGH +tags: app-size, ruler, emerge-tools, thinning +--- + +# Skill: Analyze App Bundle Size + +Measure iOS and Android app download/install sizes using Ruler, App Store Connect, and Emerge Tools. + +## Quick Command + +```bash +# Android (Ruler) +cd android && ./gradlew analyzeReleaseBundle + +# iOS (Xcode export with thinning) +cd ios && xcodebuild -exportArchive \ + -archivePath MyApp.xcarchive \ + -exportPath ./export \ + -exportOptionsPlist ExportOptions.plist +# Check: App Thinning Size Report.txt +``` + +## When to Use + +- App download size is too large +- Users complain about storage usage +- App approaching store limits +- Comparing releases for size regression + +> **Note**: This skill involves interpreting visual size reports (Ruler, Emerge Tools X-Ray). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the reports manually, or await MCP-based visual feedback integration (see roadmap). + +## Key Metrics + +| Metric | Description | User Impact | +|--------|-------------|-------------| +| Download Size | Compressed, transferred over network | Download time, data usage | +| Install Size | Uncompressed, on device storage | Storage space | + +**Google finding**: Every 6 MB increase reduces installs by 1%. + +## Android: Ruler (Spotify) + +### Setup + +Add to `android/build.gradle`: + +```groovy +buildscript { + dependencies { + classpath("com.spotify.ruler:ruler-gradle-plugin:2.0.0-beta-3") + } +} +``` + +Add to `android/app/build.gradle`: + +```groovy +apply plugin: "com.spotify.ruler" + +ruler { + abi.set("arm64-v8a") // Target architecture + locale.set("en") + screenDensity.set(480) + sdkVersion.set(34) +} +``` + +### Analyze + +```bash +cd android +./gradlew analyzeReleaseBundle +``` + +Opens HTML report with: +- Download size +- Install size +- Component breakdown (biggest → smallest) + +### CI Size Validation + +```groovy +ruler { + verification { + downloadSizeThreshold = 20 * 1024 * 1024 // 20 MB + installSizeThreshold = 50 * 1024 * 1024 // 50 MB + } +} +``` + +Build fails if thresholds exceeded. + +## iOS: Xcode App Thinning + +### Via App Store Connect (Most Accurate) + +After uploading to TestFlight: +1. Open App Store Connect +2. Go to your build +3. View size table by device variant + +**Note**: TestFlight builds include debug data, App Store builds slightly larger due to DRM. + +### Via Xcode Export + +1. Archive app: **Product → Archive** +2. In Organizer, click **Distribute App** +3. Select **Custom** +4. Choose **App Thinning: All compatible device variants** + +Or in `ExportOptions.plist`: + +```xml +thinning +<thin-for-all-variants> +``` + +### Output + +Creates folder with: +- **Universal IPA**: All variants combined +- **Thinned IPAs**: One per device variant +- **App Thinning Size Report.txt**: + +``` +Variant: SampleApp-.ipa +App + On Demand Resources size: 3.5 MB compressed, 10.6 MB uncompressed +App size: 3.5 MB compressed, 10.6 MB uncompressed +``` + +- Compressed = Download size +- Uncompressed = Install size + +## Emerge Tools (Cross-Platform) + +Third-party service with visual analysis. + +### Upload + +Upload IPA, APK, or AAB through their web interface or CI integration. + +### Features + +![Emerge Tools X-Ray for iOS](images/emerge-xray-ios.png) + +- **X-Ray**: Treemap visualization (like source-map-explorer for binaries) + - Shows Frameworks (hermes.framework), Mach-O sections (TEXT, DATA), etc. + - Color-coded: Binaries, Localizations, Fonts, Asset Catalogs, Videos, CoreML Models + - Visible components: `main.jsbundle` (JS code), RCT modules, DYLD sections +- **Breakdown**: Component-by-component size +- **Insights**: Automated suggestions (use with caution) + +**Caution**: Some suggestions may not apply to React Native (e.g., "remove Hermes"). + +## Size Comparison + +| Tool | Platform | Accuracy | CI Integration | +|------|----------|----------|----------------| +| Ruler | Android | High | Yes (Gradle) | +| App Store Connect | iOS | Highest | No | +| Xcode Export | iOS | High | Yes (xcodebuild) | +| Emerge Tools | Both | High | Yes (API) | + +## Typical React Native App Sizes + +| Component | Approximate Size | +|-----------|------------------| +| Hermes engine | ~2-3 MB | +| React Native core | ~3-5 MB | +| JavaScript bundle | 1-10 MB | +| Assets (images, etc.) | Varies | + +**Baseline empty app**: ~6-10 MB download + +## Optimization Impact Example + +| Optimization | Size Reduction | +|--------------|----------------| +| Enable R8 (Android) | ~30% | +| Remove unused polyfills | 400+ KB | +| Asset catalog (iOS) | 10-50% of assets | +| Tree shaking | 10-15% | + +## Quick Commands + +```bash +# Android release bundle size +cd android && ./gradlew bundleRelease +# Check: android/app/build/outputs/bundle/release/ + +# iOS archive +cd ios && xcodebuild -workspace ios/MyApp.xcworkspace \ + -scheme MyApp \ + -configuration Release \ + -archivePath MyApp.xcarchive \ + archive + +# Export with thinning report +cd ios && xcodebuild -exportArchive \ + -archivePath MyApp.xcarchive \ + -exportPath ./export \ + -exportOptionsPlist ExportOptions.plist +``` + +## Related Skills + +- [bundle-r8-android.md](./bundle-r8-android.md) - Reduce Android size +- [bundle-native-assets.md](./bundle-native-assets.md) - Optimize asset delivery +- [bundle-analyze-js.md](./bundle-analyze-js.md) - JS bundle analysis diff --git a/.claude/skills/react-native-best-practices/references/bundle-analyze-js.md b/.claude/skills/react-native-best-practices/references/bundle-analyze-js.md new file mode 100644 index 0000000..531a4ea --- /dev/null +++ b/.claude/skills/react-native-best-practices/references/bundle-analyze-js.md @@ -0,0 +1,262 @@ +--- +title: Analyze JS Bundle Size +impact: CRITICAL +tags: bundle, analysis, source-map-explorer, expo-atlas +--- + +# Skill: Analyze JS Bundle Size + +Use source-map-explorer and Expo Atlas to visualize what's in your JavaScript bundle. + +## Quick Command + +```bash +# React Native CLI +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true && \ +npx source-map-explorer output.js --no-border-checks + +# Expo +EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios && npx expo-atlas +``` + +## When to Use + +- JS bundle seems too large +- Want to identify heavy dependencies +- Investigating startup time issues +- Before/after optimization comparison + +> **Note**: This skill involves interpreting visual treemap output (source-map-explorer, Expo Atlas). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the visualization manually, or await MCP-based visual feedback integration (see roadmap). + +## Understanding Hermes Bytecode + +Modern React Native (0.70+) uses Hermes bytecode, not raw JavaScript: +- Skips parsing at runtime +- Still benefits from smaller bundles +- Heavy imports still execute on startup + +**Impact of bundle size:** +- Larger bytecode = longer download from store +- More imports on init path = slower TTI + +## Method 1: source-map-explorer + +### Generate Bundle with Source Map + +**React Native CLI:** + +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false \ + --minify true +``` + +**Expo (SDK 51+):** + +```bash +npx expo export --platform ios --source-maps --output-dir dist +# Bundle at: dist/ios/_expo/static/js/ios/*.js +# Source map at: dist/ios/_expo/static/js/ios/*.map +``` + +### Analyze + +```bash +npx source-map-explorer output.js --no-border-checks +``` + +**Note**: `--no-border-checks` needed due to Metro's non-standard source maps. + +Opens browser with treemap visualization: + +![Bundle Treemap from source-map-explorer](images/bundle-treemap-source-map-explorer.png) + +The treemap shows: +- **Hierarchy**: `node_modules/` → `react-native/` → `Libraries/` → individual files +- **Size**: Box area proportional to file size (KB shown in labels) +- **Major components visible**: + - `react-native` (724.18 KB, 80.5%) + - `Renderer` (208.44 KB) - ReactNativeRenderer-prod.js, ReactFabric-prod.js + - `Components` (125.29 KB) - Touchable, ScrollView, etc. + - `Animated` (79.48 KB) - Animation system + - `virtualized-lists` (57.57 KB) - FlatList internals + +Click on any section to drill down into that directory. + +**Limitation**: May lose ~30% info due to mapping issues. + +## Method 2: Expo Atlas + +More accurate for Expo projects (or with workaround for bare RN). + +### For Expo Projects + +```bash +# Start with Atlas enabled +EXPO_UNSTABLE_ATLAS=true npx expo start --no-dev + +# Or export +EXPO_UNSTABLE_ATLAS=true npx expo export +``` + +Then launch UI: + +```bash +npx expo-atlas +``` + +![Expo Atlas Treemap](images/expo-atlas-treemap.png) + +Expo Atlas provides more accurate visualization for Expo projects, with similar treemap interface showing module sizes and dependencies. + +### For Non-Expo Projects + +Use `expo-atlas-without-expo` package. + +## Method 3: Re.Pack Bundle Analysis (Webpack/Rspack) + +If using Re.Pack: + +### webpack-bundle-analyzer + +```bash +rspack build --analyze +``` + +### bundle-stats / statoscope + +```bash +# Generate stats +npx react-native bundle \ + --platform android \ + --entry-file index.js \ + --dev false \ + --minify true \ + --json stats.json + +# Analyze +npx bundle-stats --html --json stats.json +``` + +### Rsdoctor + +```javascript +// rspack.config.js +const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin'); + +module.exports = { + plugins: [ + process.env.RSDOCTOR && new RsdoctorRspackPlugin(), + ].filter(Boolean), +}; +``` + +Run with: + +```bash +RSDOCTOR=true npx react-native start +``` + +## What to Look For + +### Red Flags + +| Finding | Problem | Solution | +|---------|---------|----------| +| Entire library imported | Barrel exports | Use direct imports | +| Duplicate packages | Multiple versions | Dedupe in package.json | +| Dev dependencies in bundle | Incorrect imports | Check conditional imports | +| Large polyfills | Unnecessary for Hermes | Remove (see native-sdks-over-polyfills.md) | +| Moment.js with locales | Bloated date library | Switch to date-fns or dayjs | + +### Common Offenders + +- **Lodash full import**: Use `lodash-es` or specific imports +- **Moment.js**: Replace with `date-fns` or `dayjs` +- **Intl polyfills**: Check Hermes API and method coverage before removing them +- **AWS SDK**: Import specific services only + +## Code Examples + +### Identify Barrel Import Impact + +```tsx +// BAD: Imports entire library through barrel +import { format } from 'date-fns'; + +// In bundle: All of date-fns loaded + +// GOOD: Direct import +import format from 'date-fns/format'; + +// In bundle: Only format function +``` + +## Comparing Bundles + +### source-map-explorer + +```bash +# Generate baseline +npx react-native bundle ... --bundle-output baseline.js --sourcemap-output baseline.js.map + +# Make changes, generate new bundle +npx react-native bundle ... --bundle-output current.js --sourcemap-output current.js.map + +# Compare manually in browser +``` + +### Re.Pack (automated) + +```bash +npx bundle-stats compare baseline-stats.json current-stats.json +``` + +## Quick Commands + +**React Native CLI:** + +```bash +# iOS bundle analysis +npx react-native bundle \ + --entry-file index.js \ + --bundle-output ios-bundle.js \ + --platform ios \ + --sourcemap-output ios-bundle.js.map \ + --dev false \ + --minify true && \ +npx source-map-explorer ios-bundle.js --no-border-checks + +# Android bundle analysis +npx react-native bundle \ + --entry-file index.js \ + --bundle-output android-bundle.js \ + --platform android \ + --sourcemap-output android-bundle.js.map \ + --dev false \ + --minify true && \ +npx source-map-explorer android-bundle.js --no-border-checks +``` + +**Expo:** + +```bash +# Use Expo Atlas (recommended for Expo projects) +EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios +npx expo-atlas +``` + +## Related Skills + +- [bundle-barrel-exports.md](./bundle-barrel-exports.md) - Fix barrel import issues +- [bundle-tree-shaking.md](./bundle-tree-shaking.md) - Enable dead code elimination +- [bundle-library-size.md](./bundle-library-size.md) - Check library sizes before adding diff --git a/.claude/skills/react-native-best-practices/references/bundle-barrel-exports.md b/.claude/skills/react-native-best-practices/references/bundle-barrel-exports.md new file mode 100644 index 0000000..42b37f2 --- /dev/null +++ b/.claude/skills/react-native-best-practices/references/bundle-barrel-exports.md @@ -0,0 +1,248 @@ +--- +title: Avoid Barrel Exports +impact: CRITICAL +tags: bundle, imports, barrel, tree-shaking +--- + +# Skill: Avoid Barrel Exports + +Refactor barrel imports (index files) to reduce bundle size and improve startup time. + +## Quick Pattern + +**Incorrect:** + +```tsx +import { Button } from './components'; +// Loads ALL exports from components/index.ts +``` + +**Correct:** + +```tsx +import Button from './components/Button'; +// Loads only Button +``` + +## When to Use + +- Bundle contains unused code from libraries +- Circular dependency warnings in Metro +- Hot Module Replacement (HMR) breaks frequently +- TTI is slow due to module evaluation + +## What Are Barrel Exports? + +```tsx +// components/index.ts (barrel file) +export { Button } from './Button'; +export { Card } from './Card'; +export { Modal } from './Modal'; +export { Sidebar } from './Sidebar'; + +// Usage (barrel import) +import { Button } from './components'; +``` + +## Problems with Barrel Imports + +### 1. Bundle Size Overhead + +Metro includes **all exports** even if you use one: + +```tsx +// Only need Button, but entire barrel is bundled +import { Button } from './components'; +// Card, Modal, Sidebar also included! +``` + +### 2. Runtime Overhead + +All modules evaluate before returning your import: + +```tsx +import { Button } from './components'; +// JavaScript must evaluate: +// - Button.tsx +// - Card.tsx +// - Modal.tsx +// - Sidebar.tsx +// Even though you only use Button +``` + +### 3. Circular Dependencies + +Barrel files make cycles easier to create accidentally: + +``` +Warning: Require cycle: + components/index.ts -> Button.tsx -> utils/index.ts -> components/index.ts +``` + +Breaks HMR, causes unpredictable behavior. + +## Solution 1: Direct Imports + +Replace barrel imports with direct paths: + +```tsx +// BEFORE: Barrel import +import { Button, Card } from './components'; + +// AFTER: Direct imports +import Button from './components/Button'; +import Card from './components/Card'; +``` + +### Enforce with ESLint + +```bash +npm install -D eslint-plugin-no-barrel-files +``` + +```javascript +// eslint.config.js +import noBarrelFiles from 'eslint-plugin-no-barrel-files'; + +export default [ + { + plugins: { 'no-barrel-files': noBarrelFiles }, + rules: { + 'no-barrel-files/no-barrel-files': 'error', + }, + }, +]; +``` + +## Solution 2: Tree Shaking (Automatic) + +Enable tree shaking to automatically remove unused barrel exports. + +### Expo SDK 52+ + +```tsx +// metro.config.js +const { getDefaultConfig } = require('expo/metro-config'); +const config = getDefaultConfig(__dirname); + +config.transformer.getTransformOptions = async () => ({ + transform: { + experimentalImportSupport: true, + }, +}); + +module.exports = config; +``` + +```bash +# .env +EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 +EXPO_UNSTABLE_TREE_SHAKING=1 +``` + +### metro-serializer-esbuild + +```bash +npm install @rnx-kit/metro-serializer-esbuild +``` + +### Re.Pack (Webpack/Rspack) + +Tree shaking built-in. + +## Real-World Example: date-fns + +```tsx +// BAD: Imports entire library +import { format, addDays, isToday } from 'date-fns'; + +// GOOD: Direct imports +import format from 'date-fns/format'; +import addDays from 'date-fns/addDays'; +import isToday from 'date-fns/isToday'; +``` + +## Library-Specific Solutions + +Some libraries provide Babel plugins: + +### React Native Paper + +```javascript +// babel.config.js +module.exports = { + plugins: [ + 'react-native-paper/babel', // Auto-transforms imports + ], +}; +``` + +Transforms: +```tsx +import { Button } from 'react-native-paper'; +// Into: +import Button from 'react-native-paper/lib/module/components/Button'; +``` + +## Refactoring Strategy + +### Step 1: Identify Barrel Files + +Look for `index.ts` files with multiple exports: + +```bash +grep -r "export \* from" src/ +grep -r "export { .* } from" src/ +``` + +### Step 2: Update Imports + +```tsx +// Find all usages +// VS Code: Cmd+Shift+F for "from './components'" + +// Replace each with direct import +import Button from './components/Button'; +``` + +### Step 3: (Optional) Keep Barrel for External API + +If your package is consumed by others: + +```tsx +// Keep index.ts for package API +// components/index.ts +export { Button } from './Button'; + +// Internal code uses direct imports +// src/screens/Home.tsx +import Button from '../components/Button'; +``` + +## Migration Script Example + +```bash +# Use codemod or search-replace +# Find: import { (\w+) } from '\.\/components'; +# Replace: import $1 from './components/$1'; +``` + +## Verification + +After refactoring: + +1. Run bundle analysis (see [bundle-analyze-js.md](./bundle-analyze-js.md)) +2. Compare sizes before/after +3. Check for circular dependency warnings + +## Common Pitfalls + +- **Breaking external consumers**: If publishing a library, keep barrel for public API +- **IDE auto-imports**: Configure IDE to prefer direct imports +- **Inconsistent patterns**: Enforce with ESLint across team + +## Related Skills + +- [bundle-analyze-js.md](./bundle-analyze-js.md) - Verify impact +- [bundle-tree-shaking.md](./bundle-tree-shaking.md) - Automatic solution +- [bundle-library-size.md](./bundle-library-size.md) - Check library patterns diff --git a/.claude/skills/react-native-best-practices/references/bundle-code-splitting.md b/.claude/skills/react-native-best-practices/references/bundle-code-splitting.md new file mode 100644 index 0000000..9eb18f2 --- /dev/null +++ b/.claude/skills/react-native-best-practices/references/bundle-code-splitting.md @@ -0,0 +1,247 @@ +--- +title: Remote Code Loading +impact: MEDIUM +tags: code-splitting, repack, lazy-loading, chunks +--- + +# Skill: Remote Code Loading + +Set up code splitting with Re.Pack for on-demand bundle loading from trusted, first-party assets. + +## Quick Pattern + +**Before (static import):** + +```jsx +import SettingsScreen from './screens/SettingsScreen'; +``` + +**After (lazy loaded chunk):** + +```jsx +const SettingsScreen = React.lazy(() => + import(/* webpackChunkName: "settings" */ './screens/SettingsScreen') +); + +}> + + +``` + +## When to Use + +Consider code splitting when: +- **Not using Hermes** (JSC/V8 benefits more) +- App size exceeds 200 MB (Play Store limit) +- Building micro-frontend architecture +- Loading features based on user permissions +- Other optimizations exhausted + +**Note**: Hermes already uses memory mapping for efficient bundle reading. Benefits of code splitting are minimal with Hermes or even counterproductive in some cases. + +## Security Model + +Remote chunks are executable application code. Only load chunks that you build and publish yourself. + +Keep these guardrails in place: +- Serve chunks only from a first-party, HTTPS-only origin you control +- Resolve `scriptId` through a fixed allowlist or release manifest +- Fail closed if a chunk is missing or unexpected +- Do not load chunks from user-controlled input, query params, or third-party domains + +## Prerequisites + +- Re.Pack installed (replaces Metro) + +```bash +npx @callstack/repack-init +``` + +## Step-by-Step Instructions + +### 1. Initialize Re.Pack + +```bash +npx @callstack/repack-init +``` + +Follow prompts to migrate from Metro. Check [migration guide](https://re-pack.dev/docs/getting-started/quick-start). + +### 2. Create Split Point with React.lazy + +```tsx +// BEFORE: Static import +import SettingsScreen from './screens/SettingsScreen'; + +// AFTER: Dynamic import (creates split point) +const SettingsScreen = React.lazy(() => + import(/* webpackChunkName: "settings" */ './screens/SettingsScreen') +); +``` + +### 3. Wrap with Suspense + +```tsx +import React, { Suspense } from 'react'; + +const App = () => { + return ( + }> + + + ); +}; +``` + +### 4. Configure Chunk Loading + +```jsx +// index.js (before AppRegistry) +import { ScriptManager, Script } from '@callstack/repack/client'; + +const CHUNK_URLS = { + settings: 'https://assets.example.com/app/v42/settings.chunk.bundle', +}; + +ScriptManager.shared.addResolver((scriptId) => ({ + url: __DEV__ ? Script.getDevServerURL(scriptId) : getChunkUrl(scriptId), +})); + +function getChunkUrl(scriptId) { + const url = CHUNK_URLS[scriptId]; + + if (!url) { + throw new Error(`Unknown chunk: ${scriptId}`); + } + + return url; +} + +AppRegistry.registerComponent(appName, () => App); +``` + +### 5. Build and Deploy Chunks + +Build generates: +- `index.bundle` - Main bundle +- `settings.chunk.bundle` - Lazy-loaded chunk + +Deploy chunks to a first-party CDN with versioned paths, and keep the allowlist or manifest in sync with the app release. + +## Complete Example + +```tsx +// App.tsx +import React, { Suspense, useState } from 'react'; +import { Button, View, ActivityIndicator } from 'react-native'; + +// Lazy load heavy feature +const HeavyFeature = React.lazy(() => + import(/* webpackChunkName: "heavy-feature" */ './HeavyFeature') +); + +const App = () => { + const [showFeature, setShowFeature] = useState(false); + + return ( + + + + ); + } + + return ( + fetchBoard(callId)} />} contentContainerStyle={{ padding: 16 }}> + + + ); + }; + + return ( + + + + {renderContent()} + + ); +} diff --git a/src/app/routes/_layout.tsx b/src/app/routes/_layout.tsx deleted file mode 100644 index 1a471bb..0000000 --- a/src/app/routes/_layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Stack } from 'expo-router'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -export default function RoutesLayout() { - const { t } = useTranslation(); - - return ( - - - - - - - - - - - - ); -} diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx deleted file mode 100644 index 8cd264a..0000000 --- a/src/app/routes/active.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Compass, LogOut, Navigation, SkipForward } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Alert, Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; - -import Mapbox from '@/components/maps/mapbox'; -import { RouteDeviationBanner } from '@/components/routes/route-deviation-banner'; -import { StopCard } from '@/components/routes/stop-card'; -import { StopMarker } from '@/components/routes/stop-marker'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { Env } from '@/lib/env'; -import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useRoutesStore } from '@/stores/routes/store'; - -Mapbox.setAccessToken(Env.IC_MAPBOX_PUBKEY); - -const POLL_INTERVAL_MS = 30_000; -const GEOFENCE_CIRCLE_STEPS = 64; - -/** - * Parse route geometry string into a GeoJSON Feature suitable for Mapbox rendering. - */ -const parseRouteGeometry = (geometry: string): GeoJSON.Feature | null => { - if (!geometry) return null; - try { - const parsed = JSON.parse(geometry); - if (parsed.type === 'Feature' || parsed.type === 'FeatureCollection') return parsed; - if (parsed.type === 'LineString' || parsed.type === 'MultiLineString') { - return { type: 'Feature', properties: {}, geometry: parsed }; - } - if (Array.isArray(parsed)) { - return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: parsed } }; - } - return null; - } catch { - return null; - } -}; - -/** - * Build a GeoJSON circle polygon for a geofence around a coordinate. - */ -const buildGeofenceCircle = (lng: number, lat: number, radiusMeters: number, steps: number = GEOFENCE_CIRCLE_STEPS): GeoJSON.Feature => { - const coords: number[][] = []; - const distanceLat = radiusMeters / 111_320; - const distanceLng = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180)); - - for (let i = 0; i <= steps; i++) { - const angle = (i / steps) * 2 * Math.PI; - coords.push([lng + distanceLng * Math.cos(angle), lat + distanceLat * Math.sin(angle)]); - } - - return { - type: 'Feature', - properties: {}, - geometry: { type: 'Polygon', coordinates: [coords] }, - }; -}; - -export default function ActiveRouteScreen() { - const { t } = useTranslation(); - const router = useRouter(); - const { planId, instanceId } = useLocalSearchParams<{ planId: string; instanceId: string }>(); - const cameraRef = useRef(null); - const [isMapReady, setIsMapReady] = useState(false); - const [skipModalVisible, setSkipModalVisible] = useState(false); - const [skipReason, setSkipReason] = useState(''); - - // --- Stores --- - const activeUnitId = useCoreStore((s) => s.activeUnitId); - const latitude = useLocationStore((s) => s.latitude); - const longitude = useLocationStore((s) => s.longitude); - - const activeInstance = useRoutesStore((s) => s.activeInstance); - const instanceStops = useRoutesStore((s) => s.instanceStops); - const directions = useRoutesStore((s) => s.directions); - const deviations = useRoutesStore((s) => s.deviations); - const isLoadingStops = useRoutesStore((s) => s.isLoadingStops); - const fetchStopsForInstance = useRoutesStore((s) => s.fetchStopsForInstance); - const fetchDirections = useRoutesStore((s) => s.fetchDirections); - const fetchRouteProgress = useRoutesStore((s) => s.fetchRouteProgress); - const fetchDeviations = useRoutesStore((s) => s.fetchDeviations); - const endRouteInstance = useRoutesStore((s) => s.endRouteInstance); - const checkIn = useRoutesStore((s) => s.checkIn); - const checkOut = useRoutesStore((s) => s.checkOut); - const skip = useRoutesStore((s) => s.skip); - const ackDeviation = useRoutesStore((s) => s.ackDeviation); - - // instanceId may be absent when navigating from start.tsx — fall back to store. - // Guard against the literal string "undefined" that can appear in URL params. - const resolvedInstanceId = (() => { - const id = instanceId && instanceId !== 'undefined' ? instanceId : activeInstance?.RouteInstanceId; - return id || undefined; - })(); - - // --- Derived data --- - const currentStop = useMemo(() => instanceStops.find((s) => s.Status === RouteStopStatus.Pending || s.Status === RouteStopStatus.InProgress) ?? null, [instanceStops]); - - const routeColor = activeInstance?.RouteColor || '#3b82f6'; - - const progressPercent = useMemo(() => { - if (instanceStops.length === 0) { - const total = activeInstance?.StopsTotal ?? 0; - const completed = activeInstance?.StopsCompleted ?? 0; - return total > 0 ? Math.round((completed / total) * 100) : 0; - } - const done = instanceStops.filter((s) => s.Status === RouteStopStatus.Completed || s.Status === RouteStopStatus.Skipped).length; - return Math.round((done / instanceStops.length) * 100); - }, [instanceStops, activeInstance]); - - const routeGeoJSON = useMemo(() => { - // Prefer directions geometry, fall back to instance actual route geometry - if (directions?.Geometry) { - return parseRouteGeometry(directions.Geometry); - } - if (activeInstance?.ActualRouteGeometry) { - return parseRouteGeometry(activeInstance.ActualRouteGeometry); - } - return null; - }, [directions?.Geometry, activeInstance?.ActualRouteGeometry]); - - const geofenceGeoJSON = useMemo(() => { - if (!currentStop) return null; - if (!currentStop.Longitude || !currentStop.Latitude || !isFinite(currentStop.Longitude) || !isFinite(currentStop.Latitude)) return null; - const radius = currentStop.GeofenceRadiusMeters || 100; - return buildGeofenceCircle(currentStop.Longitude, currentStop.Latitude, radius); - }, [currentStop]); - - // --- Initial data fetch --- - useEffect(() => { - if (!resolvedInstanceId) return; - fetchRouteProgress(resolvedInstanceId); - fetchStopsForInstance(resolvedInstanceId); - fetchDirections(resolvedInstanceId); - fetchDeviations(); - }, [resolvedInstanceId, fetchRouteProgress, fetchStopsForInstance, fetchDirections, fetchDeviations]); - - // --- Polling for progress --- - useEffect(() => { - if (!resolvedInstanceId) return; - const interval = setInterval(() => { - fetchRouteProgress(resolvedInstanceId); - fetchStopsForInstance(resolvedInstanceId); - fetchDeviations(); - }, POLL_INTERVAL_MS); - return () => clearInterval(interval); - }, [resolvedInstanceId, fetchRouteProgress, fetchStopsForInstance, fetchDeviations]); - - // --- Center on user location as soon as map is ready --- - useEffect(() => { - if (!isMapReady || !cameraRef.current) return; - if (latitude != null && longitude != null) { - cameraRef.current.flyTo([longitude, latitude], 500); - } - }, [isMapReady]); // eslint-disable-line react-hooks/exhaustive-deps - - // --- Fit bounds to stops + user location once stops are loaded --- - useEffect(() => { - if (!isMapReady || instanceStops.length === 0 || !cameraRef.current) return; - - const validStops = instanceStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)); - if (validStops.length === 0) return; - - const lngs = validStops.map((s) => s.Longitude); - const lats = validStops.map((s) => s.Latitude); - - if (latitude != null && longitude != null && isFinite(latitude) && isFinite(longitude)) { - lngs.push(longitude); - lats.push(latitude); - } - - const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; - const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; - - cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 800); - }, [isMapReady, instanceStops]); // eslint-disable-line react-hooks/exhaustive-deps - - // --- Actions --- - const handleCheckIn = useCallback(() => { - if (!currentStop || !activeUnitId) return; - checkIn(currentStop.RouteInstanceStopId, activeUnitId, latitude ?? 0, longitude ?? 0); - }, [currentStop, activeUnitId, latitude, longitude, checkIn]); - - const handleCheckOut = useCallback(() => { - if (!currentStop || !activeUnitId) return; - checkOut(currentStop.RouteInstanceStopId, activeUnitId); - }, [currentStop, activeUnitId, checkOut]); - - const handleSkip = useCallback(() => { - if (!currentStop) return; - setSkipReason(''); - setSkipModalVisible(true); - }, [currentStop]); - - const handleSkipConfirm = useCallback(() => { - if (!currentStop) return; - setSkipModalVisible(false); - skip(currentStop.RouteInstanceStopId, skipReason.trim() || t('routes.skipped_by_driver')); - }, [currentStop, skipReason, skip, t]); - - const handleEndRoute = useCallback(() => { - if (!resolvedInstanceId) return; - Alert.alert(t('routes.end_route'), t('routes.end_route_confirm'), [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: t('routes.end_route'), - style: 'destructive', - onPress: async () => { - try { - await endRouteInstance(resolvedInstanceId); - router.back(); - } catch { - Alert.alert(t('common.error'), t('common.errorOccurred')); - } - }, - }, - ]); - }, [resolvedInstanceId, endRouteInstance, router, t]); - - const handleDirections = useCallback(() => { - if (!resolvedInstanceId) return; - router.push(`/routes/directions?instanceId=${resolvedInstanceId}`); - }, [resolvedInstanceId, router]); - - const handleDeviationPress = useCallback(() => { - // Could navigate to a deviations detail screen in the future - }, []); - - return ( - - {/* Map area - 60% height */} - - setIsMapReady(true)}> - - - {/* Route polyline */} - {routeGeoJSON ? ( - - - - ) : null} - - {/* Geofence circle around next pending stop */} - {geofenceGeoJSON ? ( - - - - - ) : null} - - {/* Stop markers */} - {instanceStops - .filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)) - .map((stop) => ( - - - - ))} - - - {/* Map overlay buttons */} - - - - - - - - {/* Progress indicator */} - {activeInstance ? ( - - - {progressPercent}% {t('routes.completed')} - - - ) : null} - - - {/* Bottom area */} - - - {/* Deviation banner */} - {deviations.length > 0 ? ( - - - - ) : null} - - {/* Current stop card */} - {currentStop ? ( - - {t('routes.current_step')} - - - ) : ( - - {t('routes.stops_completed')} - - )} - - {/* ETA to next stop */} - {activeInstance?.EtaToNextStop ? ( - - - - - {t('routes.eta_to_next')}: {activeInstance.EtaToNextStop} - - - - ) : null} - - {/* Directions button */} - - - - - {/* Stop list */} - - {t('routes.stops')} - {instanceStops.map((stop) => ( - - ))} - - - {/* End Route button */} - - - - - - - {/* Skip reason modal */} - setSkipModalVisible(false)}> - - - - {t('routes.skip')} — {currentStop?.Name} - - {t('routes.skip_reason')} - - - setSkipModalVisible(false)}> - {t('common.cancel')} - - - {t('routes.skip')} - - - - - - - ); -} - -const styles = StyleSheet.create({ - map: { - flex: 1, - }, - skipInput: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - padding: 10, - fontSize: 14, - color: '#111827', - textAlignVertical: 'top', - minHeight: 80, - }, - cancelBtn: { - flex: 1, - paddingVertical: 10, - borderRadius: 8, - borderWidth: 1, - borderColor: '#d1d5db', - alignItems: 'center', - }, - skipBtn: { - flex: 1, - paddingVertical: 10, - borderRadius: 8, - backgroundColor: '#eab308', - alignItems: 'center', - }, -}); diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx deleted file mode 100644 index 822e536..0000000 --- a/src/app/routes/directions.tsx +++ /dev/null @@ -1,874 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import { AlertTriangleIcon, CheckCircleIcon, ClockIcon, ExternalLinkIcon, FlagIcon, MapIcon, MapPinIcon, NavigationIcon, PlayIcon, TimerIcon, TrafficConeIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Linking, Platform, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL, UserLocation } from '@/components/maps/mapbox'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; -import { HStack } from '@/components/ui/hstack'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { Env } from '@/lib/env'; -import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; -import { useRoutesStore } from '@/stores/routes/store'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const MAPBOX_DIRECTIONS_API = 'https://api.mapbox.com/directions/v5/mapbox/driving-traffic'; -const MAPBOX_GEOCODING_API = 'https://api.mapbox.com/geocoding/v5/mapbox.places'; - -const DRIVING_PROFILE = 'driving-traffic'; // uses traffic-aware routing - -const statusColor: Record = { - [RouteStopStatus.Pending]: '#9ca3af', - [RouteStopStatus.InProgress]: '#3b82f6', - [RouteStopStatus.Completed]: '#22c55e', - [RouteStopStatus.Skipped]: '#eab308', -}; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface DirectionsSegment { - /** GeoJSON LineString coordinates for this segment */ - coordinates: [number, number][]; - /** Distance in meters */ - distance: number; - /** Duration in seconds (with traffic) */ - duration: number; - /** Typical duration without traffic */ - durationTypical?: number; -} - -interface RouteDirectionsInfo { - totalDistance: number; - totalDuration: number; - totalDurationTypical: number; - segments: DirectionsSegment[]; - /** Concatenated GeoJSON for rendering */ - routeGeoJSON: GeoJSON.Feature; - /** Congestion segments extracted from Mapbox response */ - congestion: CongestionSegment[]; -} - -interface CongestionSegment { - coordinate: [number, number]; - level: 'low' | 'moderate' | 'heavy' | 'severe'; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const openInMaps = (lat: number, lon: number, label: string) => { - const url = Platform.select({ - ios: `maps://app?daddr=${lat},${lon}&q=${encodeURIComponent(label)}`, - android: `google.navigation:q=${lat},${lon}`, - default: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`, - }); - if (url) Linking.openURL(url); -}; - -const formatDistance = (meters: number | null | undefined): string => { - if (meters == null) return ''; - if (meters < 1000) return `${Math.round(meters)} m`; - return `${(meters / 1000).toFixed(1)} km`; -}; - -const formatDuration = (seconds: number | null | undefined): string => { - if (seconds == null) return ''; - if (seconds < 60) return `${Math.round(seconds)}s`; - const mins = Math.round(seconds / 60); - if (mins < 60) return `${mins} min`; - const hours = Math.floor(mins / 60); - return `${hours}h ${mins % 60}m`; -}; - -/** Map congestion level string from the Mapbox API to a color */ -const congestionColor = (level: string): string => { - switch (level) { - case 'low': - return '#22c55e'; // green - case 'moderate': - return '#eab308'; // yellow - case 'heavy': - return '#f97316'; // orange - case 'severe': - return '#ef4444'; // red - default: - return '#3b82f6'; // blue (unknown / no data) - } -}; - -/** Derive a human-readable driving condition summary from congestion data */ -const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: string; color: string; icon: typeof TrafficConeIcon } => { - if (congestion.length === 0) { - return { label: 'No traffic data', color: '#9ca3af', icon: TrafficConeIcon }; - } - - const counts = { low: 0, moderate: 0, heavy: 0, severe: 0 }; - for (const seg of congestion) { - counts[seg.level]++; - } - const total = congestion.length; - - if (counts.severe / total > 0.15) { - return { label: 'Severe traffic', color: '#ef4444', icon: AlertTriangleIcon }; - } - if (counts.heavy / total > 0.2) { - return { label: 'Heavy traffic', color: '#f97316', icon: AlertTriangleIcon }; - } - if (counts.moderate / total > 0.3) { - return { label: 'Moderate traffic', color: '#eab308', icon: TrafficConeIcon }; - } - return { label: 'Light traffic', color: '#22c55e', icon: TrafficConeIcon }; -}; - -// --------------------------------------------------------------------------- -// Mapbox Directions API fetcher -// --------------------------------------------------------------------------- - -/** - * Fetches driving directions from the Mapbox Directions API for a set of - * coordinate waypoints (stop locations). Requests traffic-aware durations - * and congestion annotations. - * - * Returns parsed route geometry, durations, and congestion data, or null - * on failure (e.g., missing API key, network error). - */ -async function fetchMapboxDirections(waypoints: [number, number][]): Promise { - if (waypoints.length < 2) return null; - - const token = Env.IC_MAPBOX_PUBKEY; - if (!token) return null; - - // Build the coordinate string for Mapbox Directions API - // Format: lng1,lat1;lng2,lat2;... - const coords = waypoints.map(([lng, lat]) => `${lng},${lat}`).join(';'); - - const url = - `${MAPBOX_DIRECTIONS_API}/${coords}` + `?access_token=${token}` + `&geometries=geojson` + `&overview=full` + `&annotations=congestion,duration,distance` + `&steps=true` + `&continue_straight=true` + `&language=en`; - - try { - const response = await fetch(url); - if (!response.ok) return null; - - const data = await response.json(); - if (!data.routes || data.routes.length === 0) return null; - - const route = data.routes[0]; - - // Extract geometry coordinates - const routeCoords: [number, number][] = route.geometry?.coordinates ?? []; - - // Extract leg-level distance/duration - const segments: DirectionsSegment[] = []; - let totalDistance = 0; - let totalDuration = 0; - let totalDurationTypical = 0; - - for (const leg of route.legs ?? []) { - const legDistance: number = leg.distance ?? 0; - const legDuration: number = leg.duration ?? 0; - // Mapbox doesn't always return duration_typical, use duration as fallback - const legDurationTypical: number = leg.duration_typical ?? legDuration; - - totalDistance += legDistance; - totalDuration += legDuration; - totalDurationTypical += legDurationTypical; - - // Collect congestion annotations from the leg's annotation - const annotationCongestion: string[] = leg.annotation?.congestion ?? []; - - // Extract coordinates for this leg's segment from step geometries - let segmentCoords: [number, number][] = []; - for (const step of leg.steps ?? []) { - const stepCoords = step.geometry?.coordinates ?? []; - segmentCoords = segmentCoords.concat(stepCoords); - } - - segments.push({ - coordinates: segmentCoords.length > 0 ? segmentCoords : [], - distance: legDistance, - duration: legDuration, - durationTypical: legDurationTypical, - }); - } - - // Build congestion segments from the annotation - const congestion: CongestionSegment[] = []; - if (route.legs) { - for (const leg of route.legs) { - const annotationCongestion: string[] = leg.annotation?.congestion ?? []; - // Each annotation entry corresponds to a coordinate pair between nodes - // We sample along the leg to build congestion segments - const legCoords: [number, number][] = []; - for (const step of leg.steps ?? []) { - const stepCoords = step.geometry?.coordinates ?? []; - legCoords.push(...stepCoords); - } - // Map congestion annotations to coordinates (one per edge) - const edgesCount = Math.min(annotationCongestion.length, legCoords.length - 1); - for (let i = 0; i < edgesCount; i++) { - const level = annotationCongestion[i]; - if (level && level !== 'unknown') { - congestion.push({ - coordinate: legCoords[i], - level: level as CongestionSegment['level'], - }); - } - } - } - } - - // Build GeoJSON Feature from the full route geometry - const routeGeoJSON: GeoJSON.Feature = { - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: routeCoords, - }, - }; - - return { - totalDistance, - totalDuration, - totalDurationTypical, - segments, - routeGeoJSON, - congestion, - }; - } catch { - return null; - } -} - -/** - * Build a fallback straight-line GeoJSON from stops when directions API is unavailable. - */ -function buildFallbackRoute(stops: { Longitude: number; Latitude: number; StopOrder: number }[]): GeoJSON.Feature { - const sorted = [...stops].sort((a, b) => a.StopOrder - b.StopOrder); - return { - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: sorted.map((s) => [s.Longitude, s.Latitude] as [number, number]), - }, - }; -} - -// --------------------------------------------------------------------------- -// Components -// --------------------------------------------------------------------------- - -/** Start marker: green with play icon label */ -function StartMarker({ name }: { name: string }) { - return ( - - - - - - - {name} - - - - ); -} - -/** End marker: red with flag icon label */ -function EndMarker({ name }: { name: string }) { - return ( - - - - - - - {name} - - - - ); -} - -/** Intermediate stop marker: numbered circle */ -function IntermediateStopMarker({ order, color }: { order: number; color: string }) { - return ( - - - {order} - - - ); -} - -const markerStyles = StyleSheet.create({ - container: { - alignItems: 'center', - }, - pin: { - width: 34, - height: 34, - borderRadius: 17, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 3, - borderColor: '#ffffff', - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 3, - }, - circle: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 2.5, - borderColor: '#ffffff', - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.25, - shadowRadius: 2, - }, - circleText: { - color: '#ffffff', - fontSize: 12, - fontWeight: 'bold', - }, - labelContainer: { - marginTop: 2, - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - maxWidth: 120, - }, - labelText: { - color: '#ffffff', - fontSize: 10, - fontWeight: '600', - textAlign: 'center', - }, -}); - -// --------------------------------------------------------------------------- -// Main screen -// --------------------------------------------------------------------------- - -export default function RouteDirectionsScreen() { - const { t } = useTranslation(); - const { instanceId } = useLocalSearchParams<{ instanceId: string }>(); - const { colorScheme } = useColorScheme(); - const cameraRef = useRef(null); - const [isMapReady, setIsMapReady] = useState(false); - const [isFetchingDirections, setIsFetchingDirections] = useState(false); - const [mapboxDirections, setMapboxDirections] = useState(null); - - const directions = useRoutesStore((s) => s.directions); - const isLoadingDirections = useRoutesStore((s) => s.isLoadingDirections); - const error = useRoutesStore((s) => s.error); - const fetchDirections = useRoutesStore((s) => s.fetchDirections); - const activeInstance = useRoutesStore((s) => s.activeInstance); - const instanceStops = useRoutesStore((s) => s.instanceStops); - const fetchStopsForInstance = useRoutesStore((s) => s.fetchStopsForInstance); - - const resolvedInstanceId = (() => { - const id = instanceId && instanceId !== 'undefined' ? instanceId : activeInstance?.RouteInstanceId; - return id || undefined; - })(); - - // Fetch backend directions and stops - useEffect(() => { - if (resolvedInstanceId) { - fetchDirections(resolvedInstanceId); - fetchStopsForInstance(resolvedInstanceId); - } - }, [resolvedInstanceId, fetchDirections, fetchStopsForInstance]); - - // Sorted stops for display - const sortedStops = useMemo(() => [...instanceStops].sort((a, b) => a.StopOrder - b.StopOrder), [instanceStops]); - - // Valid stops with coordinates - const validStops = useMemo(() => sortedStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)), [sortedStops]); - - // First and last stops for distinct markers - const startStop = validStops.length > 0 ? validStops[0] : null; - const endStop = validStops.length > 1 ? validStops[validStops.length - 1] : null; - - // Intermediate stops (everything except first and last) - const intermediateStops = useMemo(() => { - if (validStops.length <= 2) return []; - return validStops.slice(1, -1); - }, [validStops]); - - // Fetch real driving directions from Mapbox API - useEffect(() => { - if (validStops.length < 2) return; - - let cancelled = false; - - const fetchDrivingDirections = async () => { - setIsFetchingDirections(true); - const waypoints: [number, number][] = validStops.map((s) => [s.Longitude, s.Latitude]); - - const result = await fetchMapboxDirections(waypoints); - - if (!cancelled) { - setMapboxDirections(result); - setIsFetchingDirections(false); - } - }; - - fetchDrivingDirections(); - - return () => { - cancelled = true; - }; - }, [validStops]); - - // Build the route GeoJSON: prefer Mapbox directions, fallback to backend, then straight-line - const routeGeoJson = useMemo((): GeoJSON.Feature | null => { - // 1. Best: Mapbox driving directions with real road geometry - if (mapboxDirections?.routeGeoJSON) { - return mapboxDirections.routeGeoJSON; - } - - // 2. Server-side directions geometry - if (directions?.Geometry) { - try { - const parsed = JSON.parse(directions.Geometry); - return parsed; - } catch { - // fall through - } - } - - // 3. Fallback: straight-line connections between stops - if (validStops.length >= 2) { - return buildFallbackRoute(validStops); - } - - return null; - }, [mapboxDirections, directions, validStops]); - - // Congestion overlay GeoJSON: colored line segments by traffic level - const congestionGeoJSON = useMemo((): GeoJSON.FeatureCollection | null => { - if (!mapboxDirections || mapboxDirections.congestion.length === 0) return null; - - // Group consecutive congestion points into features - const features: GeoJSON.Feature[] = []; - let currentLevel: string | null = null; - let currentCoords: [number, number][] = []; - - for (const seg of mapboxDirections.congestion) { - if (seg.level !== currentLevel) { - if (currentCoords.length >= 2 && currentLevel) { - features.push({ - type: 'Feature', - properties: { level: currentLevel, color: congestionColor(currentLevel) }, - geometry: { type: 'LineString', coordinates: currentCoords }, - }); - } - currentLevel = seg.level; - currentCoords = [seg.coordinate]; - } else { - currentCoords.push(seg.coordinate); - } - } - - // Flush last segment - if (currentCoords.length >= 2 && currentLevel) { - features.push({ - type: 'Feature', - properties: { level: currentLevel, color: congestionColor(currentLevel) }, - geometry: { type: 'LineString', coordinates: currentCoords }, - }); - } - - if (features.length === 0) return null; - - return { - type: 'FeatureCollection', - features, - }; - }, [mapboxDirections]); - - // Distance: prefer Mapbox directions (more accurate), fallback to server - const estimatedDistance = useMemo(() => { - if (mapboxDirections?.totalDistance != null && mapboxDirections.totalDistance > 0) { - return mapboxDirections.totalDistance; - } - return directions?.EstimatedDistanceMeters ?? null; - }, [mapboxDirections, directions]); - - // Duration: prefer Mapbox directions (traffic-aware), fallback to server - const estimatedDuration = useMemo(() => { - if (mapboxDirections?.totalDuration != null && mapboxDirections.totalDuration > 0) { - return mapboxDirections.totalDuration; - } - return directions?.EstimatedDurationSeconds ?? null; - }, [mapboxDirections, directions]); - - // Typical duration (without traffic) — for comparison - const typicalDuration = useMemo(() => { - if (mapboxDirections?.totalDurationTypical != null && mapboxDirections.totalDurationTypical > 0) { - return mapboxDirections.totalDurationTypical; - } - return null; - }, [mapboxDirections]); - - // Traffic delay - const trafficDelaySeconds = useMemo(() => { - if (estimatedDuration != null && typicalDuration != null && typicalDuration > 0) { - const delay = estimatedDuration - typicalDuration; - return delay > 30 ? delay : 0; // only show meaningful delays (>30s) - } - return null; - }, [estimatedDuration, typicalDuration]); - - // Driving conditions summary - const drivingCondition = useMemo(() => { - if (mapboxDirections?.congestion) { - return deriveDrivingCondition(mapboxDirections.congestion); - } - return null; - }, [mapboxDirections]); - - // ETA (arrival time) - const eta = useMemo(() => { - if (estimatedDuration == null) return null; - const arrivalTime = new Date(Date.now() + estimatedDuration * 1000); - return arrivalTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }, [estimatedDuration]); - - // Destination for "Open in Maps" - const destination = useMemo(() => { - const last = sortedStops[sortedStops.length - 1]; - if (last?.Latitude != null && last?.Longitude != null) { - return { lat: last.Latitude, lon: last.Longitude, name: last.Name }; - } - return null; - }, [sortedStops]); - - // --------------------------------------------------------------------------- - // Camera: imperative fitBounds matching the active route screen pattern - // --------------------------------------------------------------------------- - - // Once the map is ready AND stops are loaded, fit bounds to show all stops. - // If Mapbox driving directions are later loaded, re-fit to the full route. - useEffect(() => { - if (!isMapReady || validStops.length === 0 || !cameraRef.current) return; - - let lngs = validStops.map((s) => s.Longitude); - let lats = validStops.map((s) => s.Latitude); - - // Prefer fitting to full driving route geometry when available - if (mapboxDirections?.routeGeoJSON) { - const geom = mapboxDirections.routeGeoJSON.geometry as GeoJSON.LineString; - if (geom?.coordinates && geom.coordinates.length > 1) { - lngs = geom.coordinates.map((c: number[]) => c[0]); - lats = geom.coordinates.map((c: number[]) => c[1]); - } - } - - const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; - const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; - - // Padding: [top, right, bottom, left] — extra bottom for the overlay panel - cameraRef.current.fitBounds(ne, sw, [60, 60, 300, 60], 800); - }, [isMapReady, validStops, mapboxDirections]); // eslint-disable-line react-hooks/exhaustive-deps - - const handleOpenInMaps = useCallback(() => { - if (destination) { - openInMaps(destination.lat, destination.lon, destination.name ?? t('routes.destination')); - } - }, [destination, t]); - - // --- Loading states --- - if (isLoadingDirections && sortedStops.length === 0) { - return ( - - - - ); - } - - if (sortedStops.length === 0) { - return ( - - - {error ?? t('routes.no_directions')} - - ); - } - - return ( - - {/* Map */} - - setIsMapReady(true)}> - - {/* Bare Camera — positioned imperatively via fitBounds once isMapReady && validStops */} - - {/* Route line (driving geometry) */} - {routeGeoJson ? ( - - {/* Shadow/border line underneath */} - - {/* Main route line */} - - - ) : null} - - {/* Congestion overlay */} - {congestionGeoJSON ? ( - - - - ) : null} - - {/* Start marker */} - {startStop ? ( - - - - ) : null} - - {/* End marker (only if different from start) */} - {endStop && endStop.RouteInstanceStopId !== startStop?.RouteInstanceStopId ? ( - - - - ) : null} - - {/* Intermediate stop markers */} - {intermediateStops.map((stop) => ( - - - - ))} - - - {/* Loading overlay while fetching driving directions */} - {isFetchingDirections ? ( - - - {t('routes.fetching_directions')} - - ) : null} - - - {/* Bottom panel */} - - {/* Summary bar: distance, duration, ETA, driving conditions */} - - - {/* Distance */} - {estimatedDistance != null ? ( - - - - {formatDistance(estimatedDistance)} - - {t('routes.distance')} - - ) : null} - - {/* Duration with traffic */} - {estimatedDuration != null ? ( - - - - {formatDuration(estimatedDuration)} - - {t('routes.duration')} - - ) : null} - - {/* ETA */} - {eta ? ( - - - - {eta} - - {t('routes.eta')} - - ) : null} - - {/* Driving conditions */} - {drivingCondition ? ( - - - - {drivingCondition.label} - - {trafficDelaySeconds != null && trafficDelaySeconds > 0 ? ( - - +{formatDuration(trafficDelaySeconds)} {t('routes.delay')} - - ) : ( - {t('routes.driving_conditions')} - )} - - ) : null} - - - {/* Traffic delay bar — only when there's a notable delay */} - {trafficDelaySeconds != null && trafficDelaySeconds > 60 ? ( - - - {t('routes.traffic_delay', { time: formatDuration(trafficDelaySeconds) })} - - ) : null} - - - {/* Stops list */} - - {sortedStops.map((stop, index) => { - const color = statusColor[stop.Status] ?? '#9ca3af'; - const isLast = index === sortedStops.length - 1; - const isFirst = index === 0; - const isStopLast = isLast && sortedStops.length > 1; - const stopLabel = isFirst ? t('routes.start') : isStopLast ? t('routes.end') : `#${stop.StopOrder}`; - - return ( - - {/* Order badge with distinct styling for start/end */} - - {isFirst ? : isStopLast ? : {stop.StopOrder}} - - - - - {stop.Name} - {isFirst ? ( - - {t('routes.start').toUpperCase()} - - ) : null} - {isStopLast ? ( - - {t('routes.end').toUpperCase()} - - ) : null} - - {stop.Address ? ( - - - - {stop.Address} - - - ) : null} - {/* Segment distance/duration from Mapbox if available */} - {mapboxDirections?.segments[index] ? ( - - - {formatDistance(mapboxDirections.segments[index].distance)} · {formatDuration(mapboxDirections.segments[index].duration)} - - - ) : null} - - - {stop.Status === RouteStopStatus.Completed ? : stop.Status === RouteStopStatus.InProgress ? : null} - - - ); - })} - - - {/* Actions */} - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, - mapContainer: { flex: 1 }, - map: { flex: 1 }, - badge: { - width: 24, - height: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - marginTop: 1, - }, - fetchingOverlay: { - position: 'absolute', - top: 12, - left: 0, - right: 0, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.9)', - paddingVertical: 6, - marginHorizontal: 60, - borderRadius: 20, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.15, - shadowRadius: 3, - }, -}); diff --git a/src/app/routes/history/[planId].tsx b/src/app/routes/history/[planId].tsx deleted file mode 100644 index 408a1aa..0000000 --- a/src/app/routes/history/[planId].tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Clock, MapPin, Navigation, RefreshCcwDotIcon } from 'lucide-react-native'; -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, RefreshControl, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { FlatList } from '@/components/ui/flat-list'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type RouteInstanceResultData, RouteInstanceStatus } from '@/models/v4/routes/routeInstanceResultData'; -import { useRoutesStore } from '@/stores/routes/store'; - -const STATUS_COLORS: Record = { - [RouteInstanceStatus.Completed]: '#22c55e', - [RouteInstanceStatus.Cancelled]: '#ef4444', - [RouteInstanceStatus.Active]: '#3b82f6', - [RouteInstanceStatus.Paused]: '#eab308', - [RouteInstanceStatus.Pending]: '#9ca3af', -}; - -const STATUS_LABELS: Record = { - [RouteInstanceStatus.Pending]: 'Pending', - [RouteInstanceStatus.Active]: 'Active', - [RouteInstanceStatus.Paused]: 'Paused', - [RouteInstanceStatus.Completed]: 'Completed', - [RouteInstanceStatus.Cancelled]: 'Cancelled', -}; - -function formatDuration(seconds: number): string { - if (!seconds || seconds <= 0) return '--'; - const hrs = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (hrs > 0) return `${hrs}h ${mins}m`; - return `${mins}m`; -} - -function formatDistance(meters: number): string { - if (!meters || meters <= 0) return '--'; - if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; - return `${Math.round(meters)} m`; -} - -function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return '--'; - try { - const date = new Date(dateStr); - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return dateStr; - } -} - -export default function RouteHistory() { - const { t } = useTranslation(); - const { planId } = useLocalSearchParams<{ planId: string }>(); - const routeHistory = useRoutesStore((state) => state.routeHistory); - const fetchRouteHistory = useRoutesStore((state) => state.fetchRouteHistory); - const isLoading = useRoutesStore((state) => state.isLoading); - const error = useRoutesStore((state) => state.error); - - useFocusEffect( - useCallback(() => { - if (planId) { - fetchRouteHistory(planId); - } - }, [planId, fetchRouteHistory]) - ); - - const handleRefresh = () => { - if (planId) { - fetchRouteHistory(planId); - } - }; - - const handleInstancePress = (instance: RouteInstanceResultData) => { - router.push(`/routes/history/instance/${instance.RouteInstanceId}`); - }; - - const renderItem = ({ item }: { item: RouteInstanceResultData }) => { - const statusColor = STATUS_COLORS[item.Status] ?? '#9ca3af'; - const statusLabel = STATUS_LABELS[item.Status] ?? 'Unknown'; - const completedDate = item.CompletedOn || item.CancelledOn || item.StartedOn; - - return ( - handleInstancePress(item)}> - - - - {/* Date */} - - - {formatDate(completedDate)} - - - {/* Unit Name */} - {item.UnitName || t('routes.unit')} - - {/* Stats row */} - - - - {formatDistance(item.TotalDistanceMeters ?? 0)} - - - - - {formatDuration(item.TotalDurationSeconds ?? 0)} - - - - - {item.CurrentStopIndex ?? 0} stops - - - - - {/* Status badge */} - - {statusLabel} - - - - {/* Progress bar */} - {item.ProgressPercentage != null && item.ProgressPercentage > 0 && ( - - - - )} - - - ); - }; - - const renderContent = () => { - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - return ( - - testID="route-history-list" - data={routeHistory} - renderItem={renderItem} - keyExtractor={(item: RouteInstanceResultData) => item.RouteInstanceId} - refreshControl={} - ListEmptyComponent={} - contentContainerStyle={{ paddingBottom: 20 }} - /> - ); - }; - - return ( - - - {renderContent()} - - ); -} diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx deleted file mode 100644 index a347c08..0000000 --- a/src/app/routes/history/instance/[id].tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { Stack, useLocalSearchParams } from 'expo-router'; -import { AlertTriangle, CheckCircle, Clock, MapPin, Navigation, SkipForward, XCircle } from 'lucide-react-native'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; - -import { getRoutePlan, getRouteProgress, getStopsForInstance, getUnacknowledgedDeviations } from '@/api/routes/routes'; -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import Mapbox from '@/components/maps/mapbox'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type RouteDeviationResultData, RouteDeviationType } from '@/models/v4/routes/routeDeviationResultData'; -import { type RouteInstanceResultData, RouteInstanceStatus } from '@/models/v4/routes/routeInstanceResultData'; -import { type RouteInstanceStopResultData, RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; - -// --- Helpers --- - -const parseRouteGeometry = (geometry: string) => { - if (!geometry) return null; - try { - const parsed = JSON.parse(geometry); - if (parsed.type === 'Feature' || parsed.type === 'FeatureCollection') return parsed; - if (parsed.type === 'LineString' || parsed.type === 'MultiLineString') { - return { type: 'Feature', properties: {}, geometry: parsed }; - } - return null; - } catch { - return null; - } -}; - -const STATUS_COLORS: Record = { - [RouteInstanceStatus.Completed]: '#22c55e', - [RouteInstanceStatus.Cancelled]: '#ef4444', - [RouteInstanceStatus.Active]: '#3b82f6', - [RouteInstanceStatus.Paused]: '#eab308', - [RouteInstanceStatus.Pending]: '#9ca3af', -}; - -const STATUS_LABEL_KEYS: Record = { - [RouteInstanceStatus.Pending]: 'routes.pending', - [RouteInstanceStatus.Active]: 'routes.active', - [RouteInstanceStatus.Paused]: 'routes.paused', - [RouteInstanceStatus.Completed]: 'routes.completed', - [RouteInstanceStatus.Cancelled]: 'routes.cancel_route', -}; - -const STOP_STATUS_COLORS: Record = { - [RouteStopStatus.Pending]: '#9ca3af', - [RouteStopStatus.InProgress]: '#3b82f6', - [RouteStopStatus.Completed]: '#22c55e', - [RouteStopStatus.Skipped]: '#f59e0b', -}; - -const DEVIATION_TYPE_KEYS: Record = { - [RouteDeviationType.OffRoute]: 'routes.deviation_type_off_route', - [RouteDeviationType.MissedStop]: 'routes.deviation_type_missed_stop', - [RouteDeviationType.UnexpectedStop]: 'routes.deviation_type_unexpected_stop', - [RouteDeviationType.SpeedViolation]: 'routes.deviation_type_speed_violation', - [RouteDeviationType.Other]: 'routes.deviation_type_other', -}; - -function formatDuration(seconds: number): string { - if (!seconds || seconds <= 0) return '--'; - const hrs = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (hrs > 0) return `${hrs}h ${mins}m`; - return `${mins}m`; -} - -function formatDistance(meters: number): string { - if (!meters || meters <= 0) return '--'; - if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; - return `${Math.round(meters)} m`; -} - -function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return '--'; - try { - const date = new Date(dateStr); - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return dateStr; - } -} - -function getStopIcon(status: number) { - switch (status) { - case RouteStopStatus.Completed: - return CheckCircle; - case RouteStopStatus.Skipped: - return SkipForward; - case RouteStopStatus.InProgress: - return Navigation; - default: - return MapPin; - } -} - -// --- Component --- - -export default function RouteInstanceDetail() { - const { t } = useTranslation(); - const { id: instanceId } = useLocalSearchParams<{ id: string }>(); - // Local history state — never touches the global live-route slices - const [activeInstance, setActiveInstance] = useState(null); - const [instanceStops, setInstanceStops] = useState([]); - const [deviations, setDeviations] = useState([]); - const [activePlan, setActivePlan] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingStops, setIsLoadingStops] = useState(false); - const [error, setError] = useState(null); - - useFocusEffect( - useCallback(() => { - if (!instanceId) return; - setIsLoading(true); - setError(null); - getRouteProgress(instanceId) - .then((r) => setActiveInstance(r.Data)) - .catch(() => setError('Failed to load instance')) - .finally(() => setIsLoading(false)); - setIsLoadingStops(true); - getStopsForInstance(instanceId) - .then((r) => setInstanceStops(Array.isArray(r.Data) ? r.Data : [])) - .catch(() => {}) - .finally(() => setIsLoadingStops(false)); - getUnacknowledgedDeviations() - .then((r) => setDeviations(Array.isArray(r.Data) ? r.Data : [])) - .catch(() => {}); - }, [instanceId]) - ); - - // Fetch the plan when the instance loads so we can show planned geometry - useFocusEffect( - useCallback(() => { - if (activeInstance?.RoutePlanId) { - getRoutePlan(activeInstance.RoutePlanId) - .then((r) => setActivePlan(r.Data)) - .catch(() => {}); - } - }, [activeInstance?.RoutePlanId]) - ); - - // Parse route geometries - const actualRouteGeoJSON = useMemo(() => (activeInstance?.ActualRouteGeometry ? parseRouteGeometry(activeInstance.ActualRouteGeometry) : null), [activeInstance?.ActualRouteGeometry]); - - const plannedRouteGeoJSON = useMemo(() => (activePlan?.MapboxRouteGeometry ? parseRouteGeometry(activePlan.MapboxRouteGeometry) : null), [activePlan?.MapboxRouteGeometry]); - - // Build stop markers GeoJSON - const stopMarkersGeoJSON = useMemo(() => { - if (!instanceStops || instanceStops.length === 0) return null; - return { - type: 'FeatureCollection' as const, - features: instanceStops - .filter((s) => s.Latitude && s.Longitude) - .map((stop) => ({ - type: 'Feature' as const, - properties: { - id: stop.RouteInstanceStopId, - name: stop.Name || `Stop ${stop.StopOrder}`, - status: stop.Status, - color: STOP_STATUS_COLORS[stop.Status] ?? '#9ca3af', - }, - geometry: { - type: 'Point' as const, - coordinates: [stop.Longitude, stop.Latitude], - }, - })), - }; - }, [instanceStops]); - - // Calculate map bounds from actual route or stops - const mapBounds = useMemo(() => { - const coords: [number, number][] = []; - - // Collect coordinates from stops - instanceStops?.forEach((s) => { - if (s.Latitude && s.Longitude) { - coords.push([s.Longitude, s.Latitude]); - } - }); - - if (coords.length < 2) return null; - - const lngs = coords.map((c) => c[0]); - const lats = coords.map((c) => c[1]); - return { - ne: [Math.max(...lngs) + 0.005, Math.max(...lats) + 0.005] as [number, number], - sw: [Math.min(...lngs) - 0.005, Math.min(...lats) - 0.005] as [number, number], - }; - }, [instanceStops]); - - // Summary stats - const completedStops = instanceStops.filter((s) => s.Status === RouteStopStatus.Completed).length; - const totalStops = instanceStops.length; - const instanceDeviations = deviations.filter((d) => d.RouteInstanceId === instanceId); - - if (isLoading && !activeInstance) { - return ( - - - - - ); - } - - if (error && !activeInstance) { - return ( - - - - - ); - } - - if (!activeInstance) { - return ( - - - - - ); - } - - const statusColor = STATUS_COLORS[activeInstance.Status] ?? '#9ca3af'; - const statusLabel = t(STATUS_LABEL_KEYS[activeInstance.Status] ?? 'common.unknown'); - - return ( - - - - {/* Map */} - - - {/* Camera fitted to bounds */} - {mapBounds ? ( - - ) : ( - - )} - - {/* Planned route - dashed line */} - {plannedRouteGeoJSON && ( - - - - )} - - {/* Actual route - solid line */} - {actualRouteGeoJSON && ( - - - - )} - - {/* Stop markers color-coded by status */} - {stopMarkersGeoJSON && ( - - - - )} - - - - {/* Summary Stats */} - - - {activeInstance.RoutePlanName || t('routes.route_summary')} - - {statusLabel} - - - - - - - {formatDistance(activeInstance.TotalDistanceMeters ?? 0)} - {t('routes.distance')} - - - - - {formatDuration(activeInstance.TotalDurationSeconds ?? 0)} - {t('routes.duration')} - - - - - - {completedStops}/{totalStops} - - {t('routes.stops')} - - - - - {instanceDeviations.length} - {t('routes.deviations')} - - - - {/* Dates */} - - - {t('routes.in_progress')} - {formatDate(activeInstance.StartedOn)} - - {activeInstance.CompletedOn ? ( - - {t('routes.completed')} - {formatDate(activeInstance.CompletedOn)} - - ) : null} - {activeInstance.CancelledOn ? ( - - {t('routes.cancel_route')} - {formatDate(activeInstance.CancelledOn)} - - ) : null} - - - - {/* Stops List */} - {isLoadingStops ? ( - - - - ) : instanceStops.length > 0 ? ( - - - {t('routes.stops')} - - {[...instanceStops] - .sort((a, b) => a.StopOrder - b.StopOrder) - .map((stop) => ( - - ))} - - ) : null} - - {/* Deviations List */} - {instanceDeviations.length > 0 && ( - - - {t('routes.deviations')} - - {instanceDeviations.map((deviation) => ( - - ))} - - )} - - - ); -} - -// --- Sub-components --- - -function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { - const { t } = useTranslation(); - const statusColor = STOP_STATUS_COLORS[stop.Status] ?? '#9ca3af'; - const StopIcon = getStopIcon(stop.Status); - - return ( - - - - - - - {stop.Name || `Stop ${stop.StopOrder}`} - {stop.Address ? ( - - {stop.Address} - - ) : null} - - {stop.CheckedInOn ? ( - - {t('routes.check_in')}: {formatDate(stop.CheckedInOn)} - - ) : null} - {stop.CheckedOutOn ? ( - - {t('routes.check_out')}: {formatDate(stop.CheckedOutOn)} - - ) : null} - {stop.SkippedOn ? ( - - {t('routes.skipped')}: {formatDate(stop.SkippedOn)} - - ) : null} - - - - #{stop.StopOrder} - - - - ); -} - -function DeviationCard({ deviation }: { deviation: RouteDeviationResultData }) { - const { t } = useTranslation(); - const typeLabel = DEVIATION_TYPE_KEYS[deviation.Type] ? t(DEVIATION_TYPE_KEYS[deviation.Type]) : t('routes.deviation'); - - return ( - - - - - - {typeLabel} - {deviation.IsAcknowledged && ( - - {t('routes.acknowledge')} - - )} - - {deviation.Description ? {deviation.Description} : null} - {formatDate(deviation.OccurredOn)} - - - - ); -} diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx deleted file mode 100644 index ca68f02..0000000 --- a/src/app/routes/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import { RoutesHome } from '@/components/routes/routes-home'; - -export default function RouteList() { - return ; -} diff --git a/src/app/routes/poi/[id].tsx b/src/app/routes/poi/[id].tsx deleted file mode 100644 index 25ee61f..0000000 --- a/src/app/routes/poi/[id].tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import { MapPin, Navigation } from 'lucide-react-native'; -import React, { useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import StaticMap from '@/components/maps/static-map'; -import { StatusBottomSheet } from '@/components/status/status-bottom-sheet'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { openMapsWithDirections } from '@/lib/navigation'; -import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils'; -import { useLocationStore } from '@/stores/app/location-store'; -import { usePoisStore } from '@/stores/pois/store'; -import { useStatusBottomSheetStore } from '@/stores/status/store'; -import { useToastStore } from '@/stores/toast/store'; - -export default function PoiDetailScreen() { - const { t } = useTranslation(); - const { id } = useLocalSearchParams<{ id: string }>(); - const poiId = Array.isArray(id) ? id[0] : id; - const poiTypes = usePoisStore((state) => state.poiTypes); - const selectedPoi = usePoisStore((state) => state.selectedPoi); - const isLoadingDetail = usePoisStore((state) => state.isLoadingDetail); - const error = usePoisStore((state) => state.error); - const fetchPoi = usePoisStore((state) => state.fetchPoi); - const fetchPoiTypes = usePoisStore((state) => state.fetchPoiTypes); - const clearSelectedPoi = usePoisStore((state) => state.clearSelectedPoi); - const showToast = useToastStore((state) => state.showToast); - const openStatusBottomSheet = useStatusBottomSheetStore((state) => state.setIsOpen); - const setSelectedStatusPoi = useStatusBottomSheetStore((state) => state.setSelectedPoi); - const userLatitude = useLocationStore((state) => state.latitude); - const userLongitude = useLocationStore((state) => state.longitude); - - useEffect(() => { - fetchPoiTypes(); - if (poiId) { - fetchPoi(poiId); - } - - return () => { - clearSelectedPoi(); - }; - }, [clearSelectedPoi, fetchPoi, fetchPoiTypes, poiId]); - - const poiTypesById = useMemo(() => createPoiTypeMap(poiTypes), [poiTypes]); - const poi = selectedPoi && String(selectedPoi.PoiId) === String(poiId) ? selectedPoi : null; - - const handleRoute = async () => { - if (!poi) { - return; - } - - const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiSelectionLabel(poi, poiTypesById), userLatitude || undefined, userLongitude || undefined); - if (!success) { - showToast('error', t('routes.failed_to_open_poi_maps')); - } - }; - - const handleSetDestination = () => { - if (!poi || !destinationEnabled) { - return; - } - - setSelectedStatusPoi(poi); - openStatusBottomSheet(true); - }; - - if (isLoadingDetail && !poi) { - return ( - - - - ); - } - - if (!poi) { - return ; - } - - const displayName = getPoiDisplayName(poi, poiTypesById); - const poiTypeName = getPoiTypeName(poi, poiTypesById) || t('routes.poi_type_unknown'); - const selectionLabel = getPoiSelectionLabel(poi, poiTypesById); - const destinationEnabled = isPoiDestinationEnabled(poi, poiTypesById); - - return ( - <> - - - - {displayName} - - - {poiTypeName} - - {destinationEnabled ? ( - - {t('routes.poi_destination_enabled')} - - ) : null} - - - - - - - - {destinationEnabled ? ( - - ) : null} - - - - - {poi.Address ? ( - - {t('routes.poi_address')} - {poi.Address} - - ) : null} - - {poi.Note ? ( - - {t('routes.poi_note')} - {poi.Note} - - ) : null} - - - {t('routes.poi_coordinates')} - - {t('routes.poi_coordinates_value', { - latitude: poi.Latitude.toFixed(6), - longitude: poi.Longitude.toFixed(6), - })} - - - - - - - - - ); -} diff --git a/src/app/routes/start.tsx b/src/app/routes/start.tsx deleted file mode 100644 index 9257ee9..0000000 --- a/src/app/routes/start.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { router, useLocalSearchParams } from 'expo-router'; -import { Clock, Info, MapPin, Navigation, Phone, Play, Truck, User } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Alert, Pressable, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; -import { Divider } from '@/components/ui/divider'; -import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type RouteStopResultData } from '@/models/v4/routes/routePlanResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRoutesStore } from '@/stores/routes/store'; -import { useUnitsStore } from '@/stores/units/store'; - -const formatDateTime = (isoString: string | null | undefined): string | null => { - if (!isoString) return null; - try { - const d = new Date(isoString); - if (isNaN(d.getTime())) return null; - return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); - } catch { - return null; - } -}; - -const formatDistance = (meters: number) => { - if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; - return `${Math.round(meters)} m`; -}; - -const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -}; - -interface StopMarkerProps { - order: number; - name: string; - color: string; -} - -function StopMarker({ order, name, color }: StopMarkerProps) { - return ( - - - {order} - - - - {name} - - - - ); -} - -export default function RouteViewScreen() { - const { planId } = useLocalSearchParams<{ planId: string }>(); - const activePlan = useRoutesStore((state) => state.activePlan); - const isLoading = useRoutesStore((state) => state.isLoading); - const error = useRoutesStore((state) => state.error); - const fetchRoutePlan = useRoutesStore((state) => state.fetchRoutePlan); - const startRouteInstance = useRoutesStore((state) => state.startRouteInstance); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const activeUnit = useCoreStore((state) => state.activeUnit); - const units = useUnitsStore((state) => state.units); - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - - const unitMap = useMemo(() => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), [units]); - - useEffect(() => { - if (planId) fetchRoutePlan(planId); - }, [planId, fetchRoutePlan]); - - const sortedStops = useMemo(() => [...(activePlan?.Stops || [])].sort((a, b) => a.StopOrder - b.StopOrder), [activePlan]); - - const mapStops = useMemo(() => sortedStops.filter((s) => s.Latitude && s.Longitude), [sortedStops]); - - const cameraRef = useRef(null); - const [isMapReady, setIsMapReady] = useState(false); - - // Fit bounds to show all stops once the map is ready - useEffect(() => { - if (!isMapReady || mapStops.length === 0 || !cameraRef.current) return; - - const lngs = mapStops.map((s) => s.Longitude); - const lats = mapStops.map((s) => s.Latitude); - - const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; - const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; - - cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 800); - }, [isMapReady, mapStops]); - - const markerColor = activePlan?.RouteColor || '#3b82f6'; - - const assignedUnitName = activePlan?.UnitId != null ? unitMap[activePlan.UnitId] || (String(activePlan.UnitId) === String(activeUnitId) ? (activeUnit?.Name ?? '') : '') : null; - - // A unit can start this route if: - // 1. The route has no pre-assigned unit (will assign on start), OR - // 2. The route is assigned to the current active unit - // UnitId from API is a number; activeUnitId is a string — compare as strings. - const canStart = !!activeUnitId && (!activePlan?.UnitId || String(activePlan.UnitId) === String(activeUnitId)); - - const handleStartRoute = async () => { - if (!planId || !activeUnitId) return; - try { - await startRouteInstance(planId, activeUnitId); - router.replace(`/routes/active?planId=${planId}`); - } catch { - Alert.alert(t('common.error'), t('common.errorOccurred')); - } - }; - - if (isLoading) { - return ( - - - - - ); - } - - if (error || !activePlan) { - return ( - - - - - ); - } - - const renderStopRow = (stop: RouteStopResultData, index: number) => { - const plannedArrival = formatDateTime(stop.PlannedArrival); - const plannedDeparture = formatDateTime(stop.PlannedDeparture); - - return ( - - - {/* Order badge */} - - {stop.StopOrder} - - - - {stop.Name} - - {stop.Address ? ( - - - - {stop.Address} - - - ) : null} - - {plannedArrival ? ( - - - - {t('routes.planned_arrival')}: {plannedArrival} - - - ) : null} - - {plannedDeparture ? ( - - - - {t('routes.planned_departure')}: {plannedDeparture} - - - ) : null} - - {stop.DwellTimeMinutes > 0 ? ( - - - - {stop.DwellTimeMinutes} {t('routes.dwell_time')} - - - ) : null} - - {stop.Notes ? {stop.Notes} : null} - - {stop.ContactId ? ( - router.push(`/routes/stop/contact?stopId=${stop.RouteStopId}` as any)}> - - - {t('routes.view_contact')} - - - ) : null} - - - {index < sortedStops.length - 1 ? : null} - - ); - }; - - return ( - - - - {/* Interactive stop map */} - {mapStops.length > 0 ? ( - - setIsMapReady(true)}> - - {mapStops.map((stop) => ( - - - - ))} - - - ) : null} - - - {/* Route header */} - - - - {activePlan.Name} - - - {/* Description */} - {activePlan.Description ? {activePlan.Description} : null} - - {/* Assigned unit */} - - - {assignedUnitName || t('routes.unassigned')} - - - - - {sortedStops.length} - {t('routes.stops')} - - - {(activePlan.EstimatedDistanceMeters ?? 0) > 0 ? ( - - {formatDistance(activePlan.EstimatedDistanceMeters!)} - {t('routes.distance')} - - ) : null} - - {(activePlan.EstimatedDurationSeconds ?? 0) > 0 ? ( - - {formatDuration(activePlan.EstimatedDurationSeconds!)} - {t('routes.duration')} - - ) : null} - - - {activePlan.ScheduleInfo ? ( - - - {activePlan.ScheduleInfo} - - ) : null} - - - {/* Warning when route is assigned to a different unit */} - {activePlan.UnitId && String(activePlan.UnitId) !== String(activeUnitId) ? ( - - - - {t('routes.assigned_other_unit')} - - - ) : null} - - {/* Stops list */} - - - {t('routes.stops')} ({sortedStops.length}) - - - - {sortedStops.length > 0 ? {sortedStops.map((stop, index) => renderStopRow(stop, index))} : {t('routes.no_stops')}} - - - - - {/* Start Route button — only shown when this unit can start */} - {canStart ? ( - - - - ) : null} - - ); -} - -const styles = StyleSheet.create({ - mapContainer: { - height: 260, - width: '100%', - }, - map: { - flex: 1, - }, - markerContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - markerCircle: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 2, - borderColor: 'white', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.35, - shadowRadius: 2, - elevation: 3, - }, - markerNumber: { - color: 'white', - fontSize: 11, - fontWeight: 'bold', - }, - markerLabel: { - backgroundColor: 'rgba(255, 255, 255, 0.92)', - paddingHorizontal: 5, - paddingVertical: 2, - borderRadius: 4, - maxWidth: 110, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 1, - elevation: 2, - }, - markerLabelText: { - fontSize: 10, - fontWeight: '600', - color: '#1f2937', - }, - stopBadge: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - marginTop: 2, - }, - stopBadgeText: { - color: 'white', - fontSize: 13, - fontWeight: 'bold', - }, -}); diff --git a/src/app/routes/stop/[id].tsx b/src/app/routes/stop/[id].tsx deleted file mode 100644 index e928a17..0000000 --- a/src/app/routes/stop/[id].tsx +++ /dev/null @@ -1,383 +0,0 @@ -import { format } from 'date-fns'; -import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { CheckCircleIcon, ClockIcon, LogInIcon, LogOutIcon, MapPinIcon, SkipForwardIcon, UserIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import { Camera, FillLayer, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL } from '@/components/maps/mapbox'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField } from '@/components/ui/input'; -import { Pressable } from '@/components/ui/pressable'; -import { Text } from '@/components/ui/text'; -import { Textarea, TextareaInput } from '@/components/ui/textarea'; -import { VStack } from '@/components/ui/vstack'; -import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useRoutesStore } from '@/stores/routes/store'; - -const STOP_TYPE_LABELS: Record = { - 0: 'routes.stop_type_standard', - 1: 'routes.stop_type_pickup', - 2: 'routes.stop_type_dropoff', - 3: 'routes.stop_type_service', - 4: 'routes.stop_type_inspection', -}; - -const PRIORITY_CONFIG: Record = { - 0: { labelKey: 'routes.priority_normal', action: 'muted' }, - 1: { labelKey: 'routes.priority_low', action: 'info' }, - 2: { labelKey: 'routes.priority_medium', action: 'warning' }, - 3: { labelKey: 'routes.priority_high', action: 'error' }, - 4: { labelKey: 'routes.priority_critical', action: 'error' }, -}; - -const STATUS_LABELS: Record = { - [RouteStopStatus.Pending]: 'routes.pending', - [RouteStopStatus.InProgress]: 'routes.in_progress', - [RouteStopStatus.Completed]: 'routes.completed', - [RouteStopStatus.Skipped]: 'routes.skipped', -}; - -/** - * Build a GeoJSON circle polygon for the geofence overlay. - */ -const buildGeofenceGeoJson = (lat: number, lon: number, radiusMeters: number) => { - const points = 64; - const coords: number[][] = []; - const earthRadius = 6371000; - for (let i = 0; i <= points; i++) { - const angle = (i * 2 * Math.PI) / points; - const dLat = (radiusMeters / earthRadius) * Math.cos(angle); - const dLon = (radiusMeters / (earthRadius * Math.cos((lat * Math.PI) / 180))) * Math.sin(angle); - coords.push([lon + (dLon * 180) / Math.PI, lat + (dLat * 180) / Math.PI]); - } - return { - type: 'Feature' as const, - geometry: { - type: 'Polygon' as const, - coordinates: [coords], - }, - properties: {}, - }; -}; - -export default function StopDetailScreen() { - const { t } = useTranslation(); - const { id } = useLocalSearchParams<{ id: string }>(); - const { colorScheme } = useColorScheme(); - - const instanceStops = useRoutesStore((s) => s.instanceStops); - const checkIn = useRoutesStore((s) => s.checkIn); - const checkOut = useRoutesStore((s) => s.checkOut); - const skip = useRoutesStore((s) => s.skip); - const updateNotes = useRoutesStore((s) => s.updateNotes); - const isLoadingStops = useRoutesStore((s) => s.isLoadingStops); - - const activeUnitId = useCoreStore((s) => s.activeUnitId); - const userLat = useLocationStore((s) => s.latitude); - const userLon = useLocationStore((s) => s.longitude); - - const stop = useMemo(() => instanceStops.find((s) => s.RouteInstanceStopId === id) ?? null, [instanceStops, id]); - - const [notes, setNotes] = useState(stop?.Notes ?? ''); - const [isSavingNotes, setIsSavingNotes] = useState(false); - const [skipModalVisible, setSkipModalVisible] = useState(false); - const [skipReason, setSkipReason] = useState(''); - - useEffect(() => { - if (stop) { - setNotes(stop.Notes ?? ''); - } - }, [stop]); - - const geofenceGeoJson = useMemo(() => { - if (!stop || !stop.Latitude || !stop.Longitude || !stop.GeofenceRadiusMeters) return null; - return buildGeofenceGeoJson(stop.Latitude, stop.Longitude, stop.GeofenceRadiusMeters); - }, [stop]); - - const priorityConfig = PRIORITY_CONFIG[stop?.Priority ?? 0] ?? PRIORITY_CONFIG[0]; - const stopTypeLabel = STOP_TYPE_LABELS[stop?.StopType ?? 0] ?? 'common.unknown'; - const statusLabel = STATUS_LABELS[stop?.Status ?? 0] ?? 'common.unknown'; - - const handleSaveNotes = useCallback(async () => { - if (!stop) return; - setIsSavingNotes(true); - try { - await updateNotes(stop.RouteInstanceStopId, notes); - } finally { - setIsSavingNotes(false); - } - }, [stop, notes, updateNotes]); - - const handleCheckIn = useCallback(async () => { - if (!stop || !activeUnitId) return; - const lat = userLat ?? 0; - const lon = userLon ?? 0; - await checkIn(stop.RouteInstanceStopId, activeUnitId, lat, lon); - }, [stop, activeUnitId, userLat, userLon, checkIn]); - - const handleCheckOut = useCallback(async () => { - if (!stop || !activeUnitId) return; - await checkOut(stop.RouteInstanceStopId, activeUnitId); - }, [stop, activeUnitId, checkOut]); - - const handleSkip = useCallback(() => { - if (!stop) return; - setSkipReason(''); - setSkipModalVisible(true); - }, [stop]); - - const handleSkipConfirm = useCallback(() => { - if (!stop) return; - setSkipModalVisible(false); - skip(stop.RouteInstanceStopId, skipReason.trim() || t('routes.skipped_by_driver')); - setSkipReason(''); - }, [stop, skipReason, skip, t]); - - const handleContactPress = useCallback(() => { - if (!stop?.ContactId) return; - router.push({ pathname: '/routes/stop/contact' as any, params: { stopId: stop.RouteInstanceStopId } }); - }, [stop]); - - if (isLoadingStops) { - return ( - <> - - - - - - ); - } - - if (!stop) { - return ( - <> - - - - {t('routes.no_routes_description')} - - - - ); - } - - const canCheckIn = stop.Status === RouteStopStatus.Pending; - const canCheckOut = stop.Status === RouteStopStatus.InProgress; - const canSkip = stop.Status === RouteStopStatus.Pending || stop.Status === RouteStopStatus.InProgress; - - return ( - <> - - - {/* Header */} - - {stop.Name} - {stop.Address ? {stop.Address} : null} - - {/* Badges */} - - - {t(priorityConfig.labelKey)} - - - {t(stopTypeLabel)} - - - {t(statusLabel)} - - - - - {/* Planned times */} - - - - {t('routes.schedule')} - - - - {t('routes.planned_arrival')} - {stop.PlannedArrival ? format(new Date(stop.PlannedArrival), 'MMM d, h:mm a') : '--'} - - - {t('routes.planned_departure')} - {stop.PlannedDeparture ? format(new Date(stop.PlannedDeparture), 'MMM d, h:mm a') : '--'} - - - {stop.DwellTimeMinutes > 0 && ( - - {t('routes.dwell_time')}: {stop.DwellTimeMinutes} {t('routes.min')} - - )} - - - {/* Mini Map with geofence */} - {stop.Latitude && stop.Longitude ? ( - - - - - - - - - {geofenceGeoJson && ( - - - - - )} - - - ) : null} - - {/* Contact card */} - {stop.ContactId ? ( - - - - - - - - {t('routes.contact')} - {t('routes.contact_details')} - - {t('calls.view_details')} - - - - ) : null} - - {/* Notes */} - - {t('routes.notes')} - - - - - {/* Status action buttons */} - - {canCheckIn && ( - - )} - {canCheckOut && ( - - )} - {canSkip && ( - - )} - {stop.Status === RouteStopStatus.Completed && ( - - - {t('routes.completed')} - - )} - - - {/* Bottom spacing */} - - - - {/* Skip reason modal */} - setSkipModalVisible(false)}> - - - - {t('routes.skip')} — {stop.Name} - - {t('routes.skip_reason')} - - - setSkipModalVisible(false)}> - {t('common.cancel')} - - - {t('routes.skip')} - - - - - - - ); -} - -const styles = StyleSheet.create({ - map: { - flex: 1, - }, - markerContainer: { - alignItems: 'center', - justifyContent: 'center', - }, - skipInput: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - padding: 10, - fontSize: 14, - color: '#111827', - textAlignVertical: 'top', - minHeight: 80, - }, - cancelBtn: { - flex: 1, - paddingVertical: 10, - borderRadius: 8, - borderWidth: 1, - borderColor: '#d1d5db', - alignItems: 'center', - }, - skipBtn: { - flex: 1, - paddingVertical: 10, - borderRadius: 8, - backgroundColor: '#eab308', - alignItems: 'center', - }, -}); diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx deleted file mode 100644 index 5bb4b9c..0000000 --- a/src/app/routes/stop/contact.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { Stack, useLocalSearchParams } from 'expo-router'; -import { BuildingIcon, ExternalLinkIcon, MapPinIcon, PhoneIcon, UserIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; - -import { getStopContact } from '@/api/routes/routes'; -import { Loading } from '@/components/common/loading'; -import { Camera, MapView, PointAnnotation, StyleURL } from '@/components/maps/mapbox'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Pressable } from '@/components/ui/pressable'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import type { ContactResultData } from '@/models/v4/contacts/contactResultData'; - -/** - * Parse a GPS coordinate string like "lat,lon" into { lat, lon }. - */ -const parseGps = (coords?: string): { lat: number; lon: number } | null => { - if (!coords) return null; - const parts = coords.split(','); - if (parts.length !== 2) return null; - const lat = parseFloat(parts[0].trim()); - const lon = parseFloat(parts[1].trim()); - if (isNaN(lat) || isNaN(lon)) return null; - return { lat, lon }; -}; - -const openInMaps = (lat: number, lon: number, label: string) => { - const url = Platform.select({ - ios: `maps://app?daddr=${lat},${lon}&q=${encodeURIComponent(label)}`, - android: `google.navigation:q=${lat},${lon}`, - default: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`, - }); - if (url) Linking.openURL(url); -}; - -const callPhone = (number: string) => { - const cleaned = number.replace(/[^\d+]/g, ''); - Linking.openURL(`tel:${cleaned}`); -}; - -interface PhoneRowProps { - label: string; - number: string | null | undefined; - colorScheme: string; -} - -function PhoneRow({ label, number, colorScheme }: PhoneRowProps) { - if (!number) return null; - return ( - callPhone(number)}> - - - - - - {label} - {number} - - - - - ); -} - -export default function StopContactScreen() { - const { t } = useTranslation(); - const { stopId } = useLocalSearchParams<{ stopId: string }>(); - const { colorScheme } = useColorScheme(); - - const [contact, setContact] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!stopId) return; - let cancelled = false; - setIsLoading(true); - setError(null); - - getStopContact(stopId) - .then((result) => { - if (!cancelled) { - setContact(result.Data ?? null); - setIsLoading(false); - } - }) - .catch((err) => { - if (!cancelled) { - setError(t('common.errorOccurred')); - setIsLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [stopId, t]); - - const locationGps = useMemo(() => parseGps(contact?.LocationGpsCoordinates), [contact]); - const entranceGps = useMemo(() => parseGps(contact?.EntranceGpsCoordinates), [contact]); - const exitGps = useMemo(() => parseGps(contact?.ExitGpsCoordinates), [contact]); - - // Determine map center from available coordinates - const mapCenter = useMemo(() => { - if (locationGps) return [locationGps.lon, locationGps.lat] as [number, number]; - if (entranceGps) return [entranceGps.lon, entranceGps.lat] as [number, number]; - if (exitGps) return [exitGps.lon, exitGps.lat] as [number, number]; - return null; - }, [locationGps, entranceGps, exitGps]); - - const displayName = useMemo(() => { - if (!contact) return ''; - if (contact.CompanyName) return contact.CompanyName; - const parts = [contact.FirstName, contact.MiddleName, contact.LastName].filter(Boolean); - if (parts.length > 0) return parts.join(' '); - return contact.Name ?? t('routes.no_contact'); - }, [contact, t]); - - const fullAddress = useMemo(() => { - if (!contact) return null; - const parts = [contact.Address, contact.City, contact.State, contact.Zip].filter(Boolean); - return parts.length > 0 ? parts.join(', ') : null; - }, [contact]); - - const handleAddressPress = useCallback(() => { - const coords = locationGps ?? entranceGps ?? exitGps; - if (coords) { - openInMaps(coords.lat, coords.lon, displayName); - } - }, [locationGps, entranceGps, exitGps, displayName]); - - if (isLoading) { - return ( - <> - - - - - - ); - } - - if (error || !contact) { - return ( - <> - - - - {error ?? t('routes.no_contact')} - - - ); - } - - return ( - <> - - - {/* Header */} - - {contact.CompanyName ? : } - {displayName} - {contact.CompanyName && contact.FirstName && {[contact.FirstName, contact.LastName].filter(Boolean).join(' ')}} - {contact.Email && {contact.Email}} - - - {/* Phone numbers */} - - - - - - - - - - {/* Address */} - {fullAddress && ( - - - - - - {t('routes.address')} - {fullAddress} - - {mapCenter && } - - - - )} - - {/* Mini Map */} - {mapCenter && ( - - - - {locationGps && ( - - - - - - )} - {entranceGps && ( - - - - - - )} - {exitGps && ( - - - - - - )} - - {/* Legend */} - - {locationGps && ( - - - {t('routes.location')} - - )} - {entranceGps && ( - - - {t('routes.entrance')} - - )} - {exitGps && ( - - - {t('routes.exit')} - - )} - - - )} - - {/* Description / Notes */} - {contact.Description && ( - - {t('routes.description')} - {contact.Description} - - )} - - {contact.Notes && ( - - {t('routes.notes')} - {contact.Notes} - - )} - - {/* Bottom spacing */} - - - - ); -} - -const styles = StyleSheet.create({ - map: { - flex: 1, - }, - markerLocation: { - alignItems: 'center', - justifyContent: 'center', - }, - markerEntrance: { - alignItems: 'center', - justifyContent: 'center', - }, - markerExit: { - alignItems: 'center', - justifyContent: 'center', - }, - legendDot: { - width: 8, - height: 8, - borderRadius: 4, - }, -}); diff --git a/src/components/command/__tests__/command-board.test.tsx b/src/components/command/__tests__/command-board.test.tsx new file mode 100644 index 0000000..1cb55a2 --- /dev/null +++ b/src/components/command/__tests__/command-board.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +import { CommandBoard } from '../command-board'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const board = { + Command: { IncidentCommandId: 'ic-1', CallId: 5 }, + Nodes: [ + { CommandStructureNodeId: 'n1', Name: 'Division A', NodeType: 0, SortOrder: 1, DeletedOn: null }, + { CommandStructureNodeId: 'n2', Name: 'Old Group', NodeType: 1, SortOrder: 0, DeletedOn: '2026-01-01T00:00:00Z' }, + ], + Assignments: [{ ResourceAssignmentId: 'a1', CommandStructureNodeId: 'n1', ReleasedOn: null }], + Objectives: [ + { TacticalObjectiveId: 'o1', Name: 'Primary search', Status: 1, SortOrder: 0 }, + { TacticalObjectiveId: 'o2', Name: 'Ventilation', Status: 0, SortOrder: 1 }, + ], + Timers: [], + Annotations: [], + Accountability: [{ UserId: 'u1', FullName: 'Jane Doe', Status: 'Critical' }], + Roles: [], +} as unknown as IncidentCommandBoard; + +describe('CommandBoard', () => { + it('renders live lanes with their ICS type and excludes deleted lanes', () => { + render(); + + expect(screen.getByText('Division A')).toBeTruthy(); + expect(screen.getByText('Division')).toBeTruthy(); // CommandNodeType[0] + expect(screen.queryByText('Old Group')).toBeNull(); + }); + + it('renders objectives and personnel accountability', () => { + render(); + + expect(screen.getByText('Primary search')).toBeTruthy(); + expect(screen.getByText('Ventilation')).toBeTruthy(); + expect(screen.getByText('Jane Doe')).toBeTruthy(); + expect(screen.getByText('Critical')).toBeTruthy(); + }); +}); diff --git a/src/components/command/__tests__/incident-card.test.tsx b/src/components/command/__tests__/incident-card.test.tsx new file mode 100644 index 0000000..73f02ee --- /dev/null +++ b/src/components/command/__tests__/incident-card.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +import { IncidentCard } from '../incident-card'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const board = { + Command: { IncidentCommandId: 'ic-1', CallId: 5 }, + Nodes: [ + { CommandStructureNodeId: 'n1', DeletedOn: null }, + { CommandStructureNodeId: 'n2', DeletedOn: '2026-01-01T00:00:00Z' }, + ], + Assignments: [{ ResourceAssignmentId: 'a1', ReleasedOn: null }], + Objectives: [], + Timers: [], + Annotations: [], + Accountability: [ + { UserId: 'u1', Status: 'Critical' }, + { UserId: 'u2', Status: 'Warning' }, + ], + Roles: [], +} as unknown as IncidentCommandBoard; + +describe('IncidentCard', () => { + it('renders the title and live counts (excluding deleted lanes / released resources)', () => { + render( {}} />); + + expect(screen.getByText('Structure Fire')).toBeTruthy(); + expect(screen.getByText('incidents.lanes: 1')).toBeTruthy(); + expect(screen.getByText('incidents.resources: 1')).toBeTruthy(); + expect(screen.getByText('incidents.par_critical: 1')).toBeTruthy(); + expect(screen.getByText('incidents.par_warning: 1')).toBeTruthy(); + }); + + it('omits PAR badges when accountability is all clear', () => { + const calm = { ...board, Accountability: [] } as unknown as IncidentCommandBoard; + render( {}} />); + + expect(screen.queryByText(/par_critical/)).toBeNull(); + expect(screen.queryByText(/par_warning/)).toBeNull(); + }); + + it('fires onPress when tapped', () => { + const onPress = jest.fn(); + render(); + + fireEvent.press(screen.getByTestId('incident-card')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/command/command-board.tsx b/src/components/command/command-board.tsx new file mode 100644 index 0000000..efc2053 --- /dev/null +++ b/src/components/command/command-board.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { Divider } from '@/components/ui/divider'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; +import { CommandNodeType, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/incidentCommandEnums'; +import { type PersonnelCallCheckInStatus } from '@/models/v4/incidentCommand/personnelCallCheckInStatus'; + +interface CommandBoardProps { + board: IncidentCommandBoard; +} + +const parAction = (status: PersonnelCallCheckInStatus['Status']): 'error' | 'warning' | 'success' => { + if (status === 'Critical') return 'error'; + if (status === 'Warning') return 'warning'; + return 'success'; +}; + +/** Read-only rendering of a live incident command board (structure, objectives, accountability). */ +export const CommandBoard: React.FC = ({ board }) => { + const { t } = useTranslation(); + + const liveNodes = board.Nodes.filter((n) => !n.DeletedOn).sort((a, b) => a.SortOrder - b.SortOrder); + const liveAssignments = board.Assignments.filter((a) => !a.ReleasedOn); + const objectives = board.Objectives.slice().sort((a, b) => a.SortOrder - b.SortOrder); + + return ( + + {/* Command structure (lanes) */} + + {t('incidents.lanes')} + {liveNodes.length === 0 ? ( + {t('incidents.no_lanes')} + ) : ( + liveNodes.map((node) => ( + + + + {node.Name} + {CommandNodeType[node.NodeType] ?? ''} + + + {`${t('incidents.resources')}: ${liveAssignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId).length}`} + + + + )) + )} + + + + + {/* Objectives / benchmarks */} + + {t('incidents.objectives')} + {objectives.length === 0 ? ( + {t('incidents.no_objectives')} + ) : ( + objectives.map((objective) => ( + + {objective.Name} + + {objective.Status === TacticalObjectiveStatus.Complete ? t('common.done') : '—'} + + + )) + )} + + + + + {/* Personnel accountability / PAR */} + + {t('incidents.accountability')} + {board.Accountability.length === 0 ? ( + {t('incidents.unassigned')} + ) : ( + board.Accountability.map((person) => ( + + {person.FullName ?? person.UserId} + + {person.Status} + + + )) + )} + + + ); +}; + +export default CommandBoard; diff --git a/src/components/command/incident-card.tsx b/src/components/command/incident-card.tsx new file mode 100644 index 0000000..67ae068 --- /dev/null +++ b/src/components/command/incident-card.tsx @@ -0,0 +1,60 @@ +import { ChevronRightIcon } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +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 { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +interface IncidentCardProps { + board: IncidentCommandBoard; + title: string; + onPress: () => void; +} + +/** Summary card for one active incident command in the Incidents list. */ +export const IncidentCard: React.FC = ({ board, title, onPress }) => { + const { t } = useTranslation(); + + const laneCount = board.Nodes.filter((n) => !n.DeletedOn).length; + const resourceCount = board.Assignments.filter((a) => !a.ReleasedOn).length; + const critical = board.Accountability.filter((a) => a.Status === 'Critical').length; + const warning = board.Accountability.filter((a) => a.Status === 'Warning').length; + + return ( + + + + + {title} + + + {`${t('incidents.lanes')}: ${laneCount}`} + + + {`${t('incidents.resources')}: ${resourceCount}`} + + {critical > 0 ? ( + + {`${t('incidents.par_critical')}: ${critical}`} + + ) : null} + {warning > 0 ? ( + + {`${t('incidents.par_warning')}: ${warning}`} + + ) : null} + + + + + + + ); +}; + +export default IncidentCard; diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index d538292..3b6b687 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -36,7 +36,6 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC if (!pin) return null; const isCallPin = pin.ImagePath?.toLowerCase() === 'call' || pin.Type === 0; - const isPoiPin = pin.Type === 4; const handleRouteToLocation = async () => { if (!pin.Latitude || !pin.Longitude) { @@ -62,13 +61,6 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC } }; - const handleViewPoiDetails = () => { - if (isPoiPin && pin.Id) { - router.push(`/routes/poi/${pin.Id}` as any); - onClose(); - } - }; - const handleSetAsCurrentCall = () => { if (isCallPin && onSetAsCurrentCall) { onSetAsCurrentCall(pin); @@ -153,12 +145,6 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC )} - {isPoiPin ? ( - - ) : null} diff --git a/src/components/routes/active-routes-list.tsx b/src/components/routes/active-routes-list.tsx deleted file mode 100644 index 06d73e8..0000000 --- a/src/components/routes/active-routes-list.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { router } from 'expo-router'; -import { MapPin, Navigation, Route, Search, X } from 'lucide-react-native'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, RefreshControl, ScrollView, View } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import { RouteCard } from '@/components/routes/route-card'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { FlatList } from '@/components/ui/flat-list'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; -import { Text } from '@/components/ui/text'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRoutesStore } from '@/stores/routes/store'; -import { useUnitsStore } from '@/stores/units/store'; - -export const ActiveRoutesList: React.FC = () => { - const { t } = useTranslation(); - const routePlans = useRoutesStore((state) => state.routePlans); - const activeInstance = useRoutesStore((state) => state.activeInstance); - const isLoading = useRoutesStore((state) => state.isLoading); - const error = useRoutesStore((state) => state.error); - const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); - const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const activeUnit = useCoreStore((state) => state.activeUnit); - const units = useUnitsStore((state) => state.units); - const fetchUnits = useUnitsStore((state) => state.fetchUnits); - const [searchQuery, setSearchQuery] = useState(''); - - const unitMap = useMemo(() => Object.fromEntries(units.map((unit) => [unit.UnitId, unit.Name])), [units]); - - useEffect(() => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - if (units.length === 0) { - fetchUnits(); - } - }, [activeUnitId, fetchActiveRoute, fetchAllRoutePlans, fetchUnits, units.length]); - - const handleRefresh = () => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - }; - - const activeRouteBanner = activeInstance ? ( - { - const routeInstanceId = activeInstance.RouteInstanceId; - const activeRouteUrl = - routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${activeInstance.RoutePlanId}`; - router.push(activeRouteUrl as any); - }} - > - - - - - {activeInstance.RoutePlanName || t('routes.active_route')} - - - {t('routes.active')} - - - - {t('routes.progress', { - percent: activeInstance.StopsTotal ? Math.round(((activeInstance.StopsCompleted ?? 0) / activeInstance.StopsTotal) * 100) : 0, - })} - - - - ) : null; - - const handleRoutePress = (route: RoutePlanResultData) => { - if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { - const routeInstanceId = activeInstance.RouteInstanceId; - const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; - router.push(activeRouteUrl as any); - return; - } - - router.push(`/routes/start?planId=${route.RoutePlanId}` as any); - }; - - const filteredRoutes = useMemo(() => { - const activeRoutes = routePlans.filter((route) => route.RouteStatus === 1); - if (!searchQuery) { - return activeRoutes; - } - - const normalizedQuery = searchQuery.toLowerCase(); - return activeRoutes.filter((route) => { - const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); - const unitName = route.UnitId != null ? unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return route.Name.toLowerCase().includes(normalizedQuery) || (route.Description?.toLowerCase() || '').includes(normalizedQuery) || unitName.toLowerCase().includes(normalizedQuery); - }); - }, [activeUnit, activeUnitId, routePlans, searchQuery, unitMap]); - - if (isLoading) { - return ; - } - - if (error) { - return ( - - - - - {t('common.errorOccurred')} - {error} - - ); - } - - return ( - - - - - - - {searchQuery ? ( - setSearchQuery('')}> - - - ) : null} - - - {filteredRoutes.length > 0 || activeInstance ? ( - - - testID="routes-list" - data={filteredRoutes} - ListHeaderComponent={activeRouteBanner} - renderItem={({ item }) => { - const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); - const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return ( - handleRoutePress(item)}> - - - ); - }} - keyExtractor={(item) => item.RoutePlanId} - refreshControl={} - ListEmptyComponent={ - searchQuery ? ( - - - - - - - - - {t('routes.no_search_results', 'No routes found')} - {t('routes.try_different_search', 'Try a different search term')} - - ) : ( - - - - - - - - - {t('routes.no_routes')} - {t('routes.no_routes_description_all')} - - - {t('routes.pull_to_refresh', 'Pull down to refresh')} - - - ) - } - contentContainerStyle={{ paddingBottom: 20 }} - /> - - ) : ( - } contentContainerClassName="flex-1" showsVerticalScrollIndicator={false}> - - {/* Decorative background circle */} - - - - - - - - - {searchQuery ? t('routes.no_search_results', 'No routes found') : t('routes.no_routes')} - - {searchQuery ? t('routes.try_different_search', 'Try a different search term') : t('routes.no_routes_description_all')} - - - {!searchQuery ? ( - - - {t('routes.pull_to_refresh', 'Pull down to refresh')} - - ) : null} - - - )} - - ); -}; diff --git a/src/components/routes/filter-context.tsx b/src/components/routes/filter-context.tsx deleted file mode 100644 index 7bec6e2..0000000 --- a/src/components/routes/filter-context.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; - -import { type PoiSortOption } from '@/lib/poi-utils'; - -interface FilterState { - isFilterOpen: boolean; - selectedPoiTypeId: number | null; - sortBy: PoiSortOption; - activeFilterCount: number; -} - -interface FilterActions { - openFilter: () => void; - closeFilter: () => void; - setSelectedPoiTypeId: (id: number | null) => void; - setSortBy: (option: PoiSortOption) => void; - clearFilters: () => void; -} - -const FilterContext = createContext<(FilterState & FilterActions) | null>(null); - -export function useFilterContext() { - const context = useContext(FilterContext); - if (!context) { - throw new Error('useFilterContext must be used within a FilterProvider'); - } - return context; -} - -export const FilterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [isFilterOpen, setIsFilterOpen] = useState(false); - const [selectedPoiTypeId, setSelectedPoiTypeId] = useState(null); - const [sortBy, setSortBy] = useState('display'); - - const activeFilterCount = useMemo(() => { - let count = 0; - if (selectedPoiTypeId !== null) count++; - if (sortBy !== 'display') count++; - return count; - }, [selectedPoiTypeId, sortBy]); - - const openFilter = useCallback(() => { - setIsFilterOpen(true); - }, []); - - const closeFilter = useCallback(() => { - setIsFilterOpen(false); - }, []); - - const clearFilters = useCallback(() => { - setSelectedPoiTypeId(null); - setSortBy('display'); - }, []); - - const value = useMemo( - () => ({ - isFilterOpen, - selectedPoiTypeId, - sortBy, - activeFilterCount, - openFilter, - closeFilter, - setSelectedPoiTypeId, - setSortBy, - clearFilters, - }), - [isFilterOpen, selectedPoiTypeId, sortBy, activeFilterCount, openFilter, closeFilter, clearFilters] - ); - - return {children}; -}; diff --git a/src/components/routes/poi-card.tsx b/src/components/routes/poi-card.tsx deleted file mode 100644 index 08daad5..0000000 --- a/src/components/routes/poi-card.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { MapPin, Navigation, Tag } from 'lucide-react-native'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -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 { type PoiResultData } from '@/models/v4/mapping/poiResultData'; - -interface PoiCardProps { - poi: PoiResultData; - poiTypeLabel: string; - displayName: string; - isDestinationEnabled: boolean; - onPress: () => void; -} - -export const PoiCard: React.FC = ({ poi, poiTypeLabel, displayName, isDestinationEnabled, onPress }) => { - const { t } = useTranslation(); - - return ( - - - - - {displayName} - - {poi.Address ? ( - - - {poi.Address} - - ) : null} - - {poi.Note ? ( - - {poi.Note} - - ) : null} - - - - {poiTypeLabel} - - {isDestinationEnabled ? ( - - {t('routes.poi_destination_enabled')} - - ) : null} - - - - - - - {t('routes.view_on_map')} - - {t('routes.poi_coordinates_compact', { latitude: poi.Latitude.toFixed(4), longitude: poi.Longitude.toFixed(4) })} - - - - ); -}; diff --git a/src/components/routes/poi-list-content.tsx b/src/components/routes/poi-list-content.tsx deleted file mode 100644 index 86af8a1..0000000 --- a/src/components/routes/poi-list-content.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { router } from 'expo-router'; -import { MapPin, RefreshCcwDotIcon, Search, SlidersHorizontal, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Platform, Pressable, RefreshControl, ScrollView } from 'react-native'; - -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import { useFilterContext } from '@/components/routes/filter-context'; -import { PoiCard } from '@/components/routes/poi-card'; -import { Box } from '@/components/ui/box'; -import { FlatList } from '@/components/ui/flat-list'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, sortPois } from '@/lib/poi-utils'; -import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; -import { usePoisStore } from '@/stores/pois/store'; - -export const PoiListContent: React.FC = () => { - const { t } = useTranslation(); - const poiTypes = usePoisStore((state) => state.poiTypes); - const pois = usePoisStore((state) => state.pois); - const destinationPois = usePoisStore((state) => state.destinationPois); - const isLoading = usePoisStore((state) => state.isLoading); - const error = usePoisStore((state) => state.error); - const fetchAllPoiData = usePoisStore((state) => state.fetchAllPoiData); - const [searchQuery, setSearchQuery] = useState(''); - - const { selectedPoiTypeId, sortBy, activeFilterCount, openFilter, clearFilters } = useFilterContext(); - - useEffect(() => { - fetchAllPoiData(); - }, [fetchAllPoiData]); - - // Combine both pois and destinationPois into a single list for display - const allPois = useMemo(() => { - const seen = new Set(); - const combined: PoiResultData[] = []; - for (const poi of [...pois, ...destinationPois]) { - if (!seen.has(poi.PoiId)) { - seen.add(poi.PoiId); - combined.push(poi); - } - } - return combined; - }, [pois, destinationPois]); - - const poiTypesById = useMemo(() => { - return poiTypes.reduce>((accumulator, poiType) => { - accumulator[poiType.PoiTypeId] = poiType; - return accumulator; - }, {}); - }, [poiTypes]); - - const visiblePois = useMemo(() => { - const filteredPois = filterPois(allPois, { - poiTypesById, - searchQuery, - poiTypeId: selectedPoiTypeId, - }); - return sortPois(filteredPois, poiTypesById, sortBy); - }, [poiTypesById, allPois, searchQuery, selectedPoiTypeId, sortBy]); - - const hasActiveFilters = selectedPoiTypeId !== null || sortBy !== 'display'; - - const handleRefresh = useCallback(() => { - fetchAllPoiData(true); - }, [fetchAllPoiData]); - - const handleClearFilters = useCallback(() => { - clearFilters(); - }, [clearFilters]); - - if (isLoading && allPois.length === 0) { - return ; - } - - if (error && allPois.length === 0) { - return ; - } - - const isFiltered = searchQuery || selectedPoiTypeId !== null; - - return ( - - - - - - - - {searchQuery ? ( - setSearchQuery('')}> - - - ) : null} - - - - - {activeFilterCount > 0 ? ( - - - {activeFilterCount} - - - ) : null} - - - - {visiblePois.length > 0 ? ( - - testID="pois-list" - data={visiblePois} - keyExtractor={(item) => String(item.PoiId)} - refreshControl={} - renderItem={({ item }) => ( - router.push({ pathname: '/routes/poi/[id]', params: { id: item.PoiId } })} - /> - )} - contentContainerStyle={{ paddingBottom: Platform.OS === 'android' ? 120 : 100 }} - /> - ) : ( - } contentContainerClassName="flex-1" showsVerticalScrollIndicator={false}> - {isFiltered ? ( - - - - - - - - - - {t('routes.no_search_results_pois')} - {t('routes.no_pois_filtered_description')} - - {t('routes.clear_filters')} - - - - ) : ( - - - - - - - - - - {t('routes.no_pois')} - {t('routes.no_pois_description')} - - - {t('routes.pull_to_refresh')} - - - - )} - - )} - - ); -}; diff --git a/src/components/routes/route-card.tsx b/src/components/routes/route-card.tsx deleted file mode 100644 index f511eae..0000000 --- a/src/components/routes/route-card.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Clock, MapPin, Navigation, Truck } from 'lucide-react-native'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; - -interface RouteCardProps { - route: RoutePlanResultData; - isActive?: boolean; - unitName?: string; - isMyUnit?: boolean; -} - -export const RouteCard: React.FC = ({ route, isActive = false, unitName, isMyUnit = false }) => { - const { t } = useTranslation(); - - const formatDistance = (meters: number) => { - if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; - return `${Math.round(meters)} m`; - }; - - const formatDuration = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; - }; - - const bgClass = isMyUnit ? 'mb-2 rounded-xl bg-blue-50 p-4 shadow-sm dark:bg-blue-900/20' : 'mb-2 rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800'; - - const borderColor = isMyUnit ? '#3b82f6' : route.RouteColor || '#94a3b8'; - - return ( - - - - - - {route.Name} - - - {route.Description ? ( - - {route.Description} - - ) : null} - - {/* Unit assignment chip */} - - - {unitName || t('routes.unassigned')} - - - - - - {t('routes.stop_count', { count: route.StopsCount ?? 0 })} - - - {(route.EstimatedDistanceMeters ?? 0) > 0 ? ( - - - {formatDistance(route.EstimatedDistanceMeters!)} - - ) : null} - - {(route.EstimatedDurationSeconds ?? 0) > 0 ? ( - - - {formatDuration(route.EstimatedDurationSeconds!)} - - ) : null} - - - - - {isActive ? ( - - {t('routes.active')} - - ) : null} - {isMyUnit && !isActive ? ( - - {t('routes.my_unit')} - - ) : null} - - - - {route.ScheduleInfo ? ( - - - {route.ScheduleInfo} - - ) : null} - - ); -}; diff --git a/src/components/routes/route-deviation-banner.tsx b/src/components/routes/route-deviation-banner.tsx deleted file mode 100644 index 0947ab2..0000000 --- a/src/components/routes/route-deviation-banner.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { AlertTriangle, X } from 'lucide-react-native'; -import React from 'react'; -import { Pressable } from 'react-native'; - -import { Box } from '@/components/ui/box'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { type RouteDeviationResultData } from '@/models/v4/routes/routeDeviationResultData'; - -interface RouteDeviationBannerProps { - deviations: RouteDeviationResultData[]; - onPress: () => void; - onDismiss: (deviationId: string) => void; -} - -export const RouteDeviationBanner: React.FC = ({ deviations, onPress, onDismiss }) => { - if (deviations.length === 0) { - return null; - } - - const latestDeviation = deviations[0]; - - return ( - - - - - - - {latestDeviation.Description} - - - - {deviations.length > 1 ? ( - - +{deviations.length - 1} - - ) : null} - - { - e.stopPropagation?.(); - onDismiss(latestDeviation.RouteDeviationId); - }} - > - - - - - - ); -}; diff --git a/src/components/routes/routes-home.tsx b/src/components/routes/routes-home.tsx deleted file mode 100644 index 883dae3..0000000 --- a/src/components/routes/routes-home.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Check } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, View } from 'react-native'; - -import { ActiveRoutesList } from '@/components/routes/active-routes-list'; -import { FilterProvider, useFilterContext } from '@/components/routes/filter-context'; -import { PoiListContent } from '@/components/routes/poi-list-content'; -import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; -import { Box } from '@/components/ui/box'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type PoiSortOption } from '@/lib/poi-utils'; -import { usePoisStore } from '@/stores/pois/store'; - -const FilterSheet: React.FC = () => { - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const isDark = colorScheme === 'dark'; - const poiTypes = usePoisStore((state) => state.poiTypes); - const { isFilterOpen, selectedPoiTypeId, sortBy, closeFilter, setSelectedPoiTypeId, setSortBy } = useFilterContext(); - - const sortOptions: { label: string; value: PoiSortOption }[] = [ - { label: t('routes.poi_sort_display'), value: 'display' }, - { label: t('routes.poi_sort_type'), value: 'type' }, - ]; - - const handlePoiTypeSelect = (id: number | null) => { - setSelectedPoiTypeId(id); - }; - - const handleSortSelect = (option: PoiSortOption) => { - setSortBy(option); - }; - - return ( - - - {t('routes.poi_filters')} - - {/* POI Type Filter */} - - {t('routes.poi_filter_type')} - - - - handlePoiTypeSelect(null)} - className={`flex-row items-center justify-between rounded-lg border p-3 ${ - selectedPoiTypeId === null ? (isDark ? 'border-primary-700 bg-primary-900/30' : 'border-primary-500 bg-primary-50') : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' - }`} - > - - {t('routes.poi_filter_all_types')} - - {selectedPoiTypeId === null && } - - - {poiTypes.map((poiType) => ( - handlePoiTypeSelect(poiType.PoiTypeId)} - className={`flex-row items-center justify-between rounded-lg border p-3 ${ - selectedPoiTypeId === poiType.PoiTypeId - ? isDark - ? 'border-primary-700 bg-primary-900/30' - : 'border-primary-500 bg-primary-50' - : isDark - ? 'border-neutral-700 bg-neutral-800' - : 'border-neutral-200 bg-white' - }`} - > - {poiType.Name} - {selectedPoiTypeId === poiType.PoiTypeId && } - - ))} - - - - - - {/* Sort Options */} - - {t('routes.poi_sort_by')} - - {sortOptions.map((option) => ( - handleSortSelect(option.value)} - className={`flex-row items-center justify-between rounded-lg border p-3 ${ - sortBy === option.value ? (isDark ? 'border-primary-700 bg-primary-900/30' : 'border-primary-500 bg-primary-50') : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' - }`} - > - {option.label} - {sortBy === option.value && } - - ))} - - - - - ); -}; - -export const RoutesHome: React.FC = () => { - const tabs = React.useMemo( - () => [ - { - key: 'routes', - title: 'routes.routes_tab', - content: , - }, - { - key: 'pois', - title: 'routes.pois_tab', - content: , - }, - ], - [] - ); - - return ( - - - - - - - - - ); -}; diff --git a/src/components/routes/stop-card.tsx b/src/components/routes/stop-card.tsx deleted file mode 100644 index e5c7259..0000000 --- a/src/components/routes/stop-card.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { CheckCircle, Circle, Clock, MapPin, Phone, SkipForward, User } from 'lucide-react-native'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; -import { HStack } from '@/components/ui/hstack'; -import { Icon } from '@/components/ui/icon'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { type RouteInstanceStopResultData, RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; - -interface StopCardProps { - stop: RouteInstanceStopResultData; - isCurrent?: boolean; - onCheckIn?: () => void; - onCheckOut?: () => void; - onSkip?: () => void; - onPress?: () => void; -} - -const statusConfig = { - [RouteStopStatus.Pending]: { color: '#9ca3af', labelKey: 'routes.pending', icon: Circle }, - [RouteStopStatus.InProgress]: { color: '#3b82f6', labelKey: 'routes.in_progress', icon: Clock }, - [RouteStopStatus.Completed]: { color: '#22c55e', labelKey: 'routes.completed', icon: CheckCircle }, - [RouteStopStatus.Skipped]: { color: '#eab308', labelKey: 'routes.skipped', icon: SkipForward }, -}; - -export const StopCard: React.FC = ({ stop, isCurrent = false, onCheckIn, onCheckOut, onSkip, onPress }) => { - const { t } = useTranslation(); - const config = statusConfig[stop.Status as RouteStopStatus] || statusConfig[RouteStopStatus.Pending]; - - return ( - - - - - - - - - - {stop.Name} - - {t(config.labelKey)} - - - - {stop.Address ? ( - - - - {stop.Address} - - - ) : null} - - {stop.ContactId ? ( - - - {t('routes.contact')} - - ) : null} - - {stop.PlannedArrival ? ( - - - - {t('routes.eta')}: {stop.PlannedArrival} - - - ) : null} - - - - #{stop.StopOrder} - - - {/* Action buttons for current stop */} - {isCurrent && stop.Status !== RouteStopStatus.Completed && stop.Status !== RouteStopStatus.Skipped ? ( - - {stop.Status === RouteStopStatus.Pending ? ( - - ) : null} - - {stop.Status === RouteStopStatus.InProgress ? ( - - ) : null} - - - - ) : null} - - ); -}; diff --git a/src/components/routes/stop-marker.tsx b/src/components/routes/stop-marker.tsx deleted file mode 100644 index ee7e927..0000000 --- a/src/components/routes/stop-marker.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; - -import { Text } from '@/components/ui/text'; -import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; - -interface StopMarkerProps { - stopOrder: number; - status: number; -} - -const statusColors = { - [RouteStopStatus.Pending]: '#9ca3af', - [RouteStopStatus.InProgress]: '#3b82f6', - [RouteStopStatus.Completed]: '#22c55e', - [RouteStopStatus.Skipped]: '#eab308', -}; - -export const StopMarker: React.FC = ({ stopOrder, status }) => { - const color = statusColors[status as RouteStopStatus] || statusColors[RouteStopStatus.Pending]; - - return ( - - {stopOrder} - - ); -}; - -const styles = StyleSheet.create({ - container: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 2, - borderColor: '#ffffff', - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - }, - text: { - color: '#ffffff', - fontSize: 12, - fontWeight: 'bold', - }, -}); diff --git a/src/models/v4/incidentCommand/commandLogEntry.ts b/src/models/v4/incidentCommand/commandLogEntry.ts new file mode 100644 index 0000000..93c6c14 --- /dev/null +++ b/src/models/v4/incidentCommand/commandLogEntry.ts @@ -0,0 +1,16 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs (CommandLogEntry). + +/** An append-only ICS-201 style timeline entry, auto-written on every command action. */ +export interface CommandLogEntry { + CommandLogEntryId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + /** Maps to CommandLogEntryType. */ + EntryType: number; + Description: string; + UserId: string | null; + Latitude: string | null; + Longitude: string | null; + OccurredOn: string; +} diff --git a/src/models/v4/incidentCommand/commandStructureNode.ts b/src/models/v4/incidentCommand/commandStructureNode.ts new file mode 100644 index 0000000..08160b4 --- /dev/null +++ b/src/models/v4/incidentCommand/commandStructureNode.ts @@ -0,0 +1,26 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs. + +/** + * A live lane / span-of-control node on the command board (Division, Group, Branch, Staging, ...). + * Initially seeded from a CommandDefinitionRole, then per-incident editable. + */ +export interface CommandStructureNode { + CommandStructureNodeId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + /** Maps to CommandNodeType. */ + NodeType: number; + Name: string; + /** Parent node for branch/division/group hierarchies; null for top-level nodes. */ + ParentNodeId: string | null; + SupervisorUserId: string | null; + SupervisorUnitId: number | null; + SortOrder: number; + /** The CommandDefinitionRole this node was seeded from, if any. */ + SourceRoleId: number | null; + /** Soft-delete tombstone (null = live). */ + DeletedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/commandTransfer.ts b/src/models/v4/incidentCommand/commandTransfer.ts new file mode 100644 index 0000000..6cb8a36 --- /dev/null +++ b/src/models/v4/incidentCommand/commandTransfer.ts @@ -0,0 +1,13 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs (CommandTransfer). + +/** Log of a command transfer (handoff of Incident Commander). */ +export interface CommandTransfer { + CommandTransferId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + FromUserId: string; + ToUserId: string; + TransferredOn: string; + Notes: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentAdHocPersonnel.ts b/src/models/v4/incidentCommand/incidentAdHocPersonnel.ts new file mode 100644 index 0000000..77e5425 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentAdHocPersonnel.ts @@ -0,0 +1,25 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs (IncidentAdHocPersonnel). + +/** + * An incident-scoped, ad-hoc person created on the fly for resources not in Resgrid. May ride an ad-hoc + * (or real) unit for accountability via RidingResourceKind + RidingResourceId. + */ +export interface IncidentAdHocPersonnel { + IncidentAdHocPersonnelId: string; + DepartmentId: number; + CallId: number; + Name: string; + /** Role / qualification (e.g. "Paramedic", "Firefighter"). */ + Role: string | null; + ExternalAgencyName: string | null; + Contact: string | null; + /** The kind of unit this person is riding for accountability (maps to ResourceAssignmentKind). */ + RidingResourceKind: number; + /** Identifier of the unit this person is riding (ad-hoc unit id, real unit id, ...), or null. */ + RidingResourceId: string | null; + CreatedByUserId: string; + CreatedOn: string; + ReleasedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentAdHocUnit.ts b/src/models/v4/incidentCommand/incidentAdHocUnit.ts new file mode 100644 index 0000000..2b60a07 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentAdHocUnit.ts @@ -0,0 +1,23 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs (IncidentAdHocUnit). + +/** + * An incident-scoped, ad-hoc unit created on the fly for resources not in Resgrid (e.g. a mutual-aid + * crew from a non-Resgrid agency, or a unit formed from on-scene personnel). Not a real department Unit. + */ +export interface IncidentAdHocUnit { + IncidentAdHocUnitId: string; + DepartmentId: number; + CallId: number; + Name: string; + /** Optional reference to a department UnitType for classification. */ + UnitTypeId: number | null; + /** Free-text unit type (e.g. "Engine", "Ambulance") when no UnitTypeId applies. */ + Type: string | null; + /** Name of the external (non-Resgrid) agency this resource belongs to, if any. */ + ExternalAgencyName: string | null; + CreatedByUserId: string; + CreatedOn: string; + ReleasedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentCommand.ts b/src/models/v4/incidentCommand/incidentCommand.ts new file mode 100644 index 0000000..89a0966 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommand.ts @@ -0,0 +1,24 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs. +// Dates are ISO-8601 strings over the wire (.NET DateTime serialized by Newtonsoft.Json). + +/** A live incident-command instance established on a specific Call. */ +export interface IncidentCommand { + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + /** The CommandDefinition this instance was seeded from, if any. */ + SourceCommandDefinitionId: number | null; + EstablishedByUserId: string; + EstablishedOn: string; + CurrentCommanderUserId: string; + CommandPostLatitude: string | null; + CommandPostLongitude: string | null; + IncidentActionPlan: string | null; + /** NIMS/ICS escalation level for the incident (department defined). */ + IcsLevel: number; + /** Maps to IncidentCommandStatus. */ + Status: number; + ClosedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentCommandBoard.ts b/src/models/v4/incidentCommand/incidentCommandBoard.ts new file mode 100644 index 0000000..c63908c --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommandBoard.ts @@ -0,0 +1,25 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs — the composite, one-shot +// snapshot of a live incident command board ("Real Time Sync" view). + +import { type CommandStructureNode } from './commandStructureNode'; +import { type IncidentCommand } from './incidentCommand'; +import { type IncidentMapAnnotation } from './incidentMapAnnotation'; +import { type IncidentRoleAssignment } from './incidentRoleAssignment'; +import { type IncidentTimer } from './incidentTimer'; +import { type PersonnelCallCheckInStatus } from './personnelCallCheckInStatus'; +import { type ResourceAssignment } from './resourceAssignment'; +import { type TacticalObjective } from './tacticalObjective'; + +/** Composite snapshot of a live incident command board. */ +export interface IncidentCommandBoard { + Command: IncidentCommand; + Nodes: CommandStructureNode[]; + Assignments: ResourceAssignment[]; + Objectives: TacticalObjective[]; + Timers: IncidentTimer[]; + Annotations: IncidentMapAnnotation[]; + /** Personnel accountability / PAR status (from the Checkin feature) for the incident. */ + Accountability: PersonnelCallCheckInStatus[]; + /** Active functional command-role assignments for the incident. */ + Roles: IncidentRoleAssignment[]; +} diff --git a/src/models/v4/incidentCommand/incidentCommandBundle.ts b/src/models/v4/incidentCommand/incidentCommandBundle.ts new file mode 100644 index 0000000..ec6f17f --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommandBundle.ts @@ -0,0 +1,17 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentCommandBundle.cs — the shift-start aggregate: +// a render-ready board (incl. computed accountability/PAR) per ACTIVE incident, in a single round-trip. + +import { type IncidentAdHocPersonnel } from './incidentAdHocPersonnel'; +import { type IncidentAdHocUnit } from './incidentAdHocUnit'; +import { type IncidentCommandBoard } from './incidentCommandBoard'; + +export interface IncidentCommandBundle { + /** Server clock (Unix epoch ms) captured at read start; seeds the next /Sync/Changes cursor. */ + ServerTimestampMs: number; + /** One render-ready board (incl. accountability / PAR) per active incident command in the department. */ + Boards: IncidentCommandBoard[]; + /** Active ad-hoc units across the department's active incidents. */ + AdHocUnits: IncidentAdHocUnit[]; + /** Active ad-hoc personnel across the department's active incidents. */ + AdHocPersonnel: IncidentAdHocPersonnel[]; +} diff --git a/src/models/v4/incidentCommand/incidentCommandEnums.ts b/src/models/v4/incidentCommand/incidentCommandEnums.ts new file mode 100644 index 0000000..8b41d28 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommandEnums.ts @@ -0,0 +1,161 @@ +// Enums mirroring Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs and IncidentRole.cs. +// Numeric values MUST stay in sync with the backend (they are persisted + sent over the wire). + +/** Lifecycle status of a live incident command instance. */ +export enum IncidentCommandStatus { + Active = 0, + Closed = 1, +} + +/** ICS structural node types (the "lanes" / span-of-control units on the command board). */ +export enum CommandNodeType { + Division = 0, + Group = 1, + Branch = 2, + Sector = 3, + StrikeTeam = 4, + TaskForce = 5, + Staging = 6, + UnifiedCommand = 7, +} + +/** What kind of resource a ResourceAssignment points at (polymorphic). */ +export enum ResourceAssignmentKind { + RealUnit = 0, + RealPersonnel = 1, + LinkedDeptUnit = 2, + LinkedDeptPersonnel = 3, + AdHocUnit = 4, + AdHocPersonnel = 5, +} + +/** Classification of a tactical objective / benchmark. */ +export enum TacticalObjectiveType { + General = 0, + Benchmark = 1, + Safety = 2, +} + +/** Completion state of a tactical objective. */ +export enum TacticalObjectiveStatus { + Pending = 0, + Complete = 1, +} + +/** Type of incident timer (personnel PAR is handled by the Checkin feature, not these). */ +export enum IncidentTimerType { + Scene = 0, + Benchmark = 1, + Role = 2, + Custom = 3, +} + +/** What an incident timer is scoped to. */ +export enum IncidentTimerScopeType { + Incident = 0, + Node = 1, + Unit = 2, +} + +/** Runtime status of an incident timer. */ +export enum IncidentTimerStatus { + Running = 0, + Due = 1, + Acknowledged = 2, + Stopped = 3, +} + +/** Type of a real-time map annotation drawn on the tactical map. */ +export enum IncidentMapAnnotationType { + Line = 0, + Polygon = 1, + Symbol = 2, + Text = 3, + Marker = 4, +} + +/** Type of an entry in the append-only command (ICS-201) timeline. */ +export enum CommandLogEntryType { + CommandEstablished = 0, + CommandTransferred = 1, + NodeAdded = 2, + NodeUpdated = 3, + NodeRemoved = 4, + ResourceAssigned = 5, + ResourceMoved = 6, + ResourceReleased = 7, + ObjectiveAdded = 8, + ObjectiveCompleted = 9, + TimerStarted = 10, + TimerAcknowledged = 11, + AnnotationAdded = 12, + AnnotationRemoved = 13, + CheckIn = 14, + ChannelOpened = 15, + ChannelClosed = 16, + RoleAssigned = 17, + RoleRemoved = 18, + AdHocResourceCreated = 19, + Note = 20, + CommandClosed = 21, + ParCritical = 22, +} + +/** + * Functional incident-command positions (NIMS/ICS) across Fire / EMS / SAR / Natural-disaster / Industrial-HazMat. + * Each maps to a specialized app view and a capability set (see IncidentRoleCapabilityMap on the backend). + */ +export enum IncidentRoleType { + IncidentCommander = 0, + DeputyIncidentCommander = 1, + UnifiedCommandMember = 2, + OperationsSectionChief = 3, + PlanningSectionChief = 4, + LogisticsSectionChief = 5, + FinanceAdminSectionChief = 6, + SafetyOfficer = 7, + LiaisonOfficer = 8, + PublicInformationOfficer = 9, + StagingAreaManager = 10, + ResourcesUnitLeader = 11, + SituationUnitLeader = 12, + DocumentationUnitLeader = 13, + CommunicationsUnitLeader = 14, + DivisionGroupSupervisor = 15, + BranchDirector = 16, + StrikeTeamTaskForceLeader = 17, + MedicalUnitLeader = 18, + RehabOfficer = 19, + MedicalBranchDirector = 20, + TriageOfficer = 21, + TreatmentOfficer = 22, + TransportOfficer = 23, + HazMatGroupSupervisor = 24, + DeconOfficer = 25, + EntryTeamLeader = 26, + SearchGroupSupervisor = 27, + AirOperationsBranchDirector = 28, + ShelterMassCareCoordinator = 29, + DamageAssessmentLead = 30, +} + +/** + * Capabilities an incident role may have; drives the app's view gating. Bitwise flags — the board read + * returns the user's effective value, test with `(value & IncidentCapabilities.X) !== 0`. + */ +export enum IncidentCapabilities { + None = 0, + ViewBoard = 1, + ManageCommand = 2, + ManageStructure = 4, + AssignResources = 8, + ManageObjectives = 16, + ManageTimers = 32, + ManageAnnotations = 64, + ManageAccountability = 128, + ManageChannels = 256, + ManageResources = 512, + ViewReports = 1024, + // eslint-disable-next-line no-bitwise + All = ViewBoard | ManageCommand | ManageStructure | AssignResources | ManageObjectives | ManageTimers | ManageAnnotations | ManageAccountability | ManageChannels | ManageResources | ViewReports, +} diff --git a/src/models/v4/incidentCommand/incidentCommandResults.ts b/src/models/v4/incidentCommand/incidentCommandResults.ts new file mode 100644 index 0000000..5a64b33 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommandResults.ts @@ -0,0 +1,72 @@ +// v4 response wrappers for the IncidentCommand controller, mirroring +// Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs. +// Each extends the standard v4 envelope (BaseV4Request) and carries a typed Data payload. + +import { type BaseV4Request } from '../baseV4Request'; +import { type CommandLogEntry } from './commandLogEntry'; +import { type CommandStructureNode } from './commandStructureNode'; +import { type CommandTransfer } from './commandTransfer'; +import { type IncidentCommand } from './incidentCommand'; +import { type IncidentCommandBoard } from './incidentCommandBoard'; +import { type IncidentCommandBundle } from './incidentCommandBundle'; +import { type IncidentMapAnnotation } from './incidentMapAnnotation'; +import { type IncidentTimer } from './incidentTimer'; +import { type PersonnelCallCheckInStatus } from './personnelCallCheckInStatus'; +import { type ResourceAssignment } from './resourceAssignment'; +import { type TacticalObjective } from './tacticalObjective'; + +export interface IncidentCommandResult extends BaseV4Request { + Data: IncidentCommand | null; +} + +export interface IncidentCommandBoardResult extends BaseV4Request { + Data: IncidentCommandBoard | null; +} + +export interface CommandTransferResult extends BaseV4Request { + Data: CommandTransfer | null; +} + +export interface CommandNodeResult extends BaseV4Request { + Data: CommandStructureNode | null; +} + +export interface ResourceAssignmentResult extends BaseV4Request { + Data: ResourceAssignment | null; +} + +export interface TacticalObjectiveResult extends BaseV4Request { + Data: TacticalObjective | null; +} + +export interface IncidentTimerResult extends BaseV4Request { + Data: IncidentTimer | null; +} + +export interface IncidentMapAnnotationResult extends BaseV4Request { + Data: IncidentMapAnnotation | null; +} + +export interface CommandTimelineResult extends BaseV4Request { + Data: CommandLogEntry[]; +} + +/** Simple boolean action result (delete/release/complete/acknowledge operations). */ +export interface IncidentCommandActionResult extends BaseV4Request { + Data: boolean; +} + +/** Per-person accountability / PAR status for the incident. */ +export interface CommandAccountabilityResult extends BaseV4Request { + Data: PersonnelCallCheckInStatus[]; +} + +/** User ids newly flagged Critical (PAR overdue) by an accountability sweep. */ +export interface EvaluateAccountabilityResult extends BaseV4Request { + Data: string[]; +} + +/** Shift-start aggregate: a render-ready board per active incident + ad-hoc resources + next-sync cursor. */ +export interface SyncBundleResult extends BaseV4Request { + Data: IncidentCommandBundle | null; +} diff --git a/src/models/v4/incidentCommand/incidentMapAnnotation.ts b/src/models/v4/incidentCommand/incidentMapAnnotation.ts new file mode 100644 index 0000000..7d376e7 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentMapAnnotation.ts @@ -0,0 +1,21 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs (IncidentMapAnnotation). + +/** A real-time map annotation (markup) on the tactical map, synced across devices. */ +export interface IncidentMapAnnotation { + IncidentMapAnnotationId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + /** Maps to IncidentMapAnnotationType. */ + AnnotationType: number; + /** The annotation geometry as a GeoJSON feature. */ + GeoJson: string; + /** Optional ICS standard symbology code. */ + IcsSymbolCode: string | null; + Label: string | null; + CreatedByUserId: string; + CreatedOn: string; + DeletedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentRoleAssignment.ts b/src/models/v4/incidentCommand/incidentRoleAssignment.ts new file mode 100644 index 0000000..b65f739 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentRoleAssignment.ts @@ -0,0 +1,22 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentRole.cs (IncidentRoleAssignment). + +/** + * Assigns a Resgrid user to a functional incident-command role for a specific incident (Call). + * Incident-scoped, not a department-wide claim. Optionally scoped to a structure node for supervisors. + */ +export interface IncidentRoleAssignment { + IncidentRoleAssignmentId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + UserId: string; + /** Maps to IncidentRoleType. */ + RoleType: number; + /** Optional command structure node this role is scoped to (e.g. a Division/Group supervisor). */ + ScopeNodeId: string | null; + AssignedByUserId: string; + AssignedOn: string; + RemovedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/incidentTimer.ts b/src/models/v4/incidentCommand/incidentTimer.ts new file mode 100644 index 0000000..fa2dd2b --- /dev/null +++ b/src/models/v4/incidentCommand/incidentTimer.ts @@ -0,0 +1,27 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs (IncidentTimer). + +/** + * A scene / benchmark / role timer for an incident. Personnel accountability (PAR) is handled by the + * Checkin feature, not by these timers. + */ +export interface IncidentTimer { + IncidentTimerId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + /** Maps to IncidentTimerType. */ + TimerType: number; + /** Maps to IncidentTimerScopeType. */ + ScopeType: number; + /** Identifier of the scoped object (node id, unit id, ...), null for incident scope. */ + ScopeId: string | null; + Name: string; + IntervalSeconds: number; + StartedOn: string; + NextDueOn: string | null; + /** Maps to IncidentTimerStatus. */ + Status: number; + AcknowledgedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/personnelCallCheckInStatus.ts b/src/models/v4/incidentCommand/personnelCallCheckInStatus.ts new file mode 100644 index 0000000..d2050d9 --- /dev/null +++ b/src/models/v4/incidentCommand/personnelCallCheckInStatus.ts @@ -0,0 +1,21 @@ +// Mirrors Core/Resgrid.Model/PersonnelCallCheckInStatus.cs. + +/** Colour-coded accountability/PAR status string returned by the backend. */ +export type CheckInStatusColor = 'Green' | 'Warning' | 'Critical'; + +/** Check-in timer (accountability / PAR) status for a single dispatched person on an incident. */ +export interface PersonnelCallCheckInStatus { + UserId: string; + /** Display name (first + last); may be null if the profile could not be resolved. */ + FullName: string | null; + /** UTC timestamp of the most-recent check-in on this call, or null if never checked in. */ + LastCheckIn: string | null; + /** True when the user must check in immediately (timer has expired). */ + NeedsCheckIn: boolean; + /** Minutes until the next check-in is required; negative = minutes overdue. */ + MinutesRemaining: number; + /** "Green" (within timer), "Warning" (within warning threshold), or "Critical" (expired). */ + Status: CheckInStatusColor; + DurationMinutes: number; + WarningThresholdMinutes: number; +} diff --git a/src/models/v4/incidentCommand/resourceAssignment.ts b/src/models/v4/incidentCommand/resourceAssignment.ts new file mode 100644 index 0000000..48f7ecc --- /dev/null +++ b/src/models/v4/incidentCommand/resourceAssignment.ts @@ -0,0 +1,22 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs (ResourceAssignment). + +/** + * Assigns a resource to a command structure node. Polymorphic: the resource may be an own-department + * unit/person, a linked (mutual-aid) department unit/person, or an incident ad-hoc unit/person. + */ +export interface ResourceAssignment { + ResourceAssignmentId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + CommandStructureNodeId: string | null; + /** Maps to ResourceAssignmentKind. */ + ResourceKind: number; + /** Polymorphic resource id (unit id, user id, or ad-hoc guid) stored as string. */ + ResourceId: string; + AssignedByUserId: string; + AssignedOn: string; + ReleasedOn: string | null; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/incidentCommand/tacticalObjective.ts b/src/models/v4/incidentCommand/tacticalObjective.ts new file mode 100644 index 0000000..5dc6fa2 --- /dev/null +++ b/src/models/v4/incidentCommand/tacticalObjective.ts @@ -0,0 +1,20 @@ +// Mirrors Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs (TacticalObjective). + +/** A tactical objective / benchmark for an incident (e.g. "Primary search complete"). */ +export interface TacticalObjective { + TacticalObjectiveId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + Name: string; + /** Maps to TacticalObjectiveType. */ + ObjectiveType: number; + /** Maps to TacticalObjectiveStatus. */ + Status: number; + AutoPopulated: boolean; + CompletedByUserId: string | null; + CompletedOn: string | null; + SortOrder: number; + /** Change cursor for offline delta sync + last-write-wins. */ + ModifiedOn: string | null; +} diff --git a/src/models/v4/routes/directionsResultData.ts b/src/models/v4/routes/directionsResultData.ts deleted file mode 100644 index f9c5eb5..0000000 --- a/src/models/v4/routes/directionsResultData.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface DirectionsResultData { - RoutePlanId: string; - RouteInstanceId?: string; - EstimatedDistanceMeters?: number | null; - EstimatedDurationSeconds?: number | null; - Geometry?: string | null; - Waypoints?: DirectionsWaypointData[]; -} - -export interface DirectionsWaypointData { - RouteStopId: string; - Name?: string | null; - Address?: string | null; - Latitude?: number | null; - Longitude?: number | null; - StopOrder?: number; -} diff --git a/src/models/v4/routes/routeDeviationResultData.ts b/src/models/v4/routes/routeDeviationResultData.ts deleted file mode 100644 index 65f0613..0000000 --- a/src/models/v4/routes/routeDeviationResultData.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface RouteDeviationResultData { - RouteDeviationId: string; - RouteInstanceId: string; - Type: number; - Description: string; - Latitude: number; - Longitude: number; - OccurredOn: string; - AcknowledgedOn: string; - AcknowledgedByUserId: string; - IsAcknowledged: boolean; -} - -export enum RouteDeviationType { - OffRoute = 0, - MissedStop = 1, - UnexpectedStop = 2, - SpeedViolation = 3, - Other = 4, -} diff --git a/src/models/v4/routes/routeInputs.ts b/src/models/v4/routes/routeInputs.ts deleted file mode 100644 index cdfd2e9..0000000 --- a/src/models/v4/routes/routeInputs.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface StartRouteInput { - RoutePlanId: string; - UnitId: string; -} - -export interface EndRouteInput { - RouteInstanceId: string; -} - -export interface PauseRouteInput { - RouteInstanceId: string; -} - -export interface ResumeRouteInput { - RouteInstanceId: string; -} - -export interface CancelRouteInput { - RouteInstanceId: string; -} - -export interface CheckInInput { - RouteInstanceStopId: string; - UnitId: string; - Latitude: number; - Longitude: number; -} - -export interface CheckOutInput { - RouteInstanceStopId: string; - UnitId: string; -} - -export interface SkipStopInput { - RouteInstanceStopId: string; - Reason: string; -} - -export interface GeofenceCheckInInput { - UnitId: string; - Latitude: number; - Longitude: number; -} - -export interface UpdateStopNotesInput { - RouteInstanceStopId: string; - Notes: string; -} diff --git a/src/models/v4/routes/routeInstanceResultData.ts b/src/models/v4/routes/routeInstanceResultData.ts deleted file mode 100644 index 99a475f..0000000 --- a/src/models/v4/routes/routeInstanceResultData.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { RouteInstanceStopResultData } from './routeInstanceStopResultData'; - -export interface RouteInstanceResultData { - RouteInstanceId: string; - RoutePlanId: string; - RoutePlanName?: string | null; - Status: number; - UnitId?: number | null; - StopsCompleted?: number; - StopsTotal?: number; - TotalDistanceMeters?: number | null; - TotalDurationSeconds?: number | null; - ActualStartOn?: string | null; - ActualEndOn?: string | null; - StartedOn?: string | null; - CompletedOn?: string | null; - CancelledOn?: string | null; - AddedOn?: string; - Notes?: string | null; - // Fields that may come from progress/other endpoints - ActualRouteGeometry?: string | null; - CurrentStopIndex?: number; - ProgressPercentage?: number | null; - EtaToNextStop?: string | null; - RouteColor?: string | null; - UnitName?: string | null; -} - -export interface ActiveRouteForUnitData { - Instance: RouteInstanceResultData; - TotalStops: number; - CompletedStops: number; - PendingStops: number; - SkippedStops: number; - Stops: RouteInstanceStopResultData[]; -} - -export enum RouteInstanceStatus { - Pending = 0, - Active = 1, - Paused = 2, - Completed = 3, - Cancelled = 4, -} diff --git a/src/models/v4/routes/routeInstanceStopResultData.ts b/src/models/v4/routes/routeInstanceStopResultData.ts deleted file mode 100644 index 59c1a45..0000000 --- a/src/models/v4/routes/routeInstanceStopResultData.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface RouteInstanceStopResultData { - RouteInstanceStopId: string; - RouteInstanceId: string; - RouteStopId: string; - Status: number; - CheckedInOn: string; - CheckedOutOn: string; - SkippedOn: string; - Notes: string; - Name: string; - Address: string; - Latitude: number; - Longitude: number; - StopOrder: number; - StopType: number; - Priority: number; - PlannedArrival: string; - PlannedDeparture: string; - DwellTimeMinutes: number; - GeofenceRadiusMeters: number; - ContactId: string; - CallId: string; -} - -export enum RouteStopStatus { - Pending = 0, - InProgress = 1, - Completed = 2, - Skipped = 3, -} diff --git a/src/models/v4/routes/routePlanResultData.ts b/src/models/v4/routes/routePlanResultData.ts deleted file mode 100644 index dc08e95..0000000 --- a/src/models/v4/routes/routePlanResultData.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface RoutePlanResultData { - RoutePlanId: string; - DepartmentId?: string; - UnitId?: number | null; - Name: string; - Description?: string | null; - RouteColor?: string | null; - RouteStatus?: number; - MapboxRouteProfile?: string; - MapboxRouteGeometry?: string; - EstimatedDistanceMeters?: number | null; - EstimatedDurationSeconds?: number | null; - StopsCount?: number; - ScheduleInfo?: string | null; - AddedOn?: string; - Stops?: RouteStopResultData[]; -} - -export interface RouteStopResultData { - RouteStopId: string; - RoutePlanId: string; - Name: string; - Address: string; - Latitude: number; - Longitude: number; - StopOrder: number; - StopType: number; - Priority: number; - PlannedArrival: string; - PlannedDeparture: string; - DwellTimeMinutes: number; - GeofenceRadiusMeters: number; - ContactId: string; - CallId: string; - Notes: string; - CreatedOn: string; -} diff --git a/src/models/v4/routes/routeResults.ts b/src/models/v4/routes/routeResults.ts deleted file mode 100644 index e029c1a..0000000 --- a/src/models/v4/routes/routeResults.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { BaseV4Request } from '../baseV4Request'; -import { type DirectionsResultData } from './directionsResultData'; -import { type RouteDeviationResultData } from './routeDeviationResultData'; -import { type ActiveRouteForUnitData, type RouteInstanceResultData } from './routeInstanceResultData'; -import { type RouteInstanceStopResultData } from './routeInstanceStopResultData'; -import { type RoutePlanResultData } from './routePlanResultData'; - -export class GetRoutePlansResult extends BaseV4Request { - public Data: RoutePlanResultData[] = []; -} - -export class GetRoutePlanResult extends BaseV4Request { - public Data: RoutePlanResultData = {} as RoutePlanResultData; -} - -export class GetRouteInstanceResult extends BaseV4Request { - public Data: RouteInstanceResultData = {} as RouteInstanceResultData; -} - -export class GetActiveRouteForUnitResult extends BaseV4Request { - public Data: ActiveRouteForUnitData | null = null; -} - -export class GetRouteInstancesResult extends BaseV4Request { - public Data: RouteInstanceResultData[] = []; -} - -export class GetRouteProgressResult extends BaseV4Request { - public Data: RouteInstanceResultData = {} as RouteInstanceResultData; -} - -export class GetRouteInstanceStopsResult extends BaseV4Request { - public Data: RouteInstanceStopResultData[] = []; -} - -export class GetDirectionsResult extends BaseV4Request { - public Data: DirectionsResultData = {} as DirectionsResultData; -} - -export class GetRouteDeviationsResult extends BaseV4Request { - public Data: RouteDeviationResultData[] = []; -} - -export class SaveRoutePlanResult extends BaseV4Request { - public Data: RouteInstanceResultData = {} as RouteInstanceResultData; -} - -export class GetActiveRouteInstancesResult extends BaseV4Request { - public Data: RouteInstanceResultData[] = []; -} - -export class GetScheduledRoutesResult extends BaseV4Request { - public Data: RouteInstanceResultData[] = []; -} diff --git a/src/stores/command/__tests__/board-store.test.ts b/src/stores/command/__tests__/board-store.test.ts new file mode 100644 index 0000000..3719cfb --- /dev/null +++ b/src/stores/command/__tests__/board-store.test.ts @@ -0,0 +1,98 @@ +import { establishCommand, getCommandBoard } from '@/api/command/board'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +import { useCommandBoardStore } from '../board-store'; + +jest.mock('@/api/command/board'); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() }, +})); + +const mockGetBoard = getCommandBoard as jest.MockedFunction; +const mockEstablish = establishCommand as jest.MockedFunction; + +const fakeBoard = { + Command: { IncidentCommandId: 'ic-1', CallId: 5, Status: 0 }, + Nodes: [], + Assignments: [], + Objectives: [], + Timers: [], + Annotations: [], + Accountability: [], + Roles: [], +} as unknown as IncidentCommandBoard; + +const boardResult = { Data: fakeBoard } as unknown as Awaited>; +const emptyEstablish = { Data: null } as unknown as Awaited>; + +describe('useCommandBoardStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useCommandBoardStore.setState({ board: null, currentCallId: null, isLoading: false, error: null }); + }); + + it('fetchBoard loads the board and tracks the call id', async () => { + mockGetBoard.mockResolvedValue(boardResult); + + await useCommandBoardStore.getState().fetchBoard(5); + + const state = useCommandBoardStore.getState(); + expect(mockGetBoard).toHaveBeenCalledWith(5); + expect(state.board).toBe(fakeBoard); + expect(state.currentCallId).toBe(5); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('fetchBoard leaves a null board when the call has no command established', async () => { + mockGetBoard.mockResolvedValue({ Data: null } as unknown as Awaited>); + + await useCommandBoardStore.getState().fetchBoard(7); + + expect(useCommandBoardStore.getState().board).toBeNull(); + }); + + it('fetchBoard sets an error and clears loading when the request fails', async () => { + mockGetBoard.mockRejectedValue(new Error('network')); + + await useCommandBoardStore.getState().fetchBoard(5); + + const state = useCommandBoardStore.getState(); + expect(state.error).toBe('Failed to load command board'); + expect(state.isLoading).toBe(false); + expect(state.board).toBeNull(); + }); + + it('establishCommand establishes then reloads the board and returns true', async () => { + mockEstablish.mockResolvedValue(emptyEstablish); + mockGetBoard.mockResolvedValue(boardResult); + + const ok = await useCommandBoardStore.getState().establishCommand(5, 9); + + expect(ok).toBe(true); + expect(mockEstablish).toHaveBeenCalledWith(5, 9); + expect(mockGetBoard).toHaveBeenCalledWith(5); + expect(useCommandBoardStore.getState().board).toBe(fakeBoard); + }); + + it('establishCommand returns false and sets an error when establish fails', async () => { + mockEstablish.mockRejectedValue(new Error('boom')); + + const ok = await useCommandBoardStore.getState().establishCommand(5); + + expect(ok).toBe(false); + expect(mockGetBoard).not.toHaveBeenCalled(); + expect(useCommandBoardStore.getState().error).toBe('Failed to establish command'); + }); + + it('clearBoard resets the loaded board', () => { + useCommandBoardStore.setState({ board: fakeBoard, currentCallId: 5, error: 'x' }); + + useCommandBoardStore.getState().clearBoard(); + + const state = useCommandBoardStore.getState(); + expect(state.board).toBeNull(); + expect(state.currentCallId).toBeNull(); + expect(state.error).toBeNull(); + }); +}); diff --git a/src/stores/command/__tests__/incidents-store.test.ts b/src/stores/command/__tests__/incidents-store.test.ts new file mode 100644 index 0000000..5ec8ddf --- /dev/null +++ b/src/stores/command/__tests__/incidents-store.test.ts @@ -0,0 +1,66 @@ +import { getBundle } from '@/api/command/sync'; +import { type IncidentCommandBundle } from '@/models/v4/incidentCommand/incidentCommandBundle'; + +import { useIncidentsStore } from '../incidents-store'; + +jest.mock('@/api/command/sync'); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() }, +})); + +const mockGetBundle = getBundle as jest.MockedFunction; + +const fakeBundle = { + ServerTimestampMs: 1700000000000, + Boards: [{ Command: { CallId: 5 } }], + AdHocUnits: [], + AdHocPersonnel: [], +} as unknown as IncidentCommandBundle; + +const bundleResult = { Data: fakeBundle } as unknown as Awaited>; + +describe('useIncidentsStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useIncidentsStore.setState({ incidents: [], adHocUnits: [], adHocPersonnel: [], serverTimestampMs: null, isLoading: false, error: null }); + }); + + it('fetchActiveIncidents populates incidents + cursor from the bundle', async () => { + mockGetBundle.mockResolvedValue(bundleResult); + + await useIncidentsStore.getState().fetchActiveIncidents(); + + const state = useIncidentsStore.getState(); + expect(state.incidents).toBe(fakeBundle.Boards); + expect(state.serverTimestampMs).toBe(1700000000000); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('fetchActiveIncidents tolerates a null bundle payload', async () => { + mockGetBundle.mockResolvedValue({ Data: null } as unknown as Awaited>); + + await useIncidentsStore.getState().fetchActiveIncidents(); + + expect(useIncidentsStore.getState().incidents).toEqual([]); + }); + + it('fetchActiveIncidents sets an error and clears loading on failure', async () => { + mockGetBundle.mockRejectedValue(new Error('boom')); + + await useIncidentsStore.getState().fetchActiveIncidents(); + + const state = useIncidentsStore.getState(); + expect(state.error).toBe('Failed to load incidents'); + expect(state.isLoading).toBe(false); + }); + + it('clear resets the loaded incidents', () => { + useIncidentsStore.setState({ incidents: fakeBundle.Boards, serverTimestampMs: 1 }); + + useIncidentsStore.getState().clear(); + + expect(useIncidentsStore.getState().incidents).toEqual([]); + expect(useIncidentsStore.getState().serverTimestampMs).toBeNull(); + }); +}); diff --git a/src/stores/command/board-store.ts b/src/stores/command/board-store.ts new file mode 100644 index 0000000..6db1a18 --- /dev/null +++ b/src/stores/command/board-store.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand'; + +import { establishCommand as establishCommandApi, getCommandBoard } from '@/api/command/board'; +import { logger } from '@/lib/logging'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +interface CommandBoardState { + /** The current incident's board snapshot, or null when none is loaded / no command established. */ + board: IncidentCommandBoard | null; + /** Call id the loaded board belongs to. */ + currentCallId: number | null; + isLoading: boolean; + error: string | null; + + /** Load (or reload) the command board for a call. */ + fetchBoard: (callId: number) => Promise; + /** Establish command on a call (optionally seeding from a template), then load its board. Returns success. */ + establishCommand: (callId: number, commandDefinitionId?: number | null) => Promise; + /** Drop the loaded board (e.g. on leaving the incident screen). */ + clearBoard: () => void; +} + +export const useCommandBoardStore = create((set, get) => ({ + board: null, + currentCallId: null, + isLoading: false, + error: null, + + fetchBoard: async (callId) => { + set({ isLoading: true, error: null, currentCallId: callId }); + try { + const result = await getCommandBoard(callId); + set({ board: result?.Data ?? null, isLoading: false }); + } catch (error) { + logger.error({ message: 'Failed to fetch command board', context: { error, callId } }); + set({ isLoading: false, error: 'Failed to load command board' }); + } + }, + + establishCommand: async (callId, commandDefinitionId) => { + set({ isLoading: true, error: null }); + try { + await establishCommandApi(callId, commandDefinitionId); + // Re-read the board so the freshly-seeded lanes/command are reflected. + await get().fetchBoard(callId); + return true; + } catch (error) { + logger.error({ message: 'Failed to establish command', context: { error, callId } }); + set({ isLoading: false, error: 'Failed to establish command' }); + return false; + } + }, + + clearBoard: () => set({ board: null, currentCallId: null, error: null }), +})); diff --git a/src/stores/command/incidents-store.ts b/src/stores/command/incidents-store.ts new file mode 100644 index 0000000..8b9f81a --- /dev/null +++ b/src/stores/command/incidents-store.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; + +import { getBundle } from '@/api/command/sync'; +import { logger } from '@/lib/logging'; +import { type IncidentAdHocPersonnel } from '@/models/v4/incidentCommand/incidentAdHocPersonnel'; +import { type IncidentAdHocUnit } from '@/models/v4/incidentCommand/incidentAdHocUnit'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; + +interface IncidentsState { + /** One board per active incident command in the department (from /Sync/Bundle). */ + incidents: IncidentCommandBoard[]; + adHocUnits: IncidentAdHocUnit[]; + adHocPersonnel: IncidentAdHocPersonnel[]; + /** Server cursor for the next /Sync/Changes delta pull. */ + serverTimestampMs: number | null; + isLoading: boolean; + error: string | null; + + /** Load all active incident commands for the department in one round-trip. */ + fetchActiveIncidents: () => Promise; + clear: () => void; +} + +export const useIncidentsStore = create((set) => ({ + incidents: [], + adHocUnits: [], + adHocPersonnel: [], + serverTimestampMs: null, + isLoading: false, + error: null, + + fetchActiveIncidents: async () => { + set({ isLoading: true, error: null }); + try { + const result = await getBundle(); + const bundle = result?.Data; + set({ + incidents: bundle?.Boards ?? [], + adHocUnits: bundle?.AdHocUnits ?? [], + adHocPersonnel: bundle?.AdHocPersonnel ?? [], + serverTimestampMs: bundle?.ServerTimestampMs ?? null, + isLoading: false, + }); + } catch (error) { + logger.error({ message: 'Failed to fetch active incidents', context: { error } }); + set({ isLoading: false, error: 'Failed to load incidents' }); + } + }, + + clear: () => set({ incidents: [], adHocUnits: [], adHocPersonnel: [], serverTimestampMs: null, error: null }), +})); diff --git a/src/stores/routes/store.ts b/src/stores/routes/store.ts deleted file mode 100644 index 58ca155..0000000 --- a/src/stores/routes/store.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { create } from 'zustand'; - -import { - acknowledgeDeviation, - cancelRoute, - checkInAtStop, - checkOutFromStop, - endRoute, - geofenceCheckIn, - getActiveRouteForUnit, - getInstanceDirections, - getRouteHistory, - getRoutePlan, - getRoutePlans, - getRoutePlansForUnit, - getRouteProgress, - getStopsForInstance, - getUnacknowledgedDeviations, - pauseRoute, - resumeRoute, - skipStop, - startRoute, - updateStopNotes, -} from '@/api/routes/routes'; -import { type DirectionsResultData } from '@/models/v4/routes/directionsResultData'; -import { type RouteDeviationResultData } from '@/models/v4/routes/routeDeviationResultData'; -import { type RouteInstanceResultData } from '@/models/v4/routes/routeInstanceResultData'; -import { type RouteInstanceStopResultData } from '@/models/v4/routes/routeInstanceStopResultData'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; - -interface RoutesState { - // Data - routePlans: RoutePlanResultData[]; - activePlan: RoutePlanResultData | null; - activeInstance: RouteInstanceResultData | null; - instanceStops: RouteInstanceStopResultData[]; - directions: DirectionsResultData | null; - deviations: RouteDeviationResultData[]; - routeHistory: RouteInstanceResultData[]; - isTracking: boolean; - - // Loading states - isLoading: boolean; - isLoadingStops: boolean; - isLoadingDirections: boolean; - isInitialized: boolean; - error: string | null; - - // Actions - Route Plans - fetchRoutePlans: (unitId: string) => Promise; - fetchAllRoutePlans: () => Promise; - fetchRoutePlan: (routePlanId: string) => Promise; - - // Actions - Route Lifecycle - startRouteInstance: (routePlanId: string, unitId: string) => Promise; - endRouteInstance: (instanceId: string) => Promise; - pauseRouteInstance: (instanceId: string) => Promise; - resumeRouteInstance: (instanceId: string) => Promise; - cancelRouteInstance: (instanceId: string) => Promise; - - // Actions - Instance Tracking - fetchActiveRoute: (unitId: string) => Promise; - fetchRouteProgress: (instanceId: string) => Promise; - fetchStopsForInstance: (instanceId: string) => Promise; - fetchDirections: (instanceId: string) => Promise; - - // Actions - Stop Interactions - checkIn: (stopId: string, unitId: string, lat: number, lon: number) => Promise; - checkOut: (stopId: string, unitId: string) => Promise; - skip: (stopId: string, reason: string) => Promise; - performGeofenceCheckIn: (unitId: string, lat: number, lon: number) => Promise; - updateNotes: (stopId: string, notes: string) => Promise; - - // Actions - Deviations - fetchDeviations: () => Promise; - ackDeviation: (deviationId: string) => Promise; - - // Actions - History - fetchRouteHistory: (routePlanId: string) => Promise; - - // Actions - State management - setTracking: (tracking: boolean) => void; - clearActiveRoute: () => void; -} - -export const useRoutesStore = create((set, get) => ({ - // Initial state - routePlans: [], - activePlan: null, - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - routeHistory: [], - isTracking: false, - isLoading: false, - isLoadingStops: false, - isLoadingDirections: false, - isInitialized: false, - error: null, - - // --- Route Plans --- - fetchRoutePlans: async (unitId: string) => { - set({ isLoading: true, error: null }); - try { - const response = await getRoutePlansForUnit(unitId); - set({ - routePlans: Array.isArray(response.Data) ? response.Data : [], - isLoading: false, - isInitialized: true, - }); - } catch (error) { - set({ error: 'Failed to fetch route plans', isLoading: false }); - } - }, - - fetchAllRoutePlans: async () => { - set({ isLoading: true, error: null }); - try { - const response = await getRoutePlans(); - set({ - routePlans: Array.isArray(response.Data) ? response.Data : [], - isLoading: false, - isInitialized: true, - }); - } catch (error) { - set({ error: 'Failed to fetch route plans', isLoading: false }); - } - }, - - fetchRoutePlan: async (routePlanId: string) => { - set({ isLoading: true, error: null }); - try { - const response = await getRoutePlan(routePlanId); - set({ activePlan: response.Data, isLoading: false }); - } catch (error) { - set({ error: 'Failed to fetch route plan', isLoading: false }); - } - }, - - // --- Route Lifecycle --- - startRouteInstance: async (routePlanId: string, unitId: string) => { - set({ isLoading: true, error: null }); - try { - const response = await startRoute({ RoutePlanId: routePlanId, UnitId: unitId }); - set({ activeInstance: response.Data, isLoading: false, isTracking: true }); - } catch (error) { - set({ error: 'Failed to start route', isLoading: false }); - throw error; - } - }, - - endRouteInstance: async (instanceId: string) => { - set({ isLoading: true, error: null }); - try { - await endRoute({ RouteInstanceId: instanceId }); - set({ - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - isTracking: false, - isLoading: false, - }); - } catch (error) { - set({ error: 'Failed to end route', isLoading: false }); - throw error; - } - }, - - pauseRouteInstance: async (instanceId: string) => { - try { - await pauseRoute({ RouteInstanceId: instanceId }); - const { activeInstance } = get(); - if (activeInstance) { - set({ activeInstance: { ...activeInstance, Status: 2 }, isTracking: false }); - } - } catch (error) { - set({ error: 'Failed to pause route' }); - } - }, - - resumeRouteInstance: async (instanceId: string) => { - try { - await resumeRoute({ RouteInstanceId: instanceId }); - const { activeInstance } = get(); - if (activeInstance) { - set({ activeInstance: { ...activeInstance, Status: 1 }, isTracking: true }); - } - } catch (error) { - set({ error: 'Failed to resume route' }); - } - }, - - cancelRouteInstance: async (instanceId: string) => { - set({ isLoading: true, error: null }); - try { - await cancelRoute({ RouteInstanceId: instanceId }); - set({ - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - isTracking: false, - isLoading: false, - }); - } catch (error) { - set({ error: 'Failed to cancel route', isLoading: false }); - throw error; - } - }, - - // --- Instance Tracking --- - fetchActiveRoute: async (unitId: string) => { - try { - const response = await getActiveRouteForUnit(unitId); - const data = response.Data; - if (data?.Instance) { - set({ - activeInstance: data.Instance, - instanceStops: Array.isArray(data.Stops) ? data.Stops : [], - directions: null, - deviations: [], - isTracking: true, - }); - } else { - set({ - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - isTracking: false, - }); - } - } catch (error) { - set({ - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - isTracking: false, - }); - } - }, - - fetchRouteProgress: async (instanceId: string) => { - try { - const response = await getRouteProgress(instanceId); - set({ activeInstance: response.Data }); - } catch (error) { - set({ error: 'Failed to fetch route progress' }); - } - }, - - fetchStopsForInstance: async (instanceId: string) => { - set({ isLoadingStops: true }); - try { - const response = await getStopsForInstance(instanceId); - set({ - instanceStops: Array.isArray(response.Data) ? response.Data : [], - isLoadingStops: false, - }); - } catch (error) { - set({ error: 'Failed to fetch stops', isLoadingStops: false }); - } - }, - - fetchDirections: async (instanceId: string) => { - set({ isLoadingDirections: true }); - try { - const response = await getInstanceDirections(instanceId); - set({ directions: response.Data, isLoadingDirections: false }); - } catch (error) { - set({ error: 'Failed to fetch directions', isLoadingDirections: false }); - } - }, - - // --- Stop Interactions --- - checkIn: async (stopId: string, unitId: string, lat: number, lon: number) => { - try { - await checkInAtStop({ - RouteInstanceStopId: stopId, - UnitId: unitId, - Latitude: lat, - Longitude: lon, - }); - const { instanceStops } = get(); - set({ - instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 1, CheckedInOn: new Date().toISOString() } : s)), - }); - return true; - } catch (error) { - set({ error: 'Failed to check in at stop' }); - return false; - } - }, - - checkOut: async (stopId: string, unitId: string) => { - try { - await checkOutFromStop({ RouteInstanceStopId: stopId, UnitId: unitId }); - const { instanceStops } = get(); - set({ - instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 2, CheckedOutOn: new Date().toISOString() } : s)), - }); - return true; - } catch (error) { - set({ error: 'Failed to check out from stop' }); - return false; - } - }, - - skip: async (stopId: string, reason: string) => { - try { - await skipStop({ RouteInstanceStopId: stopId, Reason: reason }); - const { instanceStops } = get(); - set({ - instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Status: 3, SkippedOn: new Date().toISOString() } : s)), - }); - return true; - } catch (error) { - set({ error: 'Failed to skip stop' }); - return false; - } - }, - - performGeofenceCheckIn: async (unitId: string, lat: number, lon: number) => { - try { - await geofenceCheckIn({ UnitId: unitId, Latitude: lat, Longitude: lon }); - } catch (error) { - // Geofence check-in failures are non-critical - } - }, - - updateNotes: async (stopId: string, notes: string) => { - try { - await updateStopNotes({ RouteInstanceStopId: stopId, Notes: notes }); - const { instanceStops } = get(); - set({ - instanceStops: instanceStops.map((s) => (s.RouteInstanceStopId === stopId ? { ...s, Notes: notes } : s)), - }); - } catch (error) { - set({ error: 'Failed to update stop notes' }); - } - }, - - // --- Deviations --- - fetchDeviations: async () => { - try { - const response = await getUnacknowledgedDeviations(); - set({ deviations: Array.isArray(response.Data) ? response.Data : [] }); - } catch (error) { - // Non-critical - } - }, - - ackDeviation: async (deviationId: string) => { - try { - await acknowledgeDeviation(deviationId); - const { deviations } = get(); - set({ - deviations: deviations.filter((d) => d.RouteDeviationId !== deviationId), - }); - } catch (error) { - set({ error: 'Failed to acknowledge deviation' }); - } - }, - - // --- History --- - fetchRouteHistory: async (routePlanId: string) => { - set({ isLoading: true, error: null }); - try { - const response = await getRouteHistory(routePlanId); - set({ - routeHistory: Array.isArray(response.Data) ? response.Data : [], - isLoading: false, - }); - } catch (error) { - set({ error: 'Failed to fetch route history', isLoading: false }); - } - }, - - // --- State management --- - setTracking: (tracking: boolean) => set({ isTracking: tracking }), - - clearActiveRoute: () => - set({ - activeInstance: null, - instanceStops: [], - directions: null, - deviations: [], - isTracking: false, - }), -})); diff --git a/src/translations/ar.json b/src/translations/ar.json index e937807..689277f 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -479,6 +479,28 @@ "invalid_url": "يرجى إدخال عنوان URL صالح يبدأ بـ http:// أو https://", "required": "هذا الحقل مطلوب" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "أجهزة الصوت", "audio_settings": "إعدادات الصوت", @@ -909,6 +931,7 @@ "tabs": { "calls": "المكالمات", "contacts": "جهات الاتصال", + "incidents": "Incidents", "map": "الخريطة", "notes": "الملاحظات", "protocols": "البروتوكولات", diff --git a/src/translations/de.json b/src/translations/de.json index 56025b1..c5f961b 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -479,6 +479,28 @@ "invalid_url": "Bitte eine gültige URL eingeben, die mit http:// oder https:// beginnt", "required": "Dieses Feld ist erforderlich" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Audiogeräte", "audio_settings": "Audioeinstellungen", @@ -909,6 +931,7 @@ "tabs": { "calls": "Anrufe", "contacts": "Kontakte", + "incidents": "Incidents", "map": "Karte", "notes": "Notizen", "protocols": "Protokolle", diff --git a/src/translations/en.json b/src/translations/en.json index bf448c3..5a19da7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -479,6 +479,28 @@ "invalid_url": "Please enter a valid URL starting with http:// or https://", "required": "This field is required" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Audio Devices", "audio_settings": "Audio Settings", @@ -909,6 +931,7 @@ "tabs": { "calls": "Calls", "contacts": "Contacts", + "incidents": "Incidents", "map": "Map", "notes": "Notes", "protocols": "Protocols", diff --git a/src/translations/es.json b/src/translations/es.json index 7f18022..0c5073a 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -479,6 +479,28 @@ "invalid_url": "Por favor, introduce una URL válida que comience con http:// o https://", "required": "Este campo es obligatorio" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Dispositivos de audio", "audio_settings": "Configuración de audio", @@ -909,6 +931,7 @@ "tabs": { "calls": "Llamadas", "contacts": "Contactos", + "incidents": "Incidents", "map": "Mapa", "notes": "Notas", "protocols": "Protocolos", diff --git a/src/translations/fr.json b/src/translations/fr.json index 9f45e00..a08828d 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -479,6 +479,28 @@ "invalid_url": "Veuillez saisir une URL valide commençant par http:// ou https://", "required": "Ce champ est obligatoire" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Appareils audio", "audio_settings": "Paramètres audio", @@ -909,6 +931,7 @@ "tabs": { "calls": "Appels", "contacts": "Contacts", + "incidents": "Incidents", "map": "Carte", "notes": "Notes", "protocols": "Protocoles", diff --git a/src/translations/it.json b/src/translations/it.json index 1d061bc..1d01d91 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -479,6 +479,28 @@ "invalid_url": "Inserisci un URL valido che inizia con http:// o https://", "required": "Questo campo è obbligatorio" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Dispositivi audio", "audio_settings": "Impostazioni audio", @@ -909,6 +931,7 @@ "tabs": { "calls": "Chiamate", "contacts": "Contatti", + "incidents": "Incidents", "map": "Mappa", "notes": "Note", "protocols": "Protocolli", diff --git a/src/translations/pl.json b/src/translations/pl.json index c2767ae..2143f61 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -479,6 +479,28 @@ "invalid_url": "Wpisz prawidłowy URL zaczynający się od http:// lub https://", "required": "To pole jest wymagane" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Urządzenia audio", "audio_settings": "Ustawienia audio", @@ -909,6 +931,7 @@ "tabs": { "calls": "Zgłoszenia", "contacts": "Kontakty", + "incidents": "Incidents", "map": "Mapa", "notes": "Notatki", "protocols": "Protokoły", diff --git a/src/translations/sv.json b/src/translations/sv.json index 773a0b0..8005792 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -479,6 +479,28 @@ "invalid_url": "Ange en giltig URL som börjar med http:// eller https://", "required": "Detta fält är obligatoriskt" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Ljudenheter", "audio_settings": "Ljudinställningar", @@ -909,6 +931,7 @@ "tabs": { "calls": "Samtal", "contacts": "Kontakter", + "incidents": "Incidents", "map": "Karta", "notes": "Anteckningar", "protocols": "Protokoll", diff --git a/src/translations/uk.json b/src/translations/uk.json index 2c9ca18..b081bb1 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -479,6 +479,28 @@ "invalid_url": "Введіть правильний URL, що починається з http:// або https://", "required": "Це поле обов'язкове" }, + "incidents": { + "accountability": "Accountability", + "commander": "Commander", + "establish": "Establish Command", + "establish_failed": "Failed to establish command", + "establishing": "Establishing command…", + "lanes": "Command Structure", + "loading": "Loading command board…", + "no_command": "No command established", + "no_command_description": "No incident command has been established for this incident yet.", + "no_incidents": "No active incidents", + "no_incidents_description": "There are no active incident commands right now.", + "no_lanes": "No command structure yet", + "no_objectives": "No objectives yet", + "objectives": "Objectives", + "par_critical": "Critical", + "par_warning": "Warning", + "resources": "Resources", + "title": "Incidents", + "unassigned": "Unassigned", + "unnamed": "Incident" + }, "livekit": { "audio_devices": "Аудіопристрої", "audio_settings": "Налаштування звуку", @@ -909,6 +931,7 @@ "tabs": { "calls": "Виклики", "contacts": "Контакти", + "incidents": "Incidents", "map": "Карта", "notes": "Примітки", "protocols": "Протоколи", From 57026b2ba6efe1019fdaa2f702d91f38d7d79641 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 20 Jul 2026 14:39:01 -0700 Subject: [PATCH 2/2] RIC-T40 IC Work --- .github/workflows/auto-approve.yml | 26 + .github/workflows/react-native-cicd.yml | 675 +++ .gitignore | 5 + .prettierrc | 6 + .vscode/settings.json | 6 + Dockerfile | 32 +- README.md | 6 +- app.config.ts | 1 + assets/adaptive-icon.png | Bin 17380 -> 25970 bytes assets/favicon.png | Bin 6437 -> 13587 bytes assets/icon.ico | Bin 19870 -> 34959 bytes assets/icon.png | Bin 53962 -> 49157 bytes assets/icon/favicon.png | Bin 3887 -> 7883 bytes assets/ios-icon.png | Bin 53962 -> 49157 bytes assets/notification-icon.png | Bin 13485 -> 3376 bytes .../ic_notification-hdpi.png | Bin 0 -> 771 bytes .../ic_notification-mdpi.png | Bin 0 -> 557 bytes .../ic_notification-xhdpi.png | Bin 0 -> 1020 bytes .../ic_notification-xxhdpi.png | Bin 0 -> 1627 bytes .../ic_notification-xxxhdpi.png | Bin 0 -> 1715 bytes docker-compose.yml | 32 +- docker/README.md | 138 +- docker/docker-entrypoint.sh | 32 +- electron-builder.config.js | 12 +- electron/main.js | 2 +- env.js | 6 +- jest-setup.ts | 11 + package.json | 2 +- plugins/withInCallAudioModule.js | 2 +- plugins/withMediaButtonModule.js | 4 +- plugins/withNotificationIcon.js | 72 + public/service-worker.js | 6 +- src/api/common/cached-client.ts | 33 +- src/api/devices/push.ts | 8 +- src/api/incidentCommand/commands.ts | 58 + src/api/incidentCommand/incidentCommand.ts | 123 + src/api/incidentCommand/incidentResources.ts | 66 + src/api/incidentCommand/incidentRoles.ts | 23 + src/api/incidentCommand/incidentVoice.ts | 40 + src/api/incidentCommand/sync.ts | 31 + src/api/mapping/mapping.ts | 67 +- src/app/(app)/__tests__/calls.test.tsx | 4 + src/app/(app)/__tests__/command.test.tsx | 437 ++ src/app/(app)/_layout.tsx | 155 +- src/app/(app)/calls.tsx | 58 +- src/app/(app)/command.tsx | 693 +++ src/app/(app)/index.tsx | 66 +- src/app/(app)/poi/[id].tsx | 136 + src/app/(app)/settings.tsx | 26 +- src/app/call/[id].tsx | 69 +- src/app/call/__tests__/[id].security.test.tsx | 27 +- src/app/call/__tests__/[id].test.tsx | 626 +-- src/app/maps/index.tsx | 4 +- src/app/onboarding.tsx | 38 +- src/components/calls/call-card.tsx | 25 +- .../check-in-timers/check-in-bottom-sheet.tsx | 9 +- .../command/__tests__/add-lane-sheet.test.tsx | 56 + .../__tests__/add-resource-sheet.test.tsx | 106 + .../__tests__/assign-resource-sheet.test.tsx | 84 + .../command/__tests__/resource-cards.test.tsx | 102 + .../__tests__/timeline-section.test.tsx | 82 + .../command/__tests__/timers-section.test.tsx | 92 + .../__tests__/transfer-command-sheet.test.tsx | 52 + .../command/__tests__/voice-section.test.tsx | 115 + .../__tests__/work-time-light.test.tsx | 56 + .../command/add-assignment-sheet.tsx | 94 + src/components/command/add-lane-sheet.tsx | 201 + src/components/command/add-resource-sheet.tsx | 234 ++ .../command/assign-resource-sheet.tsx | 153 + src/components/command/command-section.tsx | 39 + .../command/landscape-structure-board.tsx | 420 ++ src/components/command/objectives-section.tsx | 118 + src/components/command/resource-cards.tsx | 199 + .../command/start-command-sheet.tsx | 109 + src/components/command/structure-section.tsx | 166 + src/components/command/timeline-section.tsx | 99 + src/components/command/timers-section.tsx | 143 + .../command/transfer-command-sheet.tsx | 95 + src/components/command/voice-section.tsx | 215 + src/components/common/offline-status-bar.tsx | 57 + .../__tests__/command-marker-actions.test.tsx | 91 + .../maps/command-marker-actions.tsx | 153 + src/components/maps/map-layers-sheet.tsx | 73 + src/components/maps/map-pins.tsx | 14 +- src/components/maps/map-view.web.tsx | 42 +- src/components/maps/pin-detail-modal.tsx | 4 + src/components/maps/pin-marker.tsx | 28 +- src/components/maps/static-map.tsx | 7 +- .../notifications/NotificationInbox.tsx | 7 +- .../push-notification-status.tsx | 29 - .../__tests__/role-assignment-item.test.tsx | 316 -- .../role-user-selection-modal.test.tsx | 325 -- .../__tests__/roles-bottom-sheet.test.tsx | 638 --- .../roles/__tests__/roles-modal.test.tsx | 366 -- src/components/roles/role-assignment-item.tsx | 120 - .../roles/role-user-selection-modal.tsx | 202 - src/components/roles/roles-bottom-sheet.tsx | 228 - src/components/roles/roles-modal.tsx | 201 - ...nit-selection-bottom-sheet-simple.test.tsx | 536 --- .../unit-selection-bottom-sheet.test.tsx | 574 --- .../settings/unit-selection-bottom-sheet.tsx | 214 - .../sidebar/__tests__/call-sidebar.test.tsx | 559 --- .../sidebar/__tests__/roles-sidebar.test.tsx | 280 -- .../__tests__/sidebar-content.test.tsx | 80 + .../sidebar/__tests__/status-sidebar.test.tsx | 257 -- .../__tests__/unit-sidebar-minimal.test.tsx | 133 - .../unit-sidebar-simplified.test.tsx | 222 - .../sidebar/__tests__/unit-sidebar.test.tsx | 219 - src/components/sidebar/call-sidebar.tsx | 199 - .../sidebar/check-in-sidebar-widget.tsx | 69 - src/components/sidebar/roles-sidebar.tsx | 55 - src/components/sidebar/sidebar-content.tsx | 113 +- src/components/sidebar/sidebar.web.tsx | 8 +- src/components/sidebar/status-sidebar.tsx | 41 - src/components/sidebar/unit-sidebar.tsx | 157 - .../gps-coordinate-duplication-fix.test.tsx | 224 - .../location-update-validation.test.tsx | 306 -- .../__tests__/status-bottom-sheet.test.tsx | 3704 ----------------- .../__tests__/status-gps-debug.test.tsx | 193 - .../status-gps-integration-working.test.tsx | 553 --- .../__tests__/status-gps-integration.test.tsx | 460 -- src/components/status/status-bottom-sheet.tsx | 899 ---- src/components/ui/alert-dialog/index.tsx | 18 +- src/components/ui/drawer/index.tsx | 26 +- .../__tests__/use-quick-check-in.test.ts | 13 +- .../use-status-signalr-updates.test.tsx | 228 - src/hooks/use-command-map-overlay.ts | 60 + src/hooks/use-map-geolocation-updates.ts | 57 + src/hooks/use-quick-check-in.ts | 10 +- src/hooks/use-status-signalr-updates.ts | 66 - src/lib/cache/cache-manager.ts | 18 +- src/lib/incident-command-utils.ts | 136 + src/lib/storage/app.tsx | 12 - src/lib/utils.ts | 13 + src/models/offline-queue/queued-event.ts | 97 + ...nUnitInput.ts => pushRegistrationInput.ts} | 4 +- .../incidentCommand/incidentCommandModels.ts | 405 ++ src/models/v4/mapping/mappingResults.ts | 10 +- .../app-initialization.service.test.ts | 11 +- .../__tests__/app-reset.service.test.ts | 21 - .../location-foreground-permissions.test.ts | 10 +- src/services/__tests__/location.test.ts | 107 +- .../offline-event-manager.service.test.ts | 22 + .../__tests__/push-notification.test.ts | 18 +- src/services/app-initialization.service.ts | 26 +- src/services/app-reset.service.ts | 12 +- src/services/callkeep.service.android.ts | 6 +- src/services/location.ts | 51 +- src/services/offline-event-manager.service.ts | 255 ++ src/services/push-notification.ts | 54 +- src/services/signalr.service.ts | 6 +- src/stores/app/__tests__/core-store.test.ts | 43 +- .../app/__tests__/livekit-store.test.ts | 2 +- src/stores/app/core-store.ts | 120 +- src/stores/app/livekit-store.ts | 2 +- src/stores/calls/store.ts | 251 +- src/stores/command/__tests__/store.test.ts | 497 +++ src/stores/command/store.ts | 1150 +++++ src/stores/maps/store.ts | 23 +- src/stores/roles/store.ts | 10 +- src/stores/signalr/signalr-store.ts | 11 + src/stores/status/__tests__/store.test.ts | 385 -- src/stores/status/store.ts | 288 -- src/stores/units/store.ts | 12 +- src/translations/ar.json | 212 +- src/translations/de.json | 214 +- src/translations/en.json | 214 +- src/translations/es.json | 214 +- src/translations/fr.json | 214 +- src/translations/it.json | 214 +- src/translations/pl.json | 214 +- src/translations/sv.json | 214 +- src/translations/uk.json | 214 +- 173 files changed, 11432 insertions(+), 14942 deletions(-) create mode 100644 .github/workflows/auto-approve.yml create mode 100644 .github/workflows/react-native-cicd.yml create mode 100644 .prettierrc create mode 100644 .vscode/settings.json create mode 100644 assets/notification-icons/ic_notification-hdpi.png create mode 100644 assets/notification-icons/ic_notification-mdpi.png create mode 100644 assets/notification-icons/ic_notification-xhdpi.png create mode 100644 assets/notification-icons/ic_notification-xxhdpi.png create mode 100644 assets/notification-icons/ic_notification-xxxhdpi.png create mode 100644 plugins/withNotificationIcon.js create mode 100644 src/api/incidentCommand/commands.ts create mode 100644 src/api/incidentCommand/incidentCommand.ts create mode 100644 src/api/incidentCommand/incidentResources.ts create mode 100644 src/api/incidentCommand/incidentRoles.ts create mode 100644 src/api/incidentCommand/incidentVoice.ts create mode 100644 src/api/incidentCommand/sync.ts create mode 100644 src/app/(app)/__tests__/command.test.tsx create mode 100644 src/app/(app)/command.tsx create mode 100644 src/app/(app)/poi/[id].tsx create mode 100644 src/components/command/__tests__/add-lane-sheet.test.tsx create mode 100644 src/components/command/__tests__/add-resource-sheet.test.tsx create mode 100644 src/components/command/__tests__/assign-resource-sheet.test.tsx create mode 100644 src/components/command/__tests__/resource-cards.test.tsx create mode 100644 src/components/command/__tests__/timeline-section.test.tsx create mode 100644 src/components/command/__tests__/timers-section.test.tsx create mode 100644 src/components/command/__tests__/transfer-command-sheet.test.tsx create mode 100644 src/components/command/__tests__/voice-section.test.tsx create mode 100644 src/components/command/__tests__/work-time-light.test.tsx create mode 100644 src/components/command/add-assignment-sheet.tsx create mode 100644 src/components/command/add-lane-sheet.tsx create mode 100644 src/components/command/add-resource-sheet.tsx create mode 100644 src/components/command/assign-resource-sheet.tsx create mode 100644 src/components/command/command-section.tsx create mode 100644 src/components/command/landscape-structure-board.tsx create mode 100644 src/components/command/objectives-section.tsx create mode 100644 src/components/command/resource-cards.tsx create mode 100644 src/components/command/start-command-sheet.tsx create mode 100644 src/components/command/structure-section.tsx create mode 100644 src/components/command/timeline-section.tsx create mode 100644 src/components/command/timers-section.tsx create mode 100644 src/components/command/transfer-command-sheet.tsx create mode 100644 src/components/command/voice-section.tsx create mode 100644 src/components/common/offline-status-bar.tsx create mode 100644 src/components/maps/__tests__/command-marker-actions.test.tsx create mode 100644 src/components/maps/command-marker-actions.tsx create mode 100644 src/components/maps/map-layers-sheet.tsx delete mode 100644 src/components/push-notification/push-notification-status.tsx delete mode 100644 src/components/roles/__tests__/role-assignment-item.test.tsx delete mode 100644 src/components/roles/__tests__/role-user-selection-modal.test.tsx delete mode 100644 src/components/roles/__tests__/roles-bottom-sheet.test.tsx delete mode 100644 src/components/roles/__tests__/roles-modal.test.tsx delete mode 100644 src/components/roles/role-assignment-item.tsx delete mode 100644 src/components/roles/role-user-selection-modal.tsx delete mode 100644 src/components/roles/roles-bottom-sheet.tsx delete mode 100644 src/components/roles/roles-modal.tsx delete mode 100644 src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx delete mode 100644 src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx delete mode 100644 src/components/settings/unit-selection-bottom-sheet.tsx delete mode 100644 src/components/sidebar/__tests__/call-sidebar.test.tsx delete mode 100644 src/components/sidebar/__tests__/roles-sidebar.test.tsx create mode 100644 src/components/sidebar/__tests__/sidebar-content.test.tsx delete mode 100644 src/components/sidebar/__tests__/status-sidebar.test.tsx delete mode 100644 src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx delete mode 100644 src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx delete mode 100644 src/components/sidebar/__tests__/unit-sidebar.test.tsx delete mode 100644 src/components/sidebar/call-sidebar.tsx delete mode 100644 src/components/sidebar/check-in-sidebar-widget.tsx delete mode 100644 src/components/sidebar/roles-sidebar.tsx delete mode 100644 src/components/sidebar/status-sidebar.tsx delete mode 100644 src/components/sidebar/unit-sidebar.tsx delete mode 100644 src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx delete mode 100644 src/components/status/__tests__/location-update-validation.test.tsx delete mode 100644 src/components/status/__tests__/status-bottom-sheet.test.tsx delete mode 100644 src/components/status/__tests__/status-gps-debug.test.tsx delete mode 100644 src/components/status/__tests__/status-gps-integration-working.test.tsx delete mode 100644 src/components/status/__tests__/status-gps-integration.test.tsx delete mode 100644 src/components/status/status-bottom-sheet.tsx delete mode 100644 src/hooks/__tests__/use-status-signalr-updates.test.tsx create mode 100644 src/hooks/use-command-map-overlay.ts create mode 100644 src/hooks/use-map-geolocation-updates.ts delete mode 100644 src/hooks/use-status-signalr-updates.ts create mode 100644 src/lib/incident-command-utils.ts rename src/models/v4/device/{pushRegistrationUnitInput.ts => pushRegistrationInput.ts} (63%) create mode 100644 src/models/v4/incidentCommand/incidentCommandModels.ts create mode 100644 src/stores/command/__tests__/store.test.ts create mode 100644 src/stores/command/store.ts delete mode 100644 src/stores/status/__tests__/store.test.ts delete mode 100644 src/stores/status/store.ts diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml new file mode 100644 index 0000000..0427314 --- /dev/null +++ b/.github/workflows/auto-approve.yml @@ -0,0 +1,26 @@ +name: Auto approve + +on: + issue_comment: + types: + - created + +jobs: + auto-approve: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/github-script@v6 + name: Approve LGTM Review + if: github.actor == 'ucswift' && contains(github.event.comment.body, 'Approve') + with: + script: | + github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + review_id: 1, + event: 'APPROVE', + body: 'This PR is approved.' + }) \ No newline at end of file diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml new file mode 100644 index 0000000..d5cca46 --- /dev/null +++ b/.github/workflows/react-native-cicd.yml @@ -0,0 +1,675 @@ +name: React Native CI/CD + +on: + push: + branches: [main, master] + paths-ignore: + - '**.md' + - 'LICENSE' + - 'docs/**' + pull_request: + branches: [main, master] + workflow_dispatch: + inputs: + buildType: + type: choice + description: 'Build type to run' + options: + - dev + - prod-apk + - prod-aab + - ios-dev + - ios-adhoc + - ios-prod + - all + platform: + type: choice + description: 'Platform to build' + default: 'all' + options: + - android + - ios + - all + +# Set minimal permissions by default +permissions: + contents: read + +env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} + EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} + EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} + CREDENTIALS_JSON_BASE64: ${{ secrets.CREDENTIALS_JSON_BASE64 }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + IC_BASE_API_URL: ${{ secrets.IC_BASE_API_URL }} + IC_LOGGING_KEY: ${{ secrets.IC_LOGGING_KEY }} + IC_MAPBOX_DLKEY: ${{ secrets.IC_MAPBOX_DLKEY }} + IC_MAPBOX_PUBKEY: ${{ secrets.IC_MAPBOX_PUBKEY }} + IC_SENTRY_DSN: ${{ secrets.IC_SENTRY_DSN }} + IC_ANDROID_KS: ${{ secrets.IC_ANDROID_KS }} + IC_GOOGLE_SERVICES: ${{ secrets.IC_GOOGLE_SERVICES }} + IC_IOS_GOOGLE_SERVICES: ${{ secrets.IC_IOS_GOOGLE_SERVICES }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APPLE_APIKEY: ${{ secrets.APPLE_APIKEY }} + MATCH_IC_BUNDLEID: ${{ secrets.MATCH_IC_BUNDLEID }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + EXPO_ACCOUNT_OWNER: ${{ secrets.EXPO_ACCOUNT_OWNER }} + BUNDLE_ID: ${{ secrets.MATCH_IC_BUNDLEID }} + EAS_PROJECT_ID: ${{ secrets.EAS_PROJECT_ID }} + SCHEME: ${{ secrets.SCHEME }} + IC_IOS_CERT: ${{ secrets.IC_IOS_CERT }} + EXPO_ASC_API_KEY_PATH: ${{ secrets.EXPO_ASC_API_KEY_PATH }} + EXPO_ASC_KEY_ID: ${{ secrets.EXPO_ASC_KEY_ID }} + EXPO_ASC_ISSUER_ID: ${{ secrets.EXPO_ASC_ISSUER_ID }} + EXPO_APPLE_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} + EXPO_APPLE_TEAM_TYPE: ${{ secrets.EXPO_APPLE_TEAM_TYPE }} + IC_COUNTLY_APP_KEY: ${{ secrets.IC_COUNTLY_APP_KEY }} + IC_COUNTLY_SERVER_URL: ${{ secrets.IC_COUNTLY_SERVER_URL }} + IC_APP_KEY: ${{ secrets.IC_APP_KEY }} + APP_KEY: ${{ secrets.APP_KEY }} + NODE_OPTIONS: --openssl-legacy-provider + CHANGERAWR_API_KEY: ${{ secrets.CHANGERAWR_API_KEY }} + CHANGERAWR_API_URL: ${{ secrets.CHANGERAWR_API_URL }} + YARN_PRODUCTION: 'false' + +jobs: + check-skip: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + steps: + - name: Skip CI check + run: echo "Proceeding with workflow" + + test: + needs: check-skip + runs-on: ubuntu-latest + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'yarn' + + - name: 📦 Setup yarn cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/yarn + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: yarn install --frozen-lockfile + + - name: 🧪 Run Checks and Tests + run: yarn check-all + + build-mobile: + needs: test + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' + permissions: + contents: write # Required for creating releases + strategy: + matrix: + platform: [android, ios] + runs-on: ${{ matrix.platform == 'ios' && 'macos-26' || 'ubuntu-latest' }} + environment: RNBuild + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 🧹 Free up disk space (Android) + if: matrix.platform == 'android' + run: | + echo "Disk space before cleanup:" + df -h + + # Remove unnecessary large packages + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/.ghcup + sudo rm -rf /usr/lib/jvm/temurin-11-jdk-amd64 + + # Remove Docker images + docker system prune -af || true + + # Remove cached apt packages + sudo apt-get clean || true + sudo rm -rf /var/lib/apt/lists/* + + # Remove swap + sudo swapoff -a || true + sudo rm -f /swapfile || true + + echo "Disk space after cleanup:" + df -h + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'yarn' + + - name: Setup Expo + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: 📦 Setup yarn cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/yarn + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: | + yarn install --frozen-lockfile + + - name: 📋 Create Google Json File + if: ${{ matrix.platform == 'android' }} + run: | + echo $IC_GOOGLE_SERVICES | base64 -d > google-services.json + + - name: 📋 Create Google Json File for iOS + if: ${{ matrix.platform == 'ios' }} + run: | + echo $IC_IOS_GOOGLE_SERVICES | base64 -d > GoogleService-Info.plist + + - name: 📋 Update package.json Versions + run: | + # Ensure jq exists on both Linux and macOS + if ! command -v jq >/dev/null 2>&1; then + echo "Installing jq..." + if [[ "$RUNNER_OS" == "Linux" ]]; then + sudo apt-get update && sudo apt-get install -y jq + elif [[ "$RUNNER_OS" == "macOS" ]]; then + brew update && brew install jq + else + echo "Unsupported OS for auto-install of jq" >&2; exit 1 + fi + fi + + # Fix the main entry in package.json + if [ -f ./package.json ]; then + # Create a backup + cp package.json package.json.bak + # Update the package.json + jq '.version = "1.${{ github.run_number }}"' package.json > package.json.tmp && mv package.json.tmp package.json + jq '.versionCode = "1${{ github.run_number }}"' package.json > package.json.tmp && mv package.json.tmp package.json + echo "Updated package.json versions" + cat package.json | grep "version" + cat package.json | grep "versionCode" + else + echo "package.json not found" + exit 1 + fi + + - name: 📱 Setup EAS build cache + uses: actions/cache@v4 + with: + path: ~/.eas-build-local + key: ${{ runner.os }}-eas-build-local-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-eas-build-local- + + - name: 🔄 Verify EAS CLI installation + run: | + echo "EAS CLI version:" + eas --version + + - name: 📋 Create iOS Cert + run: | + echo $IC_IOS_CERT | base64 -d > AuthKey_HRBP5FNJN6.p8 + + - name: 📋 Restore gradle.properties + env: + GRADLE_PROPERTIES: ${{ secrets.GRADLE_PROPERTIES }} + shell: bash + run: | + mkdir -p ~/.gradle/ + echo ${GRADLE_PROPERTIES} > ~/.gradle/gradle.properties + + - name: 📱 Build Development APK + if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'dev')) + run: | + # Build with increased memory limit + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform android --profile development --local --non-interactive --output=./ResgridIC-dev.apk + env: + NODE_ENV: development + + - name: 📱 Build Production APK + if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'prod-apk')) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform android --profile production-apk --local --non-interactive --output=./ResgridIC-prod.apk + env: + NODE_ENV: production + + - name: 📱 Build Production AAB + if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'prod-aab')) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform android --profile production --local --non-interactive --output=./ResgridIC-prod.aab + env: + NODE_ENV: production + + - name: 📱 Build iOS Development + if: (matrix.platform == 'ios' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'ios-dev')) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform ios --profile development --local --non-interactive --output=./ResgridIC-ios-dev.ipa + env: + NODE_ENV: development + + - name: 📱 Build iOS Ad-Hoc + if: (matrix.platform == 'ios' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'ios-adhoc')) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform ios --profile internal --local --non-interactive --output=./ResgridIC-ios-adhoc.ipa + env: + NODE_ENV: production + + - name: 📱 Build iOS Production + if: (matrix.platform == 'ios' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'ios-prod')) + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform ios --profile production --local --non-interactive --output=./ResgridIC-ios-prod.ipa + env: + NODE_ENV: production + + - name: 📦 Upload build artifacts to GitHub + uses: actions/upload-artifact@v4 + with: + name: app-builds-${{ matrix.platform }} + path: | + ./ResgridIC-dev.apk + ./ResgridIC-prod.apk + ./ResgridIC-prod.aab + ./ResgridIC-ios-dev.ipa + ./ResgridIC-ios-adhoc.ipa + ./ResgridIC-ios-prod.ipa + retention-days: 7 + + - name: 📦 Setup Firebase CLI + uses: w9jds/setup-firebase@main + with: + tools-version: 11.9.0 + firebase_token: ${{ secrets.FIREBASE_TOKEN }} + + - name: 📦 Upload Android artifact to Firebase App Distribution + if: (matrix.platform == 'android') + run: | + firebase appdistribution:distribute ./ResgridIC-prod.apk --app ${{ secrets.FIREBASE_ANDROID_APP_ID }} --groups "testers" + + - name: 📦 Upload iOS artifact to Firebase App Distribution + if: (matrix.platform == 'ios') + run: | + firebase appdistribution:distribute ./ResgridIC-ios-adhoc.ipa --app ${{ secrets.FIREBASE_IOS_APP_ID }} --groups "testers" + + - name: 📋 Prepare Release Notes file + if: ${{ matrix.platform == 'android' }} + run: ./scripts/extract-release-notes.sh "1.${{ github.run_number }}" "${{ github.event_name }}" "${{ github.repository }}" "${{ github.sha }}" "${{ github.token }}" + + - name: 📝 Send Release Notes to Changerawr + if: ${{ matrix.platform == 'android' }} + run: | + set -eo pipefail + + # Check if required secrets are set + if [ -z "$CHANGERAWR_API_URL" ] || [ -z "$CHANGERAWR_API_KEY" ]; then + echo "⚠️ Changerawr API credentials not configured, skipping release notes submission" + exit 0 + fi + + # Read release notes + RELEASE_NOTES=$(cat RELEASE_NOTES.md) + VERSION="1.${{ github.run_number }}" + + # Prepare JSON payload + PAYLOAD=$(jq -n \ + --arg version "$VERSION" \ + --arg notes "$RELEASE_NOTES" \ + '{ + "version": $version, + "title": ("Release v" + $version), + "content": $notes + }') + + echo "Sending release notes to Changerawr..." + + # Send to Changerawr API + RESPONSE=$(curl -X POST "$CHANGERAWR_API_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CHANGERAWR_API_KEY" \ + -d "$PAYLOAD" \ + -w "\n%{http_code}" \ + -s) + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "✅ Successfully sent release notes to Changerawr (HTTP $HTTP_CODE)" + echo "Response: $RESPONSE_BODY" + else + echo "⚠️ Failed to send release notes to Changerawr (HTTP $HTTP_CODE)" + echo "Response: $RESPONSE_BODY" + # Don't fail the build, just warn + fi + + - name: �📦 Create Release + if: ${{ matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'prod-apk') }} + uses: ncipollo/release-action@v1 + with: + tag: '1.${{ github.run_number }}' + commit: ${{ github.sha }} + makeLatest: true + allowUpdates: true + name: '1.${{ github.run_number }}' + artifacts: './ResgridIC-prod.apk' + bodyFile: 'RELEASE_NOTES.md' + build-web: + needs: test + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' + permissions: + contents: read + runs-on: ubuntu-latest + environment: RNBuild + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'yarn' + + - name: 📦 Setup yarn cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/yarn + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: yarn install --frozen-lockfile + + - name: 🌐 Build Web Application + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + yarn web:build + env: + NODE_ENV: production + APP_ENV: production + # Unset IC_* env vars for web build to use defaults from env.js + # Actual values will be injected at Docker container runtime via envsubst + IC_BASE_API_URL: '' + IC_API_VERSION: '' + IC_RESGRID_API_URL: '' + IC_CHANNEL_HUB_NAME: '' + IC_REALTIME_GEO_HUB_NAME: '' + IC_LOGGING_KEY: '' + IC_APP_KEY: '' + IC_MAPBOX_PUBKEY: '' + IC_MAPBOX_DLKEY: '' + IC_SENTRY_DSN: '' + IC_COUNTLY_APP_KEY: '' + IC_COUNTLY_SERVER_URL: '' + + - name: 📦 Upload web build artifacts + uses: actions/upload-artifact@v4 + with: + name: web-build + path: dist/ + retention-days: 7 + + build-docker: + needs: build-web + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' + permissions: + contents: read + packages: write # Required for pushing to GHCR + runs-on: ubuntu-latest + environment: RNBuild + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: � Check Docker Hub credentials availability + id: docker-creds + run: | + if [[ -n "${{ secrets.DOCKER_USERNAME }}" && -n "${{ secrets.DOCKER_PASSWORD }}" ]]; then + echo "available=true" >> $GITHUB_OUTPUT + else + echo "available=false" >> $GITHUB_OUTPUT + fi + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🐳 Log in to Docker Hub + if: steps.docker-creds.outputs.available == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 🐳 Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 📋 Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: resgridllc/ic + tags: | + type=raw,value=1.${{ github.run_number }} + type=raw,value=latest + type=sha,prefix={{branch}}- + + - name: 🐳 Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=1.${{ github.run_number }} + platforms: linux/amd64,linux/arm64 + + build-electron: + needs: test + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' + permissions: + contents: write # Required for creating releases + strategy: + matrix: + os: [windows-latest, macos-15, ubuntu-latest] + runs-on: ${{ matrix.os }} + environment: RNBuild + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'yarn' + + - name: 📦 Setup yarn cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/yarn + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: yarn install --frozen-lockfile + + - name: 📋 Update package.json Versions + shell: bash + run: | + # Ensure jq exists on all platforms + if ! command -v jq > /dev/null 2>&1; then + echo "Installing jq..." + if [[ "$RUNNER_OS" == "Linux" ]]; then + sudo apt-get update && sudo apt-get install -y jq + elif [[ "$RUNNER_OS" == "macOS" ]]; then + brew update && brew install jq + elif [[ "$RUNNER_OS" == "Windows" ]]; then + choco install jq -y + else + echo "Unsupported OS for auto-install of jq" >&2; exit 1 + fi + fi + + # Update the package.json with 3-octet version for Electron (electron-builder requires semantic versioning) + if [ -f ./package.json ]; then + cp package.json package.json.bak + jq '.version = "1.${{ github.run_number }}.0"' package.json > package.json.tmp && mv package.json.tmp package.json + echo "Updated package.json version for Electron build" + cat package.json | grep "version" + else + echo "package.json not found" + exit 1 + fi + + - name: 🖥️ Build Electron (Windows) + if: matrix.os == 'windows-latest' + run: | + $env:NODE_OPTIONS = "--openssl-legacy-provider --max_old_space_size=4096" + yarn electron:build:win + env: + NODE_ENV: production + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 🖥️ Build Electron (macOS) + if: matrix.os == 'macos-15' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + yarn electron:build:mac + env: + NODE_ENV: production + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 🖥️ Build Electron (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + yarn electron:build:linux + env: + NODE_ENV: production + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 📦 Upload Electron artifacts (Windows) + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: electron-windows + path: | + electron-dist/*.exe + electron-dist/*.msi + retention-days: 7 + + - name: 📦 Upload Electron artifacts (macOS) + if: matrix.os == 'macos-15' + uses: actions/upload-artifact@v4 + with: + name: electron-macos + path: | + electron-dist/*.dmg + electron-dist/*.zip + retention-days: 7 + + - name: 📦 Upload Electron artifacts (Linux) + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: electron-linux + path: | + electron-dist/*.AppImage + electron-dist/*.deb + electron-dist/*.rpm + retention-days: 7 + + release-electron: + needs: build-electron + if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) || github.event_name == 'workflow_dispatch' + permissions: + contents: write # Required for creating releases + runs-on: ubuntu-latest + environment: RNBuild + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@v4 + + - name: 📋 Prepare Release Notes file + run: ./scripts/extract-release-notes.sh "1.${{ github.run_number }}" "${{ github.event_name }}" "${{ github.repository }}" "${{ github.sha }}" "${{ github.token }}" + + - name: 📥 Download all Electron artifacts + uses: actions/download-artifact@v4 + with: + path: electron-artifacts/ + pattern: electron-* + merge-multiple: false + + - name: 📦 Prepare Electron release artifacts + run: | + # Find all matching Electron artifacts and check for existence + if [ -z "$(find electron-artifacts/ -type f \( -name "*.exe" -o -name "*.msi" -o -name "*.dmg" -o -name "*.zip" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \) -print -quit)" ]; then + echo "Error: No Electron artifacts found in electron-artifacts/ directory." + exit 1 + fi + + # Create release-artifacts directory and copy files using null-delimited find/xargs + mkdir -p release-artifacts + find electron-artifacts/ -type f \( -name "*.exe" -o -name "*.msi" -o -name "*.dmg" -o -name "*.zip" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \) -print0 | xargs -0 cp -t release-artifacts/ + + echo "Release artifacts prepared:" + ls -lh release-artifacts/ + + - name: 📦 Create GitHub Release with Electron builds + uses: ncipollo/release-action@v1 + with: + tag: '1.${{ github.run_number }}' + commit: ${{ github.sha }} + makeLatest: true + allowUpdates: true + name: '1.${{ github.run_number }}' + artifacts: 'release-artifacts/*' + bodyFile: 'RELEASE_NOTES.md' diff --git a/.gitignore b/.gitignore index 7b74ec7..2e09467 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ yarn-error.log google-services.json GoogleService-Info.plist credentials.json +.env.* +!.env.example Gemfile.lock Gemfile *.ipa @@ -38,6 +40,9 @@ Gemfile expo-env.d.ts # @end expo-cli .dual-graph/ +.dual-graph-pro/ .mcp.json opencode.json /.dual-graph-pro +electron-dist/ +/.codex diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ef9f3b7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 220, + "endOfLine": "auto" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..09b0324 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "i18n-ally.localesPaths": [ + "src/lib/i18n", + "src/models/v4/messages" + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f27e0a9..4fb0a73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,22 +41,22 @@ EXPOSE 80 # Set default environment variables ENV APP_ENV=production \ - UNIT_NAME="Resgrid Unit" \ - UNIT_SCHEME="ResgridUnit" \ - UNIT_BUNDLE_ID="com.resgrid.unit" \ - UNIT_PACKAGE="com.resgrid.unit" \ - UNIT_VERSION="0.0.1" \ - UNIT_BASE_API_URL="https://api.resgrid.com" \ - UNIT_API_VERSION="v4" \ - UNIT_RESGRID_API_URL="/api/v4" \ - UNIT_CHANNEL_HUB_NAME="eventingHub" \ - UNIT_REALTIME_GEO_HUB_NAME="geolocationHub" \ - UNIT_LOGGING_KEY="" \ - UNIT_APP_KEY="" \ - UNIT_MAPBOX_PUBKEY="" \ - UNIT_SENTRY_DSN="" \ - UNIT_COUNTLY_APP_KEY="" \ - UNIT_COUNTLY_SERVER_URL="" + IC_NAME="Resgrid IC" \ + IC_SCHEME="ResgridIC" \ + IC_BUNDLE_ID="com.resgrid.command" \ + IC_PACKAGE="com.resgrid.command" \ + IC_VERSION="0.0.1" \ + IC_BASE_API_URL="https://api.resgrid.com" \ + IC_API_VERSION="v4" \ + IC_RESGRID_API_URL="/api/v4" \ + IC_CHANNEL_HUB_NAME="eventingHub" \ + IC_REALTIME_GEO_HUB_NAME="geolocationHub" \ + IC_LOGGING_KEY="" \ + IC_APP_KEY="" \ + IC_MAPBOX_PUBKEY="" \ + IC_SENTRY_DSN="" \ + IC_COUNTLY_APP_KEY="" \ + IC_COUNTLY_SERVER_URL="" # Use entrypoint to inject environment variables at runtime ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/README.md b/README.md index fd308c1..829c66f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
-

Resgrid Unit Application

-

Resgrid Unit is a tablet and desktop optimized application for use in Appratuses, Vehicles in Teams or Squads

+

Resgrid IC Application

+

Resgrid IC is a tablet and desktop optimized application for use in Appratuses, Vehicles in Teams or Squads

logo @@ -34,7 +34,7 @@ ### :package: Deployment Options -Resgrid Unit can be deployed in multiple ways: +Resgrid IC can be deployed in multiple ways: #### 📱 Mobile Applications - **Android**: APK and AAB builds for Google Play Store and direct distribution diff --git a/app.config.ts b/app.config.ts index 978a147..61494fd 100644 --- a/app.config.ts +++ b/app.config.ts @@ -223,6 +223,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './customGradle.plugin.js', './customManifest.plugin.js', './plugins/withForegroundNotifications.js', + './plugins/withNotificationIcon.js', './plugins/withNotificationSounds.js', './plugins/withMediaButtonModule.js', [ diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index 2c25798e69086f09e0a02f74a5d32fc2cca6c3e0..1a38832b9ac0f687726c8268decf571415aab7c0 100644 GIT binary patch literal 25970 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
yZi>#tz%8n__z>(Lzi>Pk}=$kLsoiLe>pF( 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 diff --git a/assets/favicon.png b/assets/favicon.png index c7ae4a24e4e23ed2b911a2da63fec9645cf84ac5..3fda96b10afa97b1950bd71cbf5f8c42fa1787de 100644 GIT binary patch literal 13587 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 Z{~we=Ymj37j=ul^002ovPDHLkV1lFtF%kd( literal 6437 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

{T|SR>>ir9B61p_e;rt-uxFGvTPPOX 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 diff --git a/assets/icon/favicon.png b/assets/icon/favicon.png index a463af30b972cb2767db0a8cc60628953a09c581..3b5971525d31105601c6e27a05d614f97fde9a86 100644 GIT binary patch literal 7883 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 literal 3887 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

{T|SR>>ir9B61p_e;rt-uxFGvTPPOX zUQRl$2A!Bh3Ei3?xnmSWJ9DDh4J2Wti~-Hx)XqatJyHBXSKz%%AhLF6zsI4X8$>Cg zChYfN(k&Iz9PE&4MT`J)Bhu2`UPfAAguo3P1g~9IO9OR3oTy*SnIne|teJmCY&yLm zUXr8w!X>wkWle4zZG$-E&8te2jkz->s233slv{SI6CoZlb05*_xm;=jY<<5Pep4dE zTk@SFFpj~0&2-`OYBJpuq;jFQK$Z6mF~b6&i1{{n8kBed{P#CP9%%ZY2DZGNJ z!kLh}zG@wZmrqX*EiyjK_slkAg2DJ?Q)qVRX3I#XQYBm0`Grt)U-{Q6bREviZ0(jlcwd_C2F zqzJXeVL;6Rxgr~_brPc4HYnt|w1!dDx$2mcs8(?4PM>4-Sn1cz4tiM4k^>82>EGjM zz(-T9QQ+`yz(uUk>IDrZt)Y13flSF?Y-547z>ox~Rl%(R*nyKYPqmW=b5;Meltn7W zTnRZYEwF1%_9>1DK7PDjF_NTk{Ge&=|B8K9;KeSbat3OJ|0Z3&$bYH$oFd$ZrRr0v zzpJ>Cox}7VFUFohtyi1vOwQpO7pA!kPO4W(e&>Uk^c*(T68Z9_Ltc>StdkW8k|bFL zHP4+}-}Ni5%P+%O&MrCwP`d}M^nWHW3)(7ww}D`oaKrbYXTnS=N%)vQ;B8eI&4p=j zX`(rNJ5+XaMPue-9ImZQ!)!3)^oTy%=E0HxcH1Gnl-I)Wj+r_LirWkBvFE6cSd0VL z0KaKeQA|(}35AdR?`ND1N!-1XJr+r5=aDk0DO&)awGacWH$kJ+>D#bj&@cZ`-B~Ay zlYSg!3yJ@2=C=k94uWJUBiULA^Q>@8E}% z#jMXGXl0w9_4!m zU3aPC zDg)t7rn_h)jJ&b<`3C2GqLbJA-(7U7L-_l^kg06NyZ6uiBA&XcS_9NU?eGFX5hD-A%%6wz9Eh zl9Z=#as8kVx?G%+QbzK$&I}&~e4J(!9Zy4l{ZEba<{Px3Mp3v%cYq9J8na-69TIvq z$f<}NbF{hUb02QQs;i`da;99m8C8;04x-=}QgZdlp%hE(WTqsfj=Q)A5bUbPOV!D9 zom&@XbO`_Tv&bG^P7BQ|%mf!>;;BCR3ca!a1?F zB2_%GqEV&NKCFF4siHaY?la1S+{)M*zB}4*Np*R4aggDPuH0@?wHz?j0PNvx1f-Wr zm=b8orE7cw!!r$`el*VWnVq}^iY5D0Ikjk^1wJtRTiTr>Tv4;%Cn?g{%3Zk##x&lJ z8kA3a4u=l`pr!e{PI5_v{$hh61f(ZmOJDBZ0w0+i`Y;|s)PDI&-4DM~aXE0`58+K> z?p6bJm>F@|7+ox*)FnYNi(gK-7GIyg@?7Rzt`qCDgZeBuX+dmQH4QC-1npQKIOW!O zWJ*G;Biwt%<1)0@k&9inAY`Zem&;wg&%3X5)Fc;-8nirL4^PnWNh`>G!e;c3`Y;r@ zyW#K=V`bw=ba!>psegY=g~QI7MbKTFACG#2R*4dJi%s6ywq&-3!6f9zF5Zq~SLn*rGLTQg?hM71t%k2wzTc z5y`%eHrb8upRa+1Nbwp?2AT;g{Qx4Ei5obzbfs=IDdkQzv-1k->StSdi)06*Se&$y zO|N0o(At@EZa0rK`+m7teU`}&xcIHwOimAEpBX_57xcfsJ4qDgF#ZTXyly8Wz2$LB z_n_My;;HdaduOSJIqUAszT_2pc@SIS2S+)4RCDCm54of>`^*qMAoc~tVsJq82rDil z9-S(c=#e$n>_J1G3Gf)=aoj)mz4hf+*!;^`9_VS|tn_IT_;``l;mTpiXM$UJQ~F#x z`LzWGA7Y1XSsOEoYCW-~YDX?siPay*<2{L=eZ&{$BvSA1jX(P3duzU{0;U6{UAdM| zcJk|H)8{pSGCI5Hd~h^l^Y3(DczDfw`~2W*FU85hk#OBB-KpCa@SU8tWbsTgVeP2& zgKH)ho-ZDDYVNQ?|MX)i4)ukqPvfF;vEuNS%WowqweuS@*c-#bOCHwPaf`N=C`6nG zK@S++ZabL{F}h}R!RmuBVpA_|n0Ynq!_E=X@aFECq+>5K59jI=eq?ESjvP{rE zjTn(R;kr<~rxZTkb(`LFuh-mxW#{{S{PdS@G+%#*O32Igk0c3PUCS)5*62sMq&?}ynZTDQ;C@s3ecUoHZ!GFgu5r;^(3co{fRa*2s$%Wp9G1NB zc-S9i55;T2svXtR4&2-?z^F6njkzVY3b%*?dYbrdL#PXR`>wfQv5#$rNw600;fR1YM;*Cartl zcil^#pUuCO%O2Txg+psS0oGvZl| zM>D%Ot;l)=o+K1qZ$Nps5VbfO+#R^@^ST(cAYpEaRQYHYQPw2FmHX>8waj z*C%n|a)k~T`i%ON+(?&zUaSP3^6w)yEEZ(@&2{E*6Zq1HG9V?vC7S#f9M2*_I8w2y zAGXd2>`U3nH6-orxfrs%5l7d7hme_$A5#9-Z5xjEZ(YhOB$IAB)*6YSO8ug!-P(cR z5*$7fK^BM9ir3Bsqjnjp1DJkRSO!Y^LfC z)G|G$zEUP%(Z^sE5eWWJ032yM)dAAPw=suTUm2kDCtvLZ|2&NGH^6cxsF+Rg!KMXa zJ#9&;{vROTg-zha!H%|;NLS`66)6a2WW<8=xg;SvIC>9iUkYDh#OkY>&b!}>9$YZ5 zc84cB=n-{?i2i#v{VTsk+tp4P{|M`{P@3oa4NE)QK{Ml1=fyAX?|}_xZ0UKjAeDQn zPFsX3x3%U=?q;<#=9Ru18deeteJf)&;~tZ|MYobK+s}s@F>T3-3d5yVbGV}Dl4S5JiC-Ucz#iw%ms-^opB8#0 z1T`*4$`u}Ail#O9pyox{Fw!^H9LyQ;U5MF-Njuh~oNlUob|vq`_|dWB0^(-mZ->z* zD5G%c!M06M*QrSUT+`Z8zIGc_U^LD~Ax2CZyXkEXzNt}tub=yHt_6Ymn!8ZhfRxG1 zA1BsU(jAkiC+7kyUM9U>pj%Uvo*&-4p+gQ;)7PebJ8rv@T3DRAP9!sMYgvqxf0O#k zB*y0vzWF`V!cvg#Lj(CP511}6-}}d+~ZesPBfJpa(YU|y(Td@ z$dV(=^SfnHEnBzN=#|k?J;m);m~y++WO7jRw?rSs?c!SJ8}^ockKZ5~zs%If$!Yr51O@!bZCK zYUnUIS{q9mH*4P+qpc+{fVcGqy%NZFA%BWYI7QC&l8IzX`k|yr)-Kbmy8zF zowQ->iJv^_MlMcR32ntWQS+BFw67bRV!>a6_@JRIEFcBWcXH|#uk^!pRXg2c@Q61N zJx}Vu=gjbH!fY~eCH%z!H1se@^apbrBA^_bZo2TJ$s8E<>Mn+5LRq)Mtv*(MeNtAU zSs5pSx9qrl_e*=V%9I;O{$hilQ=racBzGBxqWSJXuk}pOt&r^SB~>3F9r0Qc0^Sph z@(g~IUjMSI@3X&9yQ1fHW-=aX?^Y~2Zmf0?|vpiT)BGFi~}l&BOJ^Fb5v`L9y4k}bMl zP0!#P#JOW-KQu5N;o0n*FE|PCNwBhDS;u2)|7dN)(F{T}if`skXcHqgje@)Ey9S^w z%%Z-(rYerx8=tcpw*E`>+l^dW!i#P=si$K0jUS~_wF`ArUv?d$lk$QM3jAciL8C6N5h z{*rv$65S;MEl`WCxMqzaq->H~`So*U1D>AqqXJL8-TeM)#kYiON7rczlu`{+3dykt zk+PByzH+2{l8-++_OTYMR!TkCJhdWZqgw(OJ1cU{nu>|}>@7ysE5*VoS%eKlhv=ar>>P3tkI|} z_l&G%r2vgeC*a_%wJZ&VMb8Q|E>VqJVfBME7dK3VK=!(erQC+z;&;(qf;we(y| zuE8+va1+eRBKAsI^0C%9Z_?A7q9n6B{XPU|R7+GIqR-uNKo>SFo4_6D*l34zl6(%f zsZvY~Y^aB*sW8Qv`p5)Wsj|`r+JT!+ZpSN+@LqqcU;MorG<&04tuv(&e|}_5ubROo zOPZeNH;gFDcjt};oapK^Iez7EYU}Hi9Lnu(!Tl;{Ii<(uS)og0g#!g>Z`};2%2!O~ z$BHUX{5U)n#zv@xgiPMOc_P+qUN0`=$(1A2b)Y#g-yk^Y2+DW=Xo%iKLk9XS@K<`l zU;>9x`rH_$B7~h(7_ubYJ!1W3uiNhj;iJd0Dap|HcfQY6(mlDwhG-M$PyS>PdfdGY zi&KoTjAW|@U75rzTpo0fi2R`yZ?m+q8MEkLq z@Dxx9?xavFj`k&@UQhn`%;++DQyo3=s%eyOO;Xdfk2Ug0q3^RkCHvHxyJp0IgTC31 z(Br~tx>^gpL`*`C4|3nlFKC6~4~aOBC#6E~O*$CE0r-UENx zO#JmVv+atA&6fV$A=mPY9;r;S-xzmlzyT9oP7o4SHw-OPcHPEk2k?bfReTW*I zcWOlw`iv%@XvE+E{4J^5uZ*NJ=``|J)k4{Ho7NOG(0lro+$6>I-B_j$a;=ZaShnG$ z;G`YT=GX8@l~=QpZyXJq^fM)qBk=5Sk&lplPMz+{CoBl zp1nmfXtu#DbC;7Rpl^%tvlCSMFY!NR0T&|aMXX8x@Tpe)y{FTe`B!wXy%J_wl)S3q zfN< zdIfgZcE8sSwgHt#=C{O_izKb`#@Ms+XTpbSu@%5$c(bY|DEiFKXtrP; z8#0`+--S=7!qpoHDqn{H0`%2TL-*NFTJ>L#`LJdU>+|peKg}(YRogv1cO;pauH)a@ zT1rbkknzMEpZ4a+{galrK)~soSlG>k&zdy8(@7kkQTKE3NE)7Vh z^^-`Zv1~sOt-WbY=D|cbcBh68`TiREjLAB37$QVm-c^nYyz4$a+Toj>pe_qWsTY>n z>4J|pNcbd>e4>2pHORl!P6-7P(_RWC>mjFaYn#UI776zVlU`SV@71fN zYyceZr%pL7+p<>x*-=&&t~`TZ9&s}6(!<~bDUKW!FHN9ah4P*R$SXWA=_4Ui(GT0L zSJS@4{tj+7Rf{(RU-3!f6u+gd8I$eZe_51l=M#UE`poR1%#P7nO zb~35_5@p23nxOe4xV;X6k6j`2cYaTbUlVRbGj6WPJcp`_uxVX>G4BMsA?4@RhN|VC zkBE;!R&lCiRS9!&&3`?dMFLD-8I&F&oMC7`g07n}h7=%f3hOrHVb!vEx2Ie6d{;CB z3q{86S5iXkA2lV;bL7HK*Sug4=*=#N-g2cWm!~hYE}@uiQm~Bd1m%rg-nWPy_2CD3qFaWYM>wN+_;?~e# zq1m4Hmdu$?up7yy+Mvsi=r~+fjsK6WFAs=0|NnnaNfIIw!dO=bS#5GQt=)AKg;0!R zY1cLG=z0&iLoCTX*44(6W64$T4wZY>isWdfl6DI-mCSqUJ@41=@p=z?{661*gx<&N z{d_%-$Mbw;UM_C>^LrQ7g_LDmBWy>Iv|l2;-n`n`>vNSgC~^$wQ?XrJrwnaH+F1(` zzefrYsYO&5lvGy!sjx)B<9+zt{2|nJD=GXX!uAZiuZx7>VBgapg&#wi68vY+e*2_M z@4FUy^fc~lAlj?K1KM`TOI0auJeWFYP}utxBz1zRzT!sTzQRg9y5l3gQGZAQ=%HWX zCPCm_9W%LNnsx5BeUD!GiasPbh-g#4#bbtS$ra2%`#$BByVR|x*C?JpPygiXUTnvd z>vi|w=jz!7i=e~DuQSHQvXgB6hVrVyn#M~b$E&6eaqV?AKnNTDYC-Sk`!M;cy~k~4 zd}TGNAKNC>F-^-)lg4Y|>pZTzNjgehvccVBPzcfC3ktUeO-F1$w5AX9><5_c_;%vT zMog#a_0{j_E)FlnurX>gX9@YjO=ygQvE5-&8I>_VhP<)pnh&=>sbGGct{u|#*Swo^ zmrv#s+A+hze{i&A+~Xiyu~gy3)su?KP|^IIJ#!|=l9*0WXJ|rfOicI!^YcxIo}kX< zStnWZO!AB|1{%m=6YYvK+5rj`Lo&)vlj5%`m640kS9f?Bt)xT&;)T&phE<`5yhpcf zn-^?%?Cg=`MNyLl)9}>%t7X>~8g<;qxoc%d!vs^Wxs1NEFO!$Su1A!Lgs1==UnpXb z_ngPjR^roq*(3H%c)5rFpfjPmw5VcEaoRj0{dc2lCXJ2L#^wGaolvcbh@Ufij~u&W zG*ng~e)@cy9vr)~j~EPX9eS?+SHGAVej7Z*zDZkvx(QooqH8kgQ*=hmHOj-7Zd5FR z?EJcKhTTyV%gI`5jK-%KP1|U^28Qer`|;vnrUily!(HSK``U_e8Hz$=lUh?YLOJ0A z+6b&?m#zdG>^#>9e6pC~D;m&yRnsk;zTwiDbmzq^8`D^Qy%S2Q%w8a?X?o4o+2QZt`Bm+&4Xfs^2`gQ=)jwjHi{vhrX}Rl&Z`~ zIgNnxcdikLF=F_}N)*_(HHw>hCY%w6%PnCAV~fupU3Pf+d-KEEYd>Vf7jwgV?y%S0r!R^y9&CcgL#`((ROeX45g3QVK-K1Ov2r7_TI>5<0hu%ywY~r z{&&a%1>01$jWeEx53jlM1MH8(UPheLgc%7zp5Zg6sLj&xeDltlGtz>0_eVhYbWHkVFI#gsPNL%wV@T8Wa-9EvLMTNcX2_c zavsx-di6UnL9L0fJYC^axBME#8O5P4;%>bnhuPT|dZ2dQ;I6*}IQfd-g<4v~eYr;* z5^$O7fvG~S8*?`c*UwqTj7Md0XQ?he3~6ILVx9|1Bdm{r7Vx|p($;z6SsU$BCtLNa zc@Ga9>?xKsVxCAq(wpg)%UMzcw;ZzauM+Yt%?7`8&Srm+`3(^exLT9%RlND=O?x$L z#AebyDg42AZo6->yzFRuF*do<>>jq7i2d6ds328~Kk>?&le$nfG|r(!naj+qI87cM zBv6Hq+YMZC`{a@npuTCB`mdtTEYP;OWWfWA*rjRIC>Xo!V9m1huHr!x*LjEi>7X8E z_i3bIdO*zqAJ%(-u*r=PD4mYe1SkT^HYE17u1ZIX2D&Y5tw+xG{1z5QiSyDPc^5E1 z(p{#JD-m-{wNq4O@}Y|)CIqFBB_~JSLU8Olh^`TdLNpm zaz6PL)E7Cy8+JzycJ9+oz|_KlboFz>plYRl|D~1O6jK>e-dKGx(z`R=$R`xvXkv2d z0{Jq$TU~Ep#X_I+<@R^7%tiR#U-*YJ9&VI7%zM`(Y@iBH` zIDJqiT>PpZL2Mju#HXyay-e4?ZE=vMTz*1MFj7ha_Z zDb$NIXDApkt18e3ZcLAK%83!o?>pGD z&|p!msq1jpP8{UK=$Z^>1~0~pB}Cf}zlkpQ>V0|nBLNY>kn{?QJ#{D5oz?n7ghF$u*PkZ<~VC)Qb2hUvzgT~m| zQ)r&SCCNJU6=PR_P#$R4Ur)H|fU@6zhC}aPrQ25Ba^L#Deckygn^twz;+ImAi*!^- zSnyn}qh5EP>7XWWqCAoB&HN3)+1~Nh&s@b2!8CGKo2yZ`I*Zvz&wNr_Q&J0PUtp|M zo%=@y2Q+{)9+v0-AZO|sb=PazCvBE8dan7-9`cs?VN|!%qoB}=A zeW7ibVY%t+lrn>r2~PI_?oZ&lZ%|T$~5Xy zC%?+n(M0SP858vDSDT3FeFM7KrBXR~EJDoCNeD;km2XdzrzfZbt>?Omn68+~tKfMd zvgavuFB~N68P)bEKf|+5cv8&yY|YyrdN7N{OuqV54p7KZK~?ccu9Wj{LrRpfvJR$n z1eZ}#)=?)WY~_o44}Z zQ|%(_>YlHCbVkclh#=ZzQ)#!AglK46a4lX5YIoyDOV3|pKRvLYTOs;!xE8_5L>EM=Dc z(Te#ao$ZX*CeTKdWsX7w!l}&p39h1>)4Mm1*l$l31&j2yEy)AME7Z6h= z>lFsgAklTKr3SY-&}PR?T|fLnHF((0b(IqaX> zI?pR8)mDetkPN9!DAlC+{;BkNw{OApN1_B}uD$v_75ORc(T<@kR+$lDI9CV6*kL)! z%dR7AR5|aZ%&$9)zpze-jp%r+w|8<#Vtca8)=wqfNLxjUd9Aq@$=~sz9$uG+0=_wP zb_WJc0s7h{#B%4|JC;AaKeg=iM%c68-6*e5wr842*~}4g5G3FMExK3QlSlQLY?K5< zP8Pl!(riMqck-Gs-n1$xRf!4@{gighA80IwpmmDZRcV=>;d;g0NkEk9*%aAzy!dom z^_w#}>`iOQPyf9%_mr%sR_RBKfAO1-_!F(p*ERf#;vK9G(#`F2@7Uu-`l8^xV#PyX zNJvUsKzfbI-*TGkDJD+Zz|nG#6D@ex3|3atfp+`S%+cjx!iK@kCY&8^(<94@K=4`5 zX`ZORc((8_c2^U6mox90#qE&!)wjy7bW8lSxOx*eBNy#AgoeU?qn_JRp-k9I7Fb}8 z)5GbQ!W+fPXl7>?jZ<{I*LX(S&@R+^L`&}PKpJe<^b}pqsAg5yTrE$wAs21cZ7Djh zd)=kjQ@>?%kFYld<$5`51>-w%E~PwXnzj)~M@kiq#; zWV454eom05_mHO*0ij2RyqNx*{_dOy`7hr3AMq6HIz~0_sXJAlR`wya*LK|gOQ!Vi zDQK`IUt&&w8}XfCm&|YZ9UkcZE#bj+-RNc!z z1|dn+e&q0&gu+uc;@Qa{QvYbl5TH0b6YDcj!o(6!R_P3Qc( zb@El$4ke}uzww5HuMaFOj9f}%7&+9=j-=tHJ0;4RN#whw5QLq!NEeJ3VjH1EO1FAR z0w25Vr(P!yiZxZekxciw)9?J-gU9|Zg?7Iq>ktN5r%0%xp^2~NiU>ZVeD+oj**Iiz z&ZGsmlsk8aP_`*u)%yUVScb!|+uVE|j#(q0%Aeh@0iOUDerJZ1Vv<|r6U^Q}-h{E? zJqMQjILJZ|+%LiG-S?&W1Mu$r11Z3vGlNUg z5#sK!o@BUj9mJuBzW3;J=*85!KX(^%VLvT0R=Lck!L*lOtz@tKm}oM@Wo;SAypD!I z-e|k{BRCAlbF>iM+-#kgxI0B0f*PL9*e}LiJPVY6@7a_mi2@;$DBdu98`$Y-Uiq$1J?ck_WDMb~w( zWivSwnNdeW7wO1#46|3iq_8Q3nh=EffDRCR1v+>o7y^T0ImX@Guu<8nR*XnKzPIGR zKXbEswOEfh-0_SUe(2rI-U0UEyUsS5KiuV;fKylE=H%v%yPfT@#i8k-huPzXwi@cV zy6y8_&-->P-*YMK{NtuQhN=(6Ht+p&v)#X}=-?G_utUeO&5j=#xxdNK__X7xTHlYp zWpD3=MRD5Nb7gCu*6s2g+HOP1+0HSR+FvgpvGlGw7v^GkSuJ+v4KKN$PGLG4`H1nB zcFdgfRQH&}Dhs(FN78~xmkar~xFnkn3{fenFTZB_t^ee4Vj$pWk8;sqY$(1oW6Ry8T2f?gKAY#K?baLAK)sL1K5N)p9#P@`D)nqH?>%#^MuVNH`x9%m}l* z1*f!HLrP(S;Pmp_;k6AOu59vhMMM=xzI_yAb3_P-jh{h*nH_UH4HWbphdQHXwpbMS z8w9*-bF`yipGd+iKh`Q1HupBCi&@{DTVDI+;>?}SN{3c55@Vy+c5;FABr-E6x}q1# zUc8)U;vV^8U2QPy9Q}(c?J#D}X&fQ1#MkWaUBj2vJ}%oDvdKoh?ag7XQstJ#mZgob z^UF4gpv7Su^;}r`q9n5YBA)p}Ju(V}{m#hEV1yBZ&&3ve*%@;C>FW@tr>CbosqLHyUPaE!vhE4qzYk3F*%@(R1TM6dRXtvBx|Nt96B|jq(h3x1FDh5XkhwH1%`6gxsDk>1+`)RGmN+O(2=Z z2Tfoaz<+vziU9T46({5LA!QGIbYn!5=FfW%-NqQ=C%Sa3cHyRo5co$VdsH}*qml!46LRX3j5xdlAYj8r-YyZ6e zB2Jj_m5|$uDKF9B)3Kdo31IA%zCyxA(tuJB(|Fx7**SK8^@Qpt?Dx|an{VIgXc}N? zrJZw|zq#C0cRXFO7_Ei)+?-Xi-=zacumb5;U*XJJlm+<&FyCg97uv{PX8Ex#g@?os z*E*WaTIK4FW`+GRpZvCULt6-Ex-&WHSi^lYYr>_YeGZyW~2P|qxja+COYZaN;lQPJ0Hd~zxtQ6O}-6V6=l5IU)BF&eg>O&k6*-bX-eZi#51fL-0b`hru6Q9=oL^AgO`7l_O+C?;6&K_ye zuI;8xwuC<#Q2A>&akmS4upn{Iq)*UB6et%o0*!l?Xp+r=YstKZ%v8Z}y~6)7`FZ~@ zPM3T+^9$~vps{;&I7M#$W`f{ORV=-~8x;!epLfZwsk~0mX>Wk>()1bzjD$x}Mhj!B z6n&b%GG8`{r+Du1S=_ALRN2Y{GC-ePr6`gN|ixaT(d$8-K51t(o4@h=^#|&AME#h*>-l4U3v@b>-Gir zPej)KIrphm`DtUG-8mO!dYVJGf}vaS(Z;Wl=sY5BY7h81>{o4x=m@Z6PGTdeV4c!y z9<$B|@@GVh1ugIGuGQEtjdYypD7bkt^U~S2V5C+L>=4TG*+?$ZGA=ISud=#cSi~DX z&WZK5OK&7Buk|0AweUzDKK^kqlj!WqxabwPFU(kKT#omr+4v0+&mO6bA(_oZ3wA^< z_^c-M@GiKtlG7ku0iuSu975-SiQ<3R*vEghoe9%mP?Ny!Up%Bu#|q6s^2HBtJEB0 z8e~vIaGHKO$+>A%arj%BQSK(f@t7iOx|uHF$>7@kx!EM@im#QCdf%G2DwRC#z$a4t zf)Bacjp;n|T=`w@8^NAL8g)I^vM(eBx}`{QTOQ5zf4)70sYgi~;2!n1??E7gygS}0 z(O)zmV_ld@dg~)z7Af`lHe^HYTsBRzfbo>o$IC=~!u6k-R*EEYt?3OajP;ASEjCA+ zP!4)bDHxo?VW=MI*iU@t!gxtA?Ghh1nVEP?kw{J~E)1{eRmp0Gj45Y(-zw>)F2(*# zr}lrIBh#GM@^{&Clj!qiEqS`@$j>$io@Q_hgxnrXtBtc-4O@1CZq+_X#{Hwp*>CN| zcP;3!01R#7de9}{D#uP6B;`K_?2FS<%9fOp5kYnWF4XJtlQ=K>vPM~bS`KSyMT34e zZ@{eb%WSD`GK4sao=B0X=eS?vY}B(_ZG7bGOtHoN7IcDROVSb2vB6uYfMt<}2Xr^; z8rU&eeHtB)k{0)iCo@K`%b)x4JyzWAu z0BX$hAVD}eWV@y|Wh{D_g&G&;5GJt4(BI)Bdv0q%zcxYI*x|jhxWQr#z7MJB40e&= z)KHzxjkeUa$Fj1zKWM)a0afTcN{S`0lR3r_RHnY1 z%A9|z{X)Eaq}DXC1SS_z{pP3)evxi{=bTwqw(wxuWI*)NS$}N=>CUTqt#IoB7Dxt= z1J{5Q``SpPI3+Rk@+^}1J+JBzVoz$E#9)VWLR_(eUYo(q z;jJ7rd6*5w&`%2{GrweUJ?Ur>)udbOOUWZ>WMm&CDtqjR-eTpVA=0)2v-hlfyu4J` zSB(4kvRE)lMze+t^%LgthO&Hx$tOEQAwCTkUz@`V z_yO_Nkta@wH5$(*(b4-9GY;%Lpz!J3T#>aeGP;x2ab8k?{_4adnxDFu4|!<5i8ED4 zPIdIYL6yj;^_ju(dY+n-hOO5@W$Y-qn}h=b7)<+ zV4*f+r1|Bry*<8bNGvz&-aE!CHm&vz?t3Bn{fH(}tJbXY_3A#l#;ZF2mjgvwa=dcD zFCFBoUsr&oD{fY?6J5o*Fhug!qF&iav^VF4#Xfzd>@iI~{nrFh`^%y1&HF1CuqQOx zs|Wc06t^PxTYcHy2hWu3s2!^BCv?lwge6RD>3+h^J#4Rk-MQ$NSEZn!^OzE@r+brH!61}_U_6sOEBdj+j z_ej}0=2y+2pFW;VEx0x};CfBp>aw5*#xmVt-(Gj}*7kKLH%<9lNvG?2&tSmd`~DWM z95Rew z*fb$IOVLHV7d;s_k4{Iq>29zt`hvUTDOri)&%7&NGdmq{!&1m~nE?nzPcTc;dr~C+ z*9c96MMjkkx!_Na8?`5EaBmC?c7S>**K;%I;^5)Hsf0U9tFPb2lR+no$vuU|Hl5jPqQwGVsrwUK#YdAWO9O~-If?~ z9->f(l_g}bBe2wi+1wKk(ps;X%NAnnM=I=d^DfkP94hGE%0Yen)uXF0yiy?yCsaqZaX>MdM?e!GNqJ2S zc_+mh)*`96iJYanQkp@8em(jz_GE9eP!_`iKeHYNOT>gq?iz&*R9$%Sa%%rPDT2{M z?XO8SajEU;T`3#UTqbRv>)1?oIS3$`yk5oZab~;&f+EZ5*i5`JivHw+FxC<$CI*RS zJy-hnF8N4_i>C8@!JEBQB}d_822QZfo$(PvhQVyr>&T+HMPPveoGB=l9YFa^hYC(- zEZ&|p!O5{=T$E}^yd)qNy~4VI8SLvoa+UYQ*Qq|FveSgJHKp7vxS&v}@t({c$tEn~ zlP5Br*Rz4Vrr{!xyfJlsFeF;hSB#gK*Gb$BK?*xw%np?uZ2{h)F(Ma1@iDddPo;Q% zW~);#M--DGn#e2t#VUUXoJ73E9HR*yWl?l#bm_+#;qOA%aHg?=UPA1mb_df9w! z=XVMo4S2dGwSvw2{p60H>-WG*ELn5fg&Ajrsku4M5|@`n_EKi|#U6htnVVUi*1Us3 z*;yr-&D9d0<+n%S2s@gRyaYW4?~19#$vo9w^x-NJnGPo+yD_b_foV+BXJ0n873sD) zKG3sMs8Y}-;Y54boiWcw^sYPp_qFGkCClhGk)d~`ebMq788w~`@;hTcuXJ~qY$vL+ zz_Y>i6D)UHG8ZKF{gUDYsF^-v3U&zx6b+f7cYy`(q%i4A&KG>H1z^ZLy{0W0V&YXn zJ(xpOlaPVDyaf|WhSV4$8qJwUZLi;mDczWTgB=-nUR!jR%bv>)e_O<*{@%DJbErr; z1Q;&-)7q(nztcFL>0@?*CG?6Rhmr-)TTuqih0Fqnl;z2#3m@#tB;hjS<=4M-yfumv zAeW%ws06?wHyAfWP>F^KOw$10 zjy$(=2<-h08$Uj=IZ)Xf;KzPOFx!Q!pF#|#$ms95>s560?Hm4T)$6LqsZj*rPRwdvbS=R z*aa;_FQX*!ccpnul$qvBz_5XXbpfs*skR<;jLKia%%(LHUl%5vEU0L-a5u*I=Vl+T?JNvh;ib^Ey9? z3`OokGdt3~91WmP;ah;v4ht-R{WF@Rc{Y#+n9?0*@9< zj52@`r|kP0>|;fFZy=d2Oahfm#s7ZXajMsIbanFW_1JHC6~@)Amd%tV)xzzijOT`V z)~<}U>u{GZCI^(=r62e+Gh!=gdrr6!{4pOjKJT(p)ix+wHXl$Jb&|v16|2|PE=d4y zsUf$xShJL)wj!q+}wnIh4 zSQp8(colsPjp-2=b8M$gj095(yf2$QZ zP5>Qo8;ILCFonV6j0#B$*po?m4qnB^?utpexvY9_*>cdmQc+lcgeb+16i41 zV)u@r_VX~b>K~s;&vGMx9;_y}h+An7t@lMzL^5c671ZD&M;f$iU0%xb7!XrwdwboD;7Ili%dRKEeI z8(eU9;FUcXU%2>x9@rvj^r^k-DjtX{7faGjy$NdSwlxqpu?P%&Q3_l7u@`uL7v=dJ z&~oc|UiJMd%4=MG^uS6ViLLsi*Ri_hc76en1t(rLD4V?fM8BYhl4{Zn3=&glcw+q? z|6YOp;S{vvlISWqFdPz*2Ms$E;{N~$fSbPwN^$=2=d z4IOhFGTRRm`U`YL*Nw@W$OBNygu}6s?4soVkETbAjY{ngRe1=p=dlr#A&V4rxF`sj z*Oo>ZnmV_Xf(>olf5C%+EpAtQ#?Ft8{;}k1nNf-7o_hPAZ4G9+ZD1pu=_(aH$gs&_jU@%8YhXQRo;1$*_2oO8{+5CAo7CuybEkCkh6K zT8Wtxf@Ln^Os4fD>#s^K3GKR6s7s{4)Bg(fiyWLvYr2?CPTHi)#dzSI8@d0x@m`=3 zoRy7%#BD_p@@Hxm+j-{yc;prvOj?}^C&9@Fr@!N_uJHMBCuZnQ0%lL^QGi9 ztlUD>%ADcR&=Zsq+y4-s51kw=EH$46+}{tK4&0 zK7yS6@vJKo`9KcJO!^GaaUaEY_>r@iN=z?+?spBTYb;q8DrJBCTN*A*1v}jf65l~v zC802Gd>Hkd4$`oG+~;Mdy#Le1V7`^V`}o$T9ulaXLn*np64>HMH_->WUjsr?)V~WB z2!PgyY0&*(k9#8cq0UYn3W{h=@#I{8fnEzB-+=Y!b>?nVj{{4uv4L+RnHA*CzL(U_ z3u8Roa_Y@a)zs!^Nis1O-J%p#elaU)c`5YDg-Oh6>IwoyJCaI?+GF*Il+bUCEHBb@ zQLmpUDbQB0@Fag6@4+h&0jbd0>})~Zi5K)?S?kBX3Zz6k4IqR~ECfeOCfUn~02NJd zAS?QRu-&&ZeM4Y=*Id%rT#UHLdF!#v^Z)nKVNoDx20P4Pd9jvHK+Z`Dmde+ErtW`) za-^Vw5{MyL194`E6l1B&%JnxD<0mir%baM@11H4q)I;O3&%|3KlE!s!1(0!fMG z+W6(iQ%!{Ug;H|!bY9}w3M$vN%Ug>zp!H|QnL;7; zJ@NT~BGyszOEAkujb_kK$l@RCe_;yK8Rq;eXCb}>7+}a{Uh_E~xb>6Y`bnMBA33vS z=@%7ol*VT|QlpqbVXi`HeJ*s|PhA5LGVXxIo?!G+T1C&DZA4xG+DTV_LPOn{1|yqy z7g8V(ohUDEXWs>h5jjSA+FR8s>YmV}m8a{a?bry5`IS}5ewM6Spt+E2M8N?tE~`Y! zllw%iPp=0#esg8QQI!CnBM_R(YAKOm*6-2 z7xti-8%p_;`-1mTlf#;$Anwke;g^k-1oDSN3Bw#2ryyJ~TQH3&QP83!3^)|s)FeD@ zEF@m`4L0Gx2pV&kpu{P1+_kxE<6@BZyXitgPt19w@u6W>W&F=`5qw{k!{wtchTECL zZn5dmoU)j*nU%$0bxv%dyxywDjxkua&oCSMMU;{yM|jNy=KFPI3TQjkN-fx_cZ0&S zBift8mrZOoyUXVcfL_;)R_z$dR~tMxlkOn82}jy^ZYR;AUOQtX&D-p2jU7=@AGEV{ z*N6Agw>^(1i;o*M0id>qFIyna*0Y@&W$pQo`TXdp_QQhrT3*epy9@h7qjh?@WJtOz z=)*qywcz>Z36=-x>^n_k@x}fUDJ})z;k3c3dvIWmHCiJP5LFpV%2AJ~=iW6S77e?B z`aVWg3rdy1=<*}A&=mo_q{V*qFeTa;2m=Kl-}O-?`&U_*?PsRzDAt+Fl;VPNU0ti3 zTmRPfkNU^4rJLwbY4>%c@s&yl?38Reu@j+8Lz$|OS0u59Ijx%i8yM!$gx#bP)CnEi z;z5q{o=Q+gPE+p<=?h^92(9x#_s4b^j9ly<_zGUUG(KWC>{QxP9lazls-hzI&CwD7#+Ufpcw;IESM#sbdraNjG&ez*HQlYZkOI~fD`i|{0NtP}_!@;&Q z_c{N~wy6C(L8#fQCTM`i3M%NSQHo3*@5=VX86l~3kzxN&_ z=p0pt+{H;=(})6@8<&lWPe4X+OD7LEylOifUG)_t6Q-4O-=bw!+Y6B6u!ihx3V9C1uqfmBdY!0z#wl1M1kTX{C8tRK04f;GKhV_^a zFK4q-Z2A=J_#34L%gd4a=yydt7!0Qr6hzI#mk=nLqe^Ze9=~?RbUcqs={x49U4I9> zhat(aulgw`oL+5u=YQ=+l`4X!R0m|hIig`~*pwEcPnA*`?p0&B(C9J$xA~z6guKV8 zT+{;&cGznf+nwnvo$%sdX^Uv#ACLWYI)7sGsi zYd?zVnrKa}Ec^$O%)TG!Dca)8L(Gb9q>ybPE|MZY=`_<#w(yDdW-bH7NZGNHBK*zu zs279Y6r81lx5#-ey07@czFgwm8)_Dr9-BS`{^Jgqq+{7No?^u1&j{a6blXT;&`Ymv zqYrH0AX%eUNiwqW9Gr|yM={G$O~I62H%JUkXM-J#Z>E*&5?Xh?`){i5+UK5DsYvHs zgfnV(wji&HA(O|;cDAE@scKIu*8IHMkeC)IJTWO@T)nb3|J&&8JAspVM6X-iu|V<- z28JD3ORDVrR`LFjVvV=xS$aRaxLRv5<#GOA+y4CgS_+Diyr`nz8WYzG@pq~&tC2FC zV3d;3G~e^4QW0SDQdFh|->f0g@wKK!8=C>r{o_wfdNW*MPModHCC?j+iBf;jxzirx z>`^IdvB#)wM>=&$SFyzf#lsbr#5|7m8uRYohPU}ZWONfUDKn7CZ$5bnND_t%aeJ5` zUDVI3g#vD@rLKQg-hl$6=O(H-tj_nTw>Qnzs^ga`OI##)e$;taHp{83Z*Zd`v~; zj}~kpbsR@#jZ02o-VJh%_4lob$r##hc*tCSw1NId=J?8g8-Q&``26rPc^zsR zLJKszJxH&wggs~2^Ej<>nn`!yV@)b*%^;{p{4L%^>@bJ(yJE`rUUfo1m?wkFP~t+Vnh`*ddC)?hoUa~GXK_@L-1Z6kyY#jnCbx=kB?W2v4}`$%o* z)whP7(}keaDgbyX7WBA8@k_lahxWyk9g**`iBr_2#LU*8B<8r7KHKiv=x5=nccnB$ zzp@7!GFvS~AK~9Duzf3!T$uE1xi%>O0*BO_Ee&^_e0!H|&L_h&AoC=L@166^3&Vy* zKiqWk($yn_YDvNNgh6*t_;h`h^GDk8lmRITV|{vNDA>qpyE|Tet6I4D%&s|szPd%b zmMq2T*L*s;ucf|tjU z$0ef}8Gj>()pir(X*?Ki8bygQ1SO>`6n6f0awJ?xahgagj`@5w>un6$O2&3cUjyt? zO~Y1|m7Z1A8QfD3vgX3|5ua-T=2WtQII`elbq4B`mk+>odyTrSn~|oVKBh4ln>9qv3!W6j+UvwEF!d=<7I(SiDOt)#dIEq@7y@$D=f9A>z#{OI$At2@*J5=u0Ys<==Ob3TpoI!a%F zs)}&TAPB`Vm|A<%W1Qtlh#9BgjDd;{G3w^yNE;~4m6fBTsU(L!*OY;&Z1Q4x!JsH-8D++HO1WY zr~=*k71|m{wO2ajmqwLHBK2;0*naZXkCl!Wjd34c*3GTd-LnOhMntBZK3gsfipDp8 zN0;8H64gESC(q%`GL|UhX~hR}(_B+1OqUF~vbiYV(^K{v@;I=s-NgrB&KtRGlOS@H`&K@#+@%Yr_P{8aB#f_1`!%!0m6 zHCTM2Ug^)Hq1cmI6@w^lKB$u1hFl(nV0mRKhxp|>&L7rBSC<5u#UXx*I+q&HAi8*hzFVOc28Visq_6k`0VG<3f#J$vc0h%E35U~U((w`Hr&8)|I^VfKzo!uo}smCv9}GDlwyvx z)tiPps$Y$@D$e9~VxZA;x(!{04T_ z*{?-Pv-DvF#kyA!Y$q#epk-*fWq_WW+l*89V`K=oM{Hj;XD$ zUTT+qnUWw2`!Lb$G})3aAdp^XE4UP~ zvn6x!?KZktJyiE{HK4lbCAC$fDpe~BbQ!A;ip!^b%x|wbgVbgT=b)CVF?B6Pw{>g- zF(saH0JlcbJ;GvQ*mqWq^lORkUIhDuH)Lm%wS~_o8{#~eVJ&4+6{CW8Rg;sywm4k3 zck3C$K+5a$k@5ofdr_2swMvd+JNi5&N^53R^Gfaf{WkiZyh#eXG%sd5?HZ--d{10P zZA3b^%8ELUL&IKr>+dG6G3Y!}nI!?TCB4uo3t)m5kp9B(9x_@lgheipuvPnWLLK$f zGgu#c!S$W3|BRqx4F8_$_cQR>Q?`>w&iaS7T~Io-1bho2Nv^Qs{yQFI2Q7i#YiajG zD>A1gv!fTYu}gXz-k!{tIEWo1J0GxO+$2vK>ig@F`FaG++sszhWIOWq$fRI9*BDfeWLmP|eoLx8H){R@8l79Zru2cJ*YMsSgFZEM&oWE8BI)`%lti<$?2T;r=xG5i~=4@ z3^(XHh(6Inv|v}WUil)PV0(uxZJ!!K!?%x;*mE?}Q6$_Yf0EQ`Cz<}V9GTV>qo_R? ztI#wgjWsyll}b6U=wLXRqbtEsn?E^twN2Dy;X7x2oXl&2tkG;C?$kubVoJri2USrJ+*tgECbTDEIJHB1Jo_Y3bmO9bq~D%gk*6@n zvo$nAyhw-ze%8E#sxrt)^+}_SugrOY{~pVT;Yo;nMjasP2AVl#IdKJ+-|7%PXk;vL)v# z<*6aRZ(zd`#i>QgiU`t(I#LSYjk~;|AcI7vT&=Bj95mmR$#T}a;3|Ah3_INjJwL1~ z#VOv9Sj=s2O;e5lSpZ%%F(uOH}&8fR+m%cPeijM*LWg#R3leoS})lgc& z8>*r3dA6+5V7W{mbJJuvIpd>G@37G=nHOA>rb3;hx^x4@l)cOhTFS+1oyk_J;f*T& zU8JQG__kPAL>7shD`Wo%@F7 z5S%M>iJKf0nQ1~3KaxgX+hit>rjA!`3<)5<_eFAl;6 zqG!s`?$m6Dne`!6`CCZvAw4IZ|Cb+LPS*r)Gqk2qu|NnLtt80-%pva>tKWeD;@M2p zb-PILdZon7811;8f(E+JdWDA&9AQvkR(tfr?4Hc3xwJG%G6?t573b^ill;yM?t-LG zuT`lQP$zm5t*s3ka;P@$giu5DHZ!ZeP=iw~DZSxT>Fry~&KKgu@EMh!BAn?{9#ju3 z(hH{`20T&^XEBZi??+{0WH&ME90>y2UB@Q#`Aw+kd+6Cw4k$RL+UpkEg7UP%k_S7S z0tR*x-yN=XpTWaMk;pz`Ofm`T$)sI!#IDo8dUqjiFB3N7{05NM8lHS0Q4!*Pvr1`8 z7JW?vBf<70BcFJOiAXY@ndU)PzUR zb*VS**y!)R{+so2%C$0+Tm`^E)G8I;M<9C#I+Pv!aO%h0crr0jbeslpSYtrP#g)Ng z?9;UOleSBX$AJ*2e59(W$|!%Z<>WC-kz=I@6g!LaAm&t)X`=+>lhIs#6-%CewjvCQ zGoytR06{$FNNud1oA1!UhE?!!Hc;JvN-|*7!LL)A^Ecz}%0eZHAggd9buZG~gF>Qd zGd}TobtczJ_+~U?6|S>-#bXZ$q?6zSJT8~Oa0WX_OyX`=DgLt2H=-?7T_k4iE8g3~ z%%)WkfXLc#h@`eY#3?YkR5zG@Lscpf$J(WTbD*Kx17u2(95&n0sB2F7ScqLZE<}Rr zUIT0VcaU8u%^DPGP%&h(j|E_+QG(@N0;n{NX*62xNdret5_op%iF4oRb|9Uy#-Mow zKPzl2zVAm0Ya;qpa-3=Xo)=xvN?Xl3^qt$~RI;QKK#A{W`CgKF69OdRtaMzLa zi~+^UQaLU5n7eHGz{Xvdbn`JupwMGzOxcQ9;Q5L7Uj?qboc-DD$Tx|p zZ}8r_(rnN3Dtob3Lm$YVtwMjYizI&0D4S`Q_~)#PS>vF_T7p|bOH%8u)%LIW$M z<~-LN^3*xKtyFBP9X5~x;oXv5Ig`zvN)%lxbuDkPUEtCTtxr-fe_ZQ0C{MDzjUWUA zjlfUzZ1B*%u)O&Z&^w4_@IGj%Y4gYN_=eaJTB8s)MW_gRmdV{%)uYbAFUn-gNj zPR_?WAT&~NDj9CEUbkZ%S)E9hI;#C6LvtVEkFlPg-Jv5$(Xc^9)_hEEvPlm zeqWN=#jiOit{WhF!6&Ez0X^eB{BY8S_t`-1r@e_AOBPphEvr8$!et#+Iifn8RsiA& z%KF6AAb}~AaEFuVLM;b1t^hDK!4C}yw4oWmW2cOEX0u$`o%Hj>8pioKTB z8z^h$KevBqSQ1D@q68RFLbf-kh77vJj-cX1;~tcqwD+K;lbcXDvbtQSovjG-DXl_( z(<}$$Kt9D(&7w_JTwUTG6$--QrFGy%?I}^2e)6mDyk_hS!ab+gLt3Fj1#1z{--DOz2KTE?KoV`#N z0$ou2;Au+e(%##4Obq<(hk=j-*K(~xYTdJy`zA<1a0o;7dp|Lf^$prWd}_5d0R zDv3XcsF;d;hCk&`WT<17{|ddB4hc9`Dkc^gDiXpSMYAL&R3t0GG$kUKOaT$`f=j`+By`N|AeHC*|p3P{6UBVeokSpyf z`A16Q3n^U5`VzSXyg|m0)IZ7olOjN|_vQhLN$nzgrY*Ja_B>bd{z~XA1z4i7YUC98v@M2DXPuRu~prmyJCPMb?#<^+05IWpBQ`UeOTi-YCJYThx?H)IhN~<8tadxLK1q6f3siNHmj&7 zS-LVt^1*;G^f%90b3k#fJ#UAQrIK^%P&`ghPLRbcl`-||Npb-k*@Sy_%1yM?gHFBt z9=)5zk>wg9y0J+-MeU+B#R6qp$1YcPm!21zN^cks0La$iJb@L}2n_PQ57)nL$@ zV{pXs*$-k*O2CC=ln}6Qc}k=|yY-n6ur4B##|9smj2IUHLU6l)NkU`nj>47r6ba=j z*`r8UXKCEa4|B&MMz3@r}z7(lcA|%yhRo*r!uwTO5}z&!Hd1dX7W@~tAJy*ziJ=8gm3*roLCGwe1jw>n$$zDPDLsGL~9FPl%-+aF>5kt*2KM5r=u z^3nyGPcCKi&?-{-%q7-1VJ}OK!JS>CxznGym9o)*gkEOR^40B3HkyzTNaJxqTuz4{ zXta8PB9eot-$fBcNbMMkTZmgO8gF&3X@g!^M8I~&J16bh|2z5yzBBzSY0K=tFA?I^ zt*V}KE?H5wG_gm9gMzCf z#h)0`r8Ep7L3^U@&x=S-0@b|-ab~gg0&=6q{w!D=s*u7o!ubXl$-32ObYosKhz0hv z1h}xSVs93nRhXbU-u7n!0pr*+oc7}Br+zRAOE|R!7r_ibG}8!eL>~u}yh!y&$WW}W zt}@nLzv;g@Sh1*U-ONl2eUSknYB7pDK&{QimFqj7_s|=Z_^LGWic5fHy!%3nKHciW z&h=OLZ{-ecC*n2F{7Jh&h;6aXGHLA`SCf`d1njp?5yrY6ss1W=w=hBZ4Rw= zL`+btA511txtk@%MNZ$eQ1ewS;$KoktYu;yf>nJ_IWo=|u?6p~+RIv&hpUZ4GeUn_ z{2Uk!`e!@_-DFnhb-UEUvmJx}FFYxwVqy0SA*xP5nQ~KL%$r!K_w1)Vt)mPmw2l}F zafYNCXZL3bsn3JWN+L~SFiM30AtuhK9mIW3Dy8N4c%5%w@eU1{oK^+I9fS!lr zkQj+z2P%f$AZaQNWcg zEi?$1b$~eiUm0Q&Kg4%Rna98sIiEvRcC(jeGk!C{qCxE?%{D2I@d&-UyqYUOdAzfI z*M0RD6|m#Xh}IEKP-xKLYIwBAb>qkoAQ$j%aRMMyIlH1Ba?nAym8wjeiIv5YUL zBLlwmAz3T{5OEnj&~9*ME|epL)T+CXT)W77?u+%S?hDIkkn5RUpd#MYaMd}iNZf!r4YG@Tg8;!XwkmBXAvXke zwBBiGt=JI%u1Ocz`IdJw?k%tz9&Eq0uM{u1aTA|=5z?r zLxcm6xb4;MGi_x66&7CyEFh@vg|>x3JVdfv)j6Wsam+Gzv5%HnblHl3(<2*p(oRCD zLWJVkmw7~IpXtn<5RWfLBt8p&@GalP*(!N)onUbf0w#Hsu)1~Y&}>i&J$+;OIn(Y4 z+OD*oa#pc^lq{Z1O`N6*iEU~FWfNR1yoBcmFyyT0fUpY^y*on9C0{Ysoz}*vU)siW z*W^-aM@19hI_fC*SxPlkAUhC#tTSoNz=%@$(K~`mzxD zDmO->ZHLoJh?ZG(@PX&d)jUrQqkjcY|%MX-(hb!J>M8huMbQbv2uS===DY6@KG!=F(bH>1!j%hJa392=aYkbgp4R3PuWIuAt}tQl62FtL9eChbv>auIEAC8<-Ea-a z!?_QmC1Q|_@h!`+d?i5q(8HZ5FCzO3M^7U~9Gxb8Xn1qeij?YIJ5OS124ZP8R35=g z(qv%3axZqRSXkckh$p^A;E1S3`z;0SS-f6|23>@h^)YkTxea`2zvK|KxUo3F?=6=V zTf0jh7j0=dWQ1d0MJdV8Gn+eSpMbDWv9YKb!m&zOT^}y*zKpFLazGJ47Qy*z22Nvz z;#{+((mO@rWJr_gXmVUBVE)w3Fl|0%RdXq)h!NoM597d+9`b&81I=o`FP(^~!d9CCtKGtrKRGl4!q~ra&)b)iz-|2rVXZCqG z?abL@twoRAU`$a7Tn%xjGMFN4zGZ^B6-T8qwqE~V9-7@Vh}Le<=jQB_93C7fcgS=` z;a4So1fLjy$%Gr!XUQ_TKL(KxX1d-y0emmw=dESx>!0rvGqM((#|?ExhB@2L~aw{F*zWd>S*zcd!loBeRwA79S#dhWTSU_I=6 zi#C`%H&sSbfqEWv2wy46b3yaT5f#|Ssp)vV7P@1+aGZP4c=oPIyTpDilzkjE+cYaK z8hmZa+s1sC$GElsvN=UIHEoQE(~b)f3@Qw!3T zJBM|B&LjH)k<&~5Slt1-F;$Q}VvG?U&`#63%T*@zzrg63mybuXNA4(I;t_k95NJs} zLB$8W^sw+r+F!MI#C{nC=(fHVD8yg zw+0~f%ZE0$sH^Vf3#(^4lFvp3mcfXoe4c<`vA;)J5ptdyD_nQ5)UNM1bx$-U!GJEE zh}2)6)p$O+GLmyW;Pti%?@KPNwP6Wu*IZ#793y$~(C-2US6J0eQIm5b=DqR*t}dOA zEAJn+F1!6jkr+#>gi!7nI)#WeaFNG_bfEY5jjnX6of&&yt&|GFvtkpX(%X8CIf|fS zxcNqY#Ok^D9g}i)L+Nay5Q^dZNa@|y4w653jzu1!c-Q1w0MREV{Vt`m?9>PV{JL%g z=ZfKjd3XgLmOHpgfrg!6Kd#*Ow79u@;VugjcnJhZGPL@jE)At#JVwW8z@zZg_{^{a z8&LvtefPt@wB2EnFLi4SCMv;{2Hqd9f*cagevR2hO8A!6d-iNwn>YfsTO~>lTh$o^ zC|(J9U)_b2RT-CpgbdaC;q^ML1WX-KCD&ecKdug+h?P^OqCjTnG5Di(`9Wg z&+425`BC3^B68j8sK%R@-4M9*-SLsVlNHrCEXBfTxLgc>C8ohM@)r9Bs5w1j`rA&) zw74xB{j)-ZwvcoF#wj z{QpeC0W>F-7hb^u44qMvAJt(i7eKwqcJO7+oM{0S>ideE$3?Tc_%$jJs< z?M32trg1Im`cuq;HwLkPuq(P$>?DTeH5nHieTbDErMr5s;9Pi1P5u4I_VM`ORYs#osdp{aXa0q<>(I8SSY#pWVMJ#^RD4|EC<4|?cMGrhHME4T_}(vHD$C!0 zB^(DX68^X1J`e4Qdj>7@fQYxr1~Kn;wLa!(jyC3Wtl`4mn?lRz*=<;)9#uc&?_XHZ z;6MKGA;0|7cns8NLd>{PCoBTGhlY0O$U1dYl9t#5p`ZHGX5;kIG0HLh31n9-!Wq632?@#L$ z^qg_&EA7fJ^!&!P$eyS(>t%v05-J9p_2LbE>`SKo7uI62XFXce^58~tq<2d8(0s2y z)(m8~$wiA%lK%XZr3>-3T86b~DtrRYhgo!=RH-AfrszXIz1FU6ZO^9AJh@X>xTF6R zLJ`-Ik=_ThfATxIvH9*d$0y-0si}S^pT1?$L3l*FbD40O(Qf>>X^QyMcYt^Ycx@>nq1w^7x!U z%#%l3`&pA%oobv>k!qZNvqnF?x<)r8E^t*c5dG!e^ou?Y>J#D@Ro8(hN>$STB)<)t zYe&%vNir1tlU8}16 zUaQpd;lPHLX!+`EVT~>B{ktFgfK=LXNU4?1lI~TzvBZ|*t;37Ons#}=O1E0p_msoD7&d&;DIa5(dFj#!a+V)FDmfg{Mr8RX3tu={eQYhw0r;n diff --git a/assets/notification-icon.png b/assets/notification-icon.png index 011616fc1a795ba3c3a9cc4d270df8e52eb4890c..19b02d08ef9f4a0d410879bbd88e9b33fbc223af 100644 GIT binary patch literal 3376 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 dwRrXjDW7{KuPqFSD9XN(DSKt?|3Aa=i*x`0 diff --git a/assets/notification-icons/ic_notification-hdpi.png b/assets/notification-icons/ic_notification-hdpi.png new file mode 100644 index 0000000000000000000000000000000000000000..68db637d4fb45d6cd54ed26f7076239ac40cf060 GIT binary patch literal 771 zcmV+e1N{7nP)RvSvYlj{q8;Y-tRf*d(OT0=gf@RBoVVrW*wlU zrZGhZWok1PSCK;wum*G*@ld|eYG8xR48qJdpuAf%(7`&<3snSAl8u^#i*!r|r#;>xRq;L?5tNByIuow8329 zCNScK?uf?vfhE8k8SxGH846<($f67w22KG3UIs1DHf!+19bgZ!PH&DuAtW@3g|!O~)xrf7sgbds-3M4m|ee z&p7R!Ty=;1KWu z*a3`+H#yKPJ*uw*)_VGl07`*D;7Nzq(3v(^VvY9SyfIz^9{kH2qXWJI3xT`931CcP z&w%RzQXf=U8zuEg5qMGr&5w(hz^%aimB3JwfHGxLAFvKMroIR`3heGtXB@D+X;Npv zD&U4ka)0p^C9*VYlGN5wQ{pZ>330G-QcnSgfj9EhMvXlJE)*OOk&HR6u+|1-p57D43}7a?LFr0G^gTsY}Rv7053|;p_F3&hI}1zyV-sm8|hLod9mD z;uk)JLT~#@@AEou{-or!HEsw@8ZyxR{|7A~zW`ZwpHm&54O9RC002ovPDHLkV1jjY BVDbO} literal 0 HcmV?d00001 diff --git a/assets/notification-icons/ic_notification-mdpi.png b/assets/notification-icons/ic_notification-mdpi.png new file mode 100644 index 0000000000000000000000000000000000000000..d9754573c2dee5cc6a5853471595d75745c17629 GIT binary patch literal 557 zcmV+|0@D47P)Nkl}E}n1w*z_Q9%(bZ7sx3P(iTp8EmX<1#J{7A3(6R&_V>k#uv~cHfm!8 zD+Q5gA~6YSvU6?D$aONaI~L-B!`*v1^X>h<^XC>RCI8Z9I>r^~)y_&xyV6}`bd}{*f zwd^lr32(88xsYc&yps1>#4O=T6K2k!2pxxbA2+YtIAUgE&2zGe1NfR@H70pBfXXUO zvG&9Bv#L~k!ECNe6{W21w=ig_Ff|56Qzi9U5?loi@B+7EzlIg8hOfj~tMyvIXYmD> za1r}Mf|EFmqv1zz0vjP;>e^`Ak?cdbi!-73gGgpkyUcE^*CgA~Xhp0GDW%U@Lv5v$ zo}}rvmQvbV!)i_PAhdmmJ2;24IFAn*^D*|}@!v{RRnjAz#*F~G5Q)tt+7lQ0-Xu{| zqb)>tjvD@OlC9wwK22*h*>t>zH@F%5TLI9|BpTs*HdUueRAgOzA5U>DV&CCoR^DZd zhH+cjPn$&>q3x){+p>v+(SVH-RWoHhl6p1jdzg*laf_5?bo**xlwF?8`DmhY!bqu@ v{|NBTZ}K0v=xB}KOkz;9O~{70);IYVj%+xdP3=Ld00000NkvXXu0mjf_Z|Yi literal 0 HcmV?d00001 diff --git a/assets/notification-icons/ic_notification-xhdpi.png b/assets/notification-icons/ic_notification-xhdpi.png new file mode 100644 index 0000000000000000000000000000000000000000..8635920cc4042b64669c28ddb7d38374b556f703 GIT binary patch literal 1020 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 JPDHLkV1nDT9U}k$ literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index bfd9ae6..fda5714 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,22 +10,22 @@ services: - "8080:80" environment: - APP_ENV=production - - UNIT_NAME=Resgrid Unit - - UNIT_SCHEME=ResgridUnit - - UNIT_BUNDLE_ID=com.resgrid.unit - - UNIT_PACKAGE=com.resgrid.unit - - UNIT_VERSION=0.0.1 - - UNIT_BASE_API_URL=https://api.resgrid.com - - UNIT_API_VERSION=v4 - - UNIT_RESGRID_API_URL=/api/v4 - - UNIT_CHANNEL_HUB_NAME=eventingHub - - UNIT_REALTIME_GEO_HUB_NAME=geolocationHub - - UNIT_LOGGING_KEY= - - UNIT_APP_KEY= - - UNIT_MAPBOX_PUBKEY= - - UNIT_SENTRY_DSN= - - UNIT_COUNTLY_APP_KEY= - - UNIT_COUNTLY_SERVER_URL= + - IC_NAME=Resgrid IC + - IC_SCHEME=ResgridIC + - IC_BUNDLE_ID=com.resgrid.command + - IC_PACKAGE=com.resgrid.command + - IC_VERSION=0.0.1 + - IC_BASE_API_URL=https://api.resgrid.com + - IC_API_VERSION=v4 + - IC_RESGRID_API_URL=/api/v4 + - IC_CHANNEL_HUB_NAME=eventingHub + - IC_REALTIME_GEO_HUB_NAME=geolocationHub + - IC_LOGGING_KEY= + - IC_APP_KEY= + - IC_MAPBOX_PUBKEY= + - IC_SENTRY_DSN= + - IC_COUNTLY_APP_KEY= + - IC_COUNTLY_SERVER_URL= restart: unless-stopped healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] diff --git a/docker/README.md b/docker/README.md index d672e2f..ad174a0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,6 +1,6 @@ # Docker Deployment Guide -This guide explains how to deploy the Resgrid Unit application using Docker. +This guide explains how to deploy the Resgrid IC application using Docker. ## Quick Start @@ -11,14 +11,14 @@ This guide explains how to deploy the Resgrid Unit application using Docker. docker pull ghcr.io/resgrid/unit:latest # Or pull from Docker Hub (if configured) -docker pull /resgrid-unit:latest +docker pull /resgrid-ic:latest # Run the container docker run -d \ -p 8080:80 \ - -e UNIT_BASE_API_URL="https://api.example.com" \ - -e UNIT_APP_KEY="your-app-key" \ - --name resgrid-unit \ + -e IC_BASE_API_URL="https://api.example.com" \ + -e IC_APP_KEY="your-app-key" \ + --name resgrid-ic \ ghcr.io/resgrid/unit:latest ``` @@ -26,14 +26,14 @@ docker run -d \ ```bash # Build the Docker image -docker build -t resgrid-unit:latest . +docker build -t resgrid-ic:latest . # Run the container docker run -d \ -p 8080:80 \ - -e UNIT_BASE_API_URL="https://api.example.com" \ - --name resgrid-unit \ - resgrid-unit:latest + -e IC_BASE_API_URL="https://api.example.com" \ + --name resgrid-ic \ + resgrid-ic:latest ``` ## Environment Variables @@ -42,26 +42,26 @@ All configuration is done via environment variables at runtime. The Docker image ### Required Variables -- `UNIT_BASE_API_URL` - Base URL for the API (e.g., `https://api.resgrid.com`) +- `IC_BASE_API_URL` - Base URL for the API (e.g., `https://api.resgrid.com`) ### Optional Variables | Variable | Default | Description | |----------|---------|-------------| | `APP_ENV` | `production` | Application environment | -| `UNIT_NAME` | `Resgrid Unit` | Application name | -| `UNIT_SCHEME` | `ResgridUnit` | URL scheme | -| `UNIT_VERSION` | `0.0.1` | Application version | -| `UNIT_API_VERSION` | `v4` | API version | -| `UNIT_RESGRID_API_URL` | `/api/v4` | Resgrid API URL path | -| `UNIT_CHANNEL_HUB_NAME` | `eventingHub` | SignalR channel hub name | -| `UNIT_REALTIME_GEO_HUB_NAME` | `geolocationHub` | SignalR geolocation hub name | -| `UNIT_LOGGING_KEY` | `""` | Logging service key | -| `UNIT_APP_KEY` | `""` | Application key | -| `UNIT_MAPBOX_PUBKEY` | `""` | Mapbox public key | -| `UNIT_SENTRY_DSN` | `""` | Sentry DSN for error tracking | -| `UNIT_COUNTLY_APP_KEY` | `""` | Countly app key for analytics | -| `UNIT_COUNTLY_SERVER_URL` | `""` | Countly server URL | +| `IC_NAME` | `Resgrid IC` | Application name | +| `IC_SCHEME` | `ResgridIC` | URL scheme | +| `IC_VERSION` | `0.0.1` | Application version | +| `IC_API_VERSION` | `v4` | API version | +| `IC_RESGRID_API_URL` | `/api/v4` | Resgrid API URL path | +| `IC_CHANNEL_HUB_NAME` | `eventingHub` | SignalR channel hub name | +| `IC_REALTIME_GEO_HUB_NAME` | `geolocationHub` | SignalR geolocation hub name | +| `IC_LOGGING_KEY` | `""` | Logging service key | +| `IC_APP_KEY` | `""` | Application key | +| `IC_MAPBOX_PUBKEY` | `""` | Mapbox public key | +| `IC_SENTRY_DSN` | `""` | Sentry DSN for error tracking | +| `IC_COUNTLY_APP_KEY` | `""` | Countly app key for analytics | +| `IC_COUNTLY_SERVER_URL` | `""` | Countly server URL | ## Docker Compose @@ -71,26 +71,26 @@ Create a `docker-compose.yml` file: version: '3.8' services: - resgrid-unit: + resgrid-ic: image: ghcr.io/resgrid/unit:latest ports: - "8080:80" environment: - APP_ENV=production - - UNIT_NAME=Resgrid Unit - - UNIT_SCHEME=ResgridUnit - - UNIT_VERSION=7.1 - - UNIT_BASE_API_URL=https://api.resgrid.com - - UNIT_API_VERSION=v4 - - UNIT_RESGRID_API_URL=/api/v4 - - UNIT_CHANNEL_HUB_NAME=eventingHub - - UNIT_REALTIME_GEO_HUB_NAME=geolocationHub - - UNIT_LOGGING_KEY=${UNIT_LOGGING_KEY} - - UNIT_APP_KEY=${UNIT_APP_KEY} - - UNIT_MAPBOX_PUBKEY=${UNIT_MAPBOX_PUBKEY} - - UNIT_SENTRY_DSN=${UNIT_SENTRY_DSN} - - UNIT_COUNTLY_APP_KEY=${UNIT_COUNTLY_APP_KEY} - - UNIT_COUNTLY_SERVER_URL=${UNIT_COUNTLY_SERVER_URL} + - IC_NAME=Resgrid IC + - IC_SCHEME=ResgridIC + - IC_VERSION=7.1 + - IC_BASE_API_URL=https://api.resgrid.com + - IC_API_VERSION=v4 + - IC_RESGRID_API_URL=/api/v4 + - IC_CHANNEL_HUB_NAME=eventingHub + - IC_REALTIME_GEO_HUB_NAME=geolocationHub + - IC_LOGGING_KEY=${IC_LOGGING_KEY} + - IC_APP_KEY=${IC_APP_KEY} + - IC_MAPBOX_PUBKEY=${IC_MAPBOX_PUBKEY} + - IC_SENTRY_DSN=${IC_SENTRY_DSN} + - IC_COUNTLY_APP_KEY=${IC_COUNTLY_APP_KEY} + - IC_COUNTLY_SERVER_URL=${IC_COUNTLY_SERVER_URL} restart: unless-stopped healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] @@ -111,13 +111,13 @@ docker-compose up -d Create a `.env` file (never commit this to version control): ```env -UNIT_BASE_API_URL=https://api.resgrid.com -UNIT_APP_KEY=your-secret-app-key -UNIT_LOGGING_KEY=your-logging-key -UNIT_MAPBOX_PUBKEY=your-mapbox-public-key -UNIT_SENTRY_DSN=your-sentry-dsn -UNIT_COUNTLY_APP_KEY=your-countly-app-key -UNIT_COUNTLY_SERVER_URL=https://countly.example.com +IC_BASE_API_URL=https://api.resgrid.com +IC_APP_KEY=your-secret-app-key +IC_LOGGING_KEY=your-logging-key +IC_MAPBOX_PUBKEY=your-mapbox-public-key +IC_SENTRY_DSN=your-sentry-dsn +IC_COUNTLY_APP_KEY=your-countly-app-key +IC_COUNTLY_SERVER_URL=https://countly.example.com ``` Run with the environment file: @@ -126,7 +126,7 @@ Run with the environment file: docker run -d \ -p 8080:80 \ --env-file .env \ - --name resgrid-unit \ + --name resgrid-ic \ ghcr.io/resgrid/unit:latest ``` @@ -138,48 +138,48 @@ Create a `deployment.yaml`: apiVersion: v1 kind: ConfigMap metadata: - name: resgrid-unit-config + name: resgrid-ic-config data: - UNIT_BASE_API_URL: "https://api.resgrid.com" - UNIT_API_VERSION: "v4" - UNIT_NAME: "Resgrid Unit" + IC_BASE_API_URL: "https://api.resgrid.com" + IC_API_VERSION: "v4" + IC_NAME: "Resgrid IC" --- apiVersion: v1 kind: Secret metadata: - name: resgrid-unit-secrets + name: resgrid-ic-secrets type: Opaque stringData: - UNIT_APP_KEY: "your-secret-app-key" - UNIT_LOGGING_KEY: "your-logging-key" - UNIT_MAPBOX_PUBKEY: "your-mapbox-public-key" + IC_APP_KEY: "your-secret-app-key" + IC_LOGGING_KEY: "your-logging-key" + IC_MAPBOX_PUBKEY: "your-mapbox-public-key" --- apiVersion: apps/v1 kind: Deployment metadata: - name: resgrid-unit + name: resgrid-ic spec: replicas: 3 selector: matchLabels: - app: resgrid-unit + app: resgrid-ic template: metadata: labels: - app: resgrid-unit + app: resgrid-ic spec: containers: - - name: resgrid-unit + - name: resgrid-ic image: ghcr.io/resgrid/unit:latest ports: - containerPort: 80 envFrom: - configMapRef: - name: resgrid-unit-config + name: resgrid-ic-config - secretRef: - name: resgrid-unit-secrets + name: resgrid-ic-secrets resources: requests: memory: "128Mi" @@ -204,14 +204,14 @@ spec: apiVersion: v1 kind: Service metadata: - name: resgrid-unit + name: resgrid-ic spec: type: LoadBalancer ports: - port: 80 targetPort: 80 selector: - app: resgrid-unit + app: resgrid-ic ``` Deploy: @@ -247,25 +247,25 @@ This approach allows the same Docker image to be used across multiple environmen ### View container logs ```bash -docker logs resgrid-unit +docker logs resgrid-ic ``` ### Verify environment variables are injected ```bash -docker exec resgrid-unit cat /usr/share/nginx/html/env-config.js +docker exec resgrid-ic cat /usr/share/nginx/html/env-config.js ``` ### Access container shell ```bash -docker exec -it resgrid-unit sh +docker exec -it resgrid-ic sh ``` ### Check nginx configuration ```bash -docker exec resgrid-unit cat /etc/nginx/nginx.conf +docker exec resgrid-ic cat /etc/nginx/nginx.conf ``` ## Multi-Architecture Support @@ -284,14 +284,14 @@ To update to a new version: docker pull ghcr.io/resgrid/unit:latest # Stop and remove the old container -docker stop resgrid-unit -docker rm resgrid-unit +docker stop resgrid-ic +docker rm resgrid-ic # Start a new container with the updated image docker run -d \ -p 8080:80 \ --env-file .env \ - --name resgrid-unit \ + --name resgrid-ic \ ghcr.io/resgrid/unit:latest ``` diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 29e5dc8..6b042fb 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -17,24 +17,24 @@ cat > "${HTML_DIR}/env-config.js" << EOF // This file is generated at container startup and injects environment variables window.__ENV__ = { APP_ENV: "$(js_escape "${APP_ENV:-production}")", - NAME: "$(js_escape "${UNIT_NAME:-Resgrid Unit}")", - SCHEME: "$(js_escape "${UNIT_SCHEME:-ResgridUnit}")", - BUNDLE_ID: "$(js_escape "${UNIT_BUNDLE_ID:-com.resgrid.unit}")", - PACKAGE: "$(js_escape "${UNIT_PACKAGE:-com.resgrid.unit}")", - VERSION: "$(js_escape "${UNIT_VERSION:-0.0.1}")", + NAME: "$(js_escape "${IC_NAME:-Resgrid IC}")", + SCHEME: "$(js_escape "${IC_SCHEME:-ResgridIC}")", + BUNDLE_ID: "$(js_escape "${IC_BUNDLE_ID:-com.resgrid.command}")", + PACKAGE: "$(js_escape "${IC_PACKAGE:-com.resgrid.command}")", + VERSION: "$(js_escape "${IC_VERSION:-0.0.1}")", ANDROID_VERSION_CODE: 1, - BASE_API_URL: "$(js_escape "${UNIT_BASE_API_URL:-https://api.resgrid.com}")", - API_VERSION: "$(js_escape "${UNIT_API_VERSION:-v4}")", - RESGRID_API_URL: "$(js_escape "${UNIT_RESGRID_API_URL:-/api/v4}")", - CHANNEL_HUB_NAME: "$(js_escape "${UNIT_CHANNEL_HUB_NAME:-eventingHub}")", - REALTIME_GEO_HUB_NAME: "$(js_escape "${UNIT_REALTIME_GEO_HUB_NAME:-geolocationHub}")", - LOGGING_KEY: "$(js_escape "${UNIT_LOGGING_KEY:-}")", - APP_KEY: "$(js_escape "${UNIT_APP_KEY:-}")", - UNIT_MAPBOX_PUBKEY: "$(js_escape "${UNIT_MAPBOX_PUBKEY:-}")", + BASE_API_URL: "$(js_escape "${IC_BASE_API_URL:-https://api.resgrid.com}")", + API_VERSION: "$(js_escape "${IC_API_VERSION:-v4}")", + RESGRID_API_URL: "$(js_escape "${IC_RESGRID_API_URL:-/api/v4}")", + CHANNEL_HUB_NAME: "$(js_escape "${IC_CHANNEL_HUB_NAME:-eventingHub}")", + REALTIME_GEO_HUB_NAME: "$(js_escape "${IC_REALTIME_GEO_HUB_NAME:-geolocationHub}")", + LOGGING_KEY: "$(js_escape "${IC_LOGGING_KEY:-}")", + APP_KEY: "$(js_escape "${IC_APP_KEY:-}")", + IC_MAPBOX_PUBKEY: "$(js_escape "${IC_MAPBOX_PUBKEY:-}")", IS_MOBILE_APP: "false", - SENTRY_DSN: "$(js_escape "${UNIT_SENTRY_DSN:-}")", - COUNTLY_APP_KEY: "$(js_escape "${UNIT_COUNTLY_APP_KEY:-}")", - COUNTLY_SERVER_URL: "$(js_escape "${UNIT_COUNTLY_SERVER_URL:-}")" + SENTRY_DSN: "$(js_escape "${IC_SENTRY_DSN:-}")", + COUNTLY_APP_KEY: "$(js_escape "${IC_COUNTLY_APP_KEY:-}")", + COUNTLY_SERVER_URL: "$(js_escape "${IC_COUNTLY_SERVER_URL:-}")" }; EOF diff --git a/electron-builder.config.js b/electron-builder.config.js index e3bdfeb..e9af535 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -3,8 +3,8 @@ * https://www.electron.build/configuration/configuration */ module.exports = { - appId: 'com.resgrid.unit', - productName: 'Resgrid Unit', + appId: 'com.resgrid.command', + productName: 'Resgrid IC', copyright: 'Copyright © 2024 Resgrid', directories: { @@ -57,7 +57,7 @@ module.exports = { installerHeaderIcon: 'assets/icon.ico', createDesktopShortcut: true, createStartMenuShortcut: true, - shortcutName: 'Resgrid Unit', + shortcutName: 'Resgrid IC', license: 'LICENSE', }, @@ -74,10 +74,10 @@ module.exports = { vendor: 'Resgrid', desktop: { entry: { - Name: 'Resgrid Unit', - Comment: 'Resgrid Unit Management Application', + Name: 'Resgrid IC', + Comment: 'Resgrid Incident Command Application', Category: 'Office;Utility;', - StartupWMClass: 'resgrid-unit', + StartupWMClass: 'resgrid-ic', }, }, }, diff --git a/electron/main.js b/electron/main.js index c5957f9..b901858 100644 --- a/electron/main.js +++ b/electron/main.js @@ -148,7 +148,7 @@ ipcMain.handle('show-notification', async (event, { title, body, data }) => { } const notification = new Notification({ - title: title || 'Resgrid Unit', + title: title || 'Resgrid IC', body: body || '', silent: false, icon: path.join(__dirname, '../assets/icon.png'), diff --git a/env.js b/env.js index da09940..93d2c8e 100644 --- a/env.js +++ b/env.js @@ -34,11 +34,11 @@ require('dotenv').config({ // TODO: Replace these values with your own -const BUNDLE_ID = 'com.resgrid.ic'; // ios bundle id -const PACKAGE = 'com.resgrid.ic'; // android package name +const BUNDLE_ID = 'com.resgrid.command'; // ios bundle id +const PACKAGE = 'com.resgrid.command'; // android package name const NAME = 'Resgrid IC'; // app name const EXPO_ACCOUNT_OWNER = 'resgrid'; // expo account owner -const EAS_PROJECT_ID = '6beb03e6-f639-4f35-bef7-5cfb5f173218'; // TODO(IC): replace with the IC app's OWN EAS project id (this is still the Unit project id) +const EAS_PROJECT_ID = 'b29e7235-267a-494d-8b59-466701ada204'; // Resgrid IC EAS project id const SCHEME = 'ResgridIC'; // app scheme /** diff --git a/jest-setup.ts b/jest-setup.ts index 7527560..edbb3b6 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -255,3 +255,14 @@ jest.mock('zod', () => ({ }, __esModule: true, })); + +// Mock NetInfo globally — the native module isn't available in the Jest environment +jest.mock('@react-native-community/netinfo', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => jest.fn()), + fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true, type: 'wifi', details: {} })), + }, + addEventListener: jest.fn(() => jest.fn()), + fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true, type: 'wifi', details: {} })), +})); diff --git a/package.json b/package.json index be5398a..140ddc7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:ci": "yarn run test --coverage", "test:watch": "yarn run test --watch", "install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash", - "e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development", + "e2e-test": "maestro test .maestro/ -e APP_ID=com.resgrid.command.development", "web:build": "cross-env EXPO_NO_DOTENV=1 expo export --platform web", "web:staging": "cross-env APP_ENV=staging yarn run web", "web:production": "cross-env APP_ENV=production yarn run web", diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js index eab2b92..587b37b 100644 --- a/plugins/withInCallAudioModule.js +++ b/plugins/withInCallAudioModule.js @@ -194,7 +194,7 @@ class InCallAudioPackage : ReactPackage { /** * Helper to resolve package name */ -function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') { +function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.command') { const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/; const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle'); diff --git a/plugins/withMediaButtonModule.js b/plugins/withMediaButtonModule.js index aec6d49..523ee53 100644 --- a/plugins/withMediaButtonModule.js +++ b/plugins/withMediaButtonModule.js @@ -382,10 +382,10 @@ class MediaButtonPackage : ReactPackage { * - Kotlin DSL: namespace = "com.example.app" * * @param {string} projectRoot - The project root directory - * @param {string} fallback - Fallback package name if not found (default: 'com.resgrid.unit') + * @param {string} fallback - Fallback package name if not found (default: 'com.resgrid.command') * @returns {string} The resolved namespace or fallback */ -function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') { +function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.command') { // Regex that accepts optional equals sign for both Groovy and Kotlin DSL const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/; diff --git a/plugins/withNotificationIcon.js b/plugins/withNotificationIcon.js new file mode 100644 index 0000000..2e2c92c --- /dev/null +++ b/plugins/withNotificationIcon.js @@ -0,0 +1,72 @@ +const { withDangerousMod, withAndroidManifest, withAndroidColors, AndroidConfig } = require('expo/config-plugins'); +const { copyFileSync, existsSync, mkdirSync } = require('fs'); +const { resolve } = require('path'); + +const ANDROID_RES_PATH = 'android/app/src/main/res/'; + +/** + * Pre-rendered pure-white silhouette densities (assets/notification-icons/) — the status-bar + * small icon Android tints. Generated from assets/notification-icon.png. + */ +const DENSITIES = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']; + +/** Brand tint applied by Android behind/around the white silhouette. */ +const NOTIFICATION_COLOR = '#2484c4'; + +/** Copies the ic_notification drawable into every density bucket at prebuild. */ +function withNotificationIconDrawables(config) { + return withDangerousMod(config, [ + 'android', + (config) => { + for (const density of DENSITIES) { + const source = resolve(config.modRequest.projectRoot, `assets/notification-icons/ic_notification-${density}.png`); + if (!existsSync(source)) { + throw new Error(`Notification icon asset missing: ${source}`); + } + const targetDir = resolve(config.modRequest.projectRoot, `${ANDROID_RES_PATH}drawable-${density}`); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + copyFileSync(source, resolve(targetDir, 'ic_notification.png')); + } + return config; + }, + ]); +} + +/** + * Points FCM at the drawable + tint color so Android notifications (which bypass notifee's + * display path) use the branded silhouette instead of a gray launcher-icon blob. + */ +function withNotificationIconManifest(config) { + return withAndroidManifest(config, (config) => { + const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + mainApplication['meta-data'] = mainApplication['meta-data'] || []; + + const upsert = (name, attrs) => { + const existing = mainApplication['meta-data'].find((item) => item.$['android:name'] === name); + if (existing) { + existing.$ = { 'android:name': name, ...attrs }; + } else { + mainApplication['meta-data'].push({ $: { 'android:name': name, ...attrs } }); + } + }; + + upsert('com.google.firebase.messaging.default_notification_icon', { 'android:resource': '@drawable/ic_notification' }); + upsert('com.google.firebase.messaging.default_notification_color', { 'android:resource': '@color/notification_icon_color' }); + return config; + }); +} + +/** Registers the tint color resource the manifest meta-data references. */ +function withNotificationIconColor(config) { + return withAndroidColors(config, (config) => { + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: 'notification_icon_color', + value: NOTIFICATION_COLOR, + }); + return config; + }); +} + +module.exports = (config) => withNotificationIconColor(withNotificationIconManifest(withNotificationIconDrawables(config))); diff --git a/public/service-worker.js b/public/service-worker.js index 0a650ff..f4e0efc 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,12 +1,12 @@ /* eslint-disable no-undef */ /* eslint-disable no-unused-vars */ /** - * Service Worker for Resgrid Unit Web Push Notifications + * Service Worker for Resgrid IC Web Push Notifications * This file handles background push notifications when the app is not in focus */ // Cache name for offline support (optional) -const CACHE_NAME = 'resgrid-unit-v1'; +const CACHE_NAME = 'resgrid-ic-v1'; // Store pending notification data for newly opened windows const pendingNotifications = new Map(); @@ -27,7 +27,7 @@ self.addEventListener('push', function (event) { } } - const title = data.title || 'Resgrid Unit'; + const title = data.title || 'Resgrid IC'; const options = { body: data.body || data.message || 'You have a new notification', icon: '/icon-192.png', diff --git a/src/api/common/cached-client.ts b/src/api/common/cached-client.ts index 3e24a45..3398a2c 100644 --- a/src/api/common/cached-client.ts +++ b/src/api/common/cached-client.ts @@ -9,6 +9,15 @@ interface CacheConfig { enabled?: boolean; // Whether to use cache for this endpoint } +const buildCachedResponse = (data: T, stale = false): AxiosResponse => + ({ + data, + status: 200, + statusText: stale ? 'OK (stale cache)' : 'OK (cached)', + headers: {}, + config: {}, + }) as AxiosResponse; + export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConfig = { enabled: true }) => { const api = createApiEndpoint(endpoint); const defaultTTL = 5 * 60 * 1000; // 5 minutes @@ -21,18 +30,22 @@ export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConf const cached = cacheManager.get(endpoint, params); if (cached) { - return Promise.resolve({ - data: cached, - status: 200, - statusText: 'OK (cached)', - headers: {}, - config: {}, - } as AxiosResponse); + return buildCachedResponse(cached); } - const response = await api.get(params); - cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL); - return response; + try { + const response = await api.get(params); + cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL); + return response; + } catch (error) { + // Offline / request failed: fall back to the last cached value even if + // its TTL has expired, so read paths keep working without a connection. + const stale = cacheManager.getStale(endpoint, params); + if (stale !== null) { + return buildCachedResponse(stale, true); + } + throw error; + } }, post: api.post, put: api.put, diff --git a/src/api/devices/push.ts b/src/api/devices/push.ts index 7be8a32..7fa23d6 100644 --- a/src/api/devices/push.ts +++ b/src/api/devices/push.ts @@ -1,12 +1,12 @@ +import { type PushRegistrationInput } from '@/models/v4/device/pushRegistrationInput'; import { type PushRegistrationResult } from '@/models/v4/device/pushRegistrationResult'; -import { type PushRegistrationUnitInput } from '@/models/v4/device/pushRegistrationUnitInput'; import { createApiEndpoint } from '../common/client'; -const registerUnitDeviceApi = createApiEndpoint('/Devices/RegisterUnitDevice'); +const registerDeviceApi = createApiEndpoint('/Devices/RegisterDevice'); -export const registerUnitDevice = async (data: PushRegistrationUnitInput) => { - const response = await registerUnitDeviceApi.post({ +export const registerDevice = async (data: PushRegistrationInput) => { + const response = await registerDeviceApi.post({ ...data, }); return response.data; diff --git a/src/api/incidentCommand/commands.ts b/src/api/incidentCommand/commands.ts new file mode 100644 index 0000000..1557549 --- /dev/null +++ b/src/api/incidentCommand/commands.ts @@ -0,0 +1,58 @@ +import { createApiEndpoint } from '../common/client'; + +/** + * Command definition templates (CommandsController) — department-defined lane sets + * per call type. Lets a security company or event operator pre-build boards like + * "Concert" with Medical Booth / Patrols / Customer Service lanes. + */ + +export interface CommandRoleResultData { + CommandDefinitionRoleId: number; + Name: string; + Description?: string | null; + LaneType: number; + SortOrder: number; + MinUnitPersonnel: number; + MaxUnitPersonnel: number; + MinUnits: number; + MaxUnits: number; + MinTimeInRole: number; + MaxTimeInRole: number; + /** Lane identification color (hex); board lanes and marker tints inherit it. */ + Color?: string | null; + ForceRequirements: boolean; + RequiredUnitTypes: number[]; + RequiredPersonnelRoles: number[]; +} + +export interface CommandResultData { + CommandDefinitionId: number; + CallTypeId?: number | null; + Name: string; + Description?: string | null; + Timer: boolean; + TimerMinutes: number; + Lanes: CommandRoleResultData[]; +} + +interface V4Result { + Data: T; + Status?: string; + Message?: string; +} + +export const getAllCommands = async () => { + const response = await createApiEndpoint('/Commands/GetAllCommands').get>(); + return response.data; +}; + +export const getCommand = async (commandDefinitionId: number) => { + const response = await createApiEndpoint(`/Commands/GetCommand/${commandDefinitionId}`).get>(); + return response.data; +}; + +/** Pass callTypeId 0 for the "Any Call Type" default template. */ +export const getCommandForCallType = async (callTypeId: number) => { + const response = await createApiEndpoint(`/Commands/GetCommandForCallType/${callTypeId}`).get>(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentCommand.ts b/src/api/incidentCommand/incidentCommand.ts new file mode 100644 index 0000000..2ba3961 --- /dev/null +++ b/src/api/incidentCommand/incidentCommand.ts @@ -0,0 +1,123 @@ +import type { + CommandAccountabilityResult, + CommandNodeResult, + CommandStructureNode, + CommandTimelineResult, + CommandTransferResult, + EstablishCommandInput, + EvaluateAccountabilityResult, + IncidentCommandActionResult, + IncidentCommandBoardResult, + IncidentCommandResult, + IncidentMapAnnotation, + IncidentMapAnnotationResult, + IncidentTimer, + IncidentTimerResult, + MoveResourceInput, + ResourceAssignment, + ResourceAssignmentResult, + TacticalObjective, + TacticalObjectiveResult, + TransferCommandInput, + UpdateActionPlanInput, +} from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { createApiEndpoint } from '../common/client'; + +// Core routes use path parameters (e.g. GetCommandBoard/{callId}), so endpoints +// with a path parameter are created per call. + +export const establishCommand = async (input: EstablishCommandInput) => { + const response = await createApiEndpoint('/IncidentCommand/EstablishCommand').post({ ...input }); + return response.data; +}; + +export const getCommandBoard = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentCommand/GetCommandBoard/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const transferCommand = async (input: TransferCommandInput) => { + const response = await createApiEndpoint('/IncidentCommand/TransferCommand').post({ ...input }); + return response.data; +}; + +export const closeCommand = async (incidentCommandId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/CloseCommand/${encodeURIComponent(incidentCommandId)}`).put({}); + return response.data; +}; + +export const updateActionPlan = async (input: UpdateActionPlanInput) => { + const response = await createApiEndpoint('/IncidentCommand/UpdateActionPlan').put({ ...input }); + return response.data; +}; + +export const getAccountability = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentCommand/GetAccountability/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const evaluateAccountability = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentCommand/EvaluateAccountability/${encodeURIComponent(String(callId))}`).post({}); + return response.data; +}; + +export const saveCommandNode = async (node: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveNode').post({ ...node }); + return response.data; +}; + +export const deleteCommandNode = async (commandStructureNodeId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/DeleteNode/${encodeURIComponent(commandStructureNodeId)}`).delete(); + return response.data; +}; + +export const assignResource = async (assignment: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/AssignResource').post({ ...assignment }); + return response.data; +}; + +export const moveResource = async (input: MoveResourceInput) => { + const response = await createApiEndpoint('/IncidentCommand/MoveResource').post({ ...input }); + return response.data; +}; + +export const releaseResource = async (resourceAssignmentId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/ReleaseResource/${encodeURIComponent(resourceAssignmentId)}`).post({}); + return response.data; +}; + +export const saveObjective = async (objective: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveObjective').post({ ...objective }); + return response.data; +}; + +export const completeObjective = async (tacticalObjectiveId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/CompleteObjective/${encodeURIComponent(tacticalObjectiveId)}`).post({}); + return response.data; +}; + +export const startIncidentTimer = async (timer: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/StartTimer').post({ ...timer }); + return response.data; +}; + +export const acknowledgeIncidentTimer = async (incidentTimerId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/AcknowledgeTimer/${encodeURIComponent(incidentTimerId)}`).post({}); + return response.data; +}; + +export const saveMapAnnotation = async (annotation: Partial) => { + const response = await createApiEndpoint('/IncidentCommand/SaveAnnotation').post({ ...annotation }); + return response.data; +}; + +export const deleteMapAnnotation = async (incidentMapAnnotationId: string) => { + const response = await createApiEndpoint(`/IncidentCommand/DeleteAnnotation/${encodeURIComponent(incidentMapAnnotationId)}`).delete(); + return response.data; +}; + +export const getCommandTimeline = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentCommand/GetTimeline/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentResources.ts b/src/api/incidentCommand/incidentResources.ts new file mode 100644 index 0000000..cb8815c --- /dev/null +++ b/src/api/incidentCommand/incidentResources.ts @@ -0,0 +1,66 @@ +import type { + AdHocPersonnelListResult, + AdHocPersonnelResult, + AdHocUnitResult, + AdHocUnitsResult, + IncidentAdHocPersonnel, + IncidentAdHocUnit, + IncidentCommandActionResult, +} from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { createApiEndpoint } from '../common/client'; + +export interface AssignPersonnelToUnitInput { + IncidentAdHocPersonnelId: string; + RidingResourceKind: number; + RidingResourceId: string; +} + +export interface FormUnitInput { + CallId: number; + Name: string; + Type?: string; + UnitTypeId?: number | null; + ExternalAgencyName?: string; + AdHocPersonnelIds: string[]; +} + +export const createAdHocUnit = async (unit: Partial) => { + const response = await createApiEndpoint('/IncidentResources/CreateAdHocUnit').post({ ...unit }); + return response.data; +}; + +export const getAdHocUnits = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentResources/GetAdHocUnits/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const releaseAdHocUnit = async (incidentAdHocUnitId: string) => { + const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocUnit/${encodeURIComponent(incidentAdHocUnitId)}`).post({}); + return response.data; +}; + +export const createAdHocPersonnel = async (personnel: Partial) => { + const response = await createApiEndpoint('/IncidentResources/CreateAdHocPersonnel').post({ ...personnel }); + return response.data; +}; + +export const getAdHocPersonnel = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentResources/GetAdHocPersonnel/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const releaseAdHocPersonnel = async (incidentAdHocPersonnelId: string) => { + const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocPersonnel/${encodeURIComponent(incidentAdHocPersonnelId)}`).post({}); + return response.data; +}; + +export const assignPersonnelToUnit = async (input: AssignPersonnelToUnitInput) => { + const response = await createApiEndpoint('/IncidentResources/AssignPersonnelToUnit').post({ ...input }); + return response.data; +}; + +export const formUnit = async (input: FormUnitInput) => { + const response = await createApiEndpoint('/IncidentResources/FormUnit').post({ ...input }); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentRoles.ts b/src/api/incidentCommand/incidentRoles.ts new file mode 100644 index 0000000..6f97fa9 --- /dev/null +++ b/src/api/incidentCommand/incidentRoles.ts @@ -0,0 +1,23 @@ +import type { IncidentCapabilitiesResult, IncidentCommandActionResult, IncidentRoleAssignment, IncidentRoleResult, IncidentRolesResult } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { createApiEndpoint } from '../common/client'; + +export const assignIncidentRole = async (assignment: Partial) => { + const response = await createApiEndpoint('/IncidentRoles/AssignRole').post({ ...assignment }); + return response.data; +}; + +export const removeIncidentRole = async (incidentRoleAssignmentId: string) => { + const response = await createApiEndpoint(`/IncidentRoles/RemoveRole/${encodeURIComponent(incidentRoleAssignmentId)}`).post({}); + return response.data; +}; + +export const getIncidentRoles = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentRoles/GetRoles/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const getMyIncidentCapabilities = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentRoles/GetMyCapabilities/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; diff --git a/src/api/incidentCommand/incidentVoice.ts b/src/api/incidentCommand/incidentVoice.ts new file mode 100644 index 0000000..63e5b8b --- /dev/null +++ b/src/api/incidentCommand/incidentVoice.ts @@ -0,0 +1,40 @@ +import type { IncidentCommandActionResult, IncidentVoiceChannelResult, IncidentVoiceChannelsResult, VoiceTransmissionLogResult, VoiceTransmissionLogsResult } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { createApiEndpoint } from '../common/client'; + +export interface CreateIncidentChannelInput { + CallId: number; + Name: string; +} + +export interface LogTransmissionInput { + CallId: number; + DepartmentVoiceChannelId: string; + StartedOn: string; + EndedOn?: string; +} + +export const createIncidentChannel = async (input: CreateIncidentChannelInput) => { + const response = await createApiEndpoint('/IncidentVoice/CreateIncidentChannel').post({ ...input }); + return response.data; +}; + +export const getChannelsForCall = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentVoice/GetChannelsForCall/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; + +export const closeIncidentChannels = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentVoice/CloseIncidentChannels/${encodeURIComponent(String(callId))}`).post({}); + return response.data; +}; + +export const logTransmission = async (input: LogTransmissionInput) => { + const response = await createApiEndpoint('/IncidentVoice/LogTransmission').post({ ...input }); + return response.data; +}; + +export const getTransmissionLog = async (callId: string | number) => { + const response = await createApiEndpoint(`/IncidentVoice/GetTransmissionLog/${encodeURIComponent(String(callId))}`).get(); + return response.data; +}; diff --git a/src/api/incidentCommand/sync.ts b/src/api/incidentCommand/sync.ts new file mode 100644 index 0000000..71927e0 --- /dev/null +++ b/src/api/incidentCommand/sync.ts @@ -0,0 +1,31 @@ +import type { SyncBundleResult, SyncChangesResult, SyncReferenceResult } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { createApiEndpoint } from '../common/client'; + +/** + * Row-based delta pull of every change-tracked incident-command row modified + * since the cursor. Pass 0 (or omit) for a full pull. Soft-deleted / closed / + * released rows are included so the client can remove them locally. + */ +export const getSyncChanges = async (sinceUnixEpochMs = 0) => { + const response = await createApiEndpoint('/Sync/Changes').get({ since: sinceUnixEpochMs }); + return response.data; +}; + +/** + * Shift-start aggregate: one render-ready IncidentCommandBoard (including + * computed PAR/accountability) per active incident + active ad-hoc resources. + */ +export const getSyncBundle = async (includeAccountability = true) => { + const response = await createApiEndpoint('/Sync/Bundle').get({ includeAccountability }); + return response.data; +}; + +/** + * Slowly-changing department reference data (call types, priorities, command + * templates, units, groups, POIs, personnel roster, ...) for offline start. + */ +export const getSyncReference = async (bypassCache = false) => { + const response = await createApiEndpoint('/Sync/Reference').get({ bypassCache }); + return response.data; +}; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index 06548eb..269de84 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -23,10 +23,6 @@ import { createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers'); const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers'); -const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', { - ttl: 5 * 60 * 1000, - enabled: true, -}); const getPoisApi = createCachedApiEndpoint('/Mapping/GetPois', { ttl: 5 * 60 * 1000, enabled: true, @@ -41,9 +37,6 @@ const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', { ttl: 5 * 60 * 1000, enabled: true, }); -const getIndoorMapApi = createApiEndpoint('/Mapping/GetIndoorMap'); -const getIndoorMapFloorApi = createApiEndpoint('/Mapping/GetIndoorMapFloor'); -const getIndoorMapZonesGeoJSONApi = createApiEndpoint('/Mapping/GetIndoorMapZonesGeoJSON'); const searchIndoorLocationsApi = createApiEndpoint('/Mapping/SearchIndoorLocations'); const getNearbyIndoorMapsApi = createApiEndpoint('/Mapping/GetNearbyIndoorMaps'); @@ -52,12 +45,20 @@ const getCustomMapsApi = createCachedApiEndpoint('/Mapping/GetCustomMaps', { ttl: 5 * 60 * 1000, enabled: true, }); -const getCustomMapApi = createApiEndpoint('/Mapping/GetCustomMap'); -const getCustomMapLayerApi = createApiEndpoint('/Mapping/GetCustomMapLayer'); -const getMapLayerGeoJSONApi = createApiEndpoint('/Mapping/GetMapLayerGeoJSON'); -const getCustomMapRegionsGeoJSONApi = createApiEndpoint('/Mapping/GetCustomMapRegionsGeoJSON'); const searchCustomMapRegionsApi = createApiEndpoint('/Mapping/SearchCustomMapRegions'); +/** + * The GeoJSON endpoints return a raw `application/geo+json` FeatureCollection body + * (not the standard v4 `{ Data: ... }` envelope). Accept either shape. + */ +const unwrapGeoJSON = (body: unknown): FeatureCollection => { + const asAny = body as { type?: string; Data?: FeatureCollection }; + if (asAny?.type === 'FeatureCollection') { + return body as FeatureCollection; + } + return asAny?.Data ?? { type: 'FeatureCollection', features: [] }; +}; + // Discovery endpoints const getAllActiveLayersApi = createCachedApiEndpoint('/Mapping/GetAllActiveLayers', { ttl: 5 * 60 * 1000, @@ -91,9 +92,7 @@ export const getPois = async (poiTypeId?: number, destinationOnly?: boolean) => }; export const getPoi = async (poiId: number | string) => { - const response = await getPoiApi.get({ - poiId: encodeURIComponent(String(poiId)), - }); + const response = await createCachedApiEndpoint(`/Mapping/GetPoi/${encodeURIComponent(String(poiId))}`, { ttl: 5 * 60 * 1000, enabled: true }).get(); return response.data; }; @@ -115,24 +114,18 @@ export const getIndoorMaps = async () => { }; export const getIndoorMap = async (mapId: string) => { - const response = await getIndoorMapApi.get({ - id: encodeURIComponent(mapId), - }); + const response = await createApiEndpoint(`/Mapping/GetIndoorMap/${encodeURIComponent(mapId)}`).get(); return response.data; }; export const getIndoorMapFloor = async (floorId: string) => { - const response = await getIndoorMapFloorApi.get({ - floorId: encodeURIComponent(floorId), - }); + const response = await createApiEndpoint(`/Mapping/GetIndoorMapFloor/${encodeURIComponent(floorId)}`).get(); return response.data; }; -export const getIndoorMapZonesGeoJSON = async (floorId: string) => { - const response = await getIndoorMapZonesGeoJSONApi.get({ - floorId: encodeURIComponent(floorId), - }); - return response.data; +export const getIndoorMapZonesGeoJSON = async (floorId: string): Promise => { + const response = await createApiEndpoint(`/Mapping/GetIndoorMapZonesGeoJSON/${encodeURIComponent(floorId)}`).get(); + return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult; }; export const searchIndoorLocations = async (term: string, mapId?: string) => { @@ -165,31 +158,23 @@ export const getCustomMaps = async (type?: number) => { }; export const getCustomMap = async (mapId: string) => { - const response = await getCustomMapApi.get({ - id: encodeURIComponent(mapId), - }); + const response = await createApiEndpoint(`/Mapping/GetCustomMap/${encodeURIComponent(mapId)}`).get(); return response.data; }; export const getCustomMapLayer = async (layerId: string) => { - const response = await getCustomMapLayerApi.get({ - layerId: encodeURIComponent(layerId), - }); + const response = await createApiEndpoint(`/Mapping/GetCustomMapLayer/${encodeURIComponent(layerId)}`).get(); return response.data; }; -export const getMapLayerGeoJSON = async (layerId: string) => { - const response = await getMapLayerGeoJSONApi.get({ - layerId: encodeURIComponent(layerId), - }); - return response.data; +export const getMapLayerGeoJSON = async (layerId: string): Promise => { + const response = await createApiEndpoint(`/Mapping/GetMapLayerGeoJSON/${encodeURIComponent(layerId)}`).get(); + return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult; }; -export const getCustomMapRegionsGeoJSON = async (layerId: string) => { - const response = await getCustomMapRegionsGeoJSONApi.get({ - layerId: encodeURIComponent(layerId), - }); - return response.data; +export const getCustomMapRegionsGeoJSON = async (layerId: string): Promise => { + const response = await createApiEndpoint(`/Mapping/GetCustomMapRegionsGeoJSON/${encodeURIComponent(layerId)}`).get(); + return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult; }; export const searchCustomMapRegions = async (term: string, layerId?: string) => { diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx index a8d5f3d..e3d85b5 100644 --- a/src/app/(app)/__tests__/calls.test.tsx +++ b/src/app/(app)/__tests__/calls.test.tsx @@ -3,6 +3,10 @@ import { router } from 'expo-router'; import React from 'react'; // Mock Platform before any other imports +jest.mock('@/components/command/start-command-sheet', () => ({ + StartCommandSheet: () => null, +})); + jest.mock('react-native', () => ({ Platform: { OS: 'ios', diff --git a/src/app/(app)/__tests__/command.test.tsx b/src/app/(app)/__tests__/command.test.tsx new file mode 100644 index 0000000..a8ff5ec --- /dev/null +++ b/src/app/(app)/__tests__/command.test.tsx @@ -0,0 +1,437 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { Dimensions } from 'react-native'; + +const mockPush = jest.fn(); + +jest.mock('expo-router', () => ({ + router: { push: (...args: unknown[]) => mockPush(...args) }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + AlarmClock: icon('alarm-clock'), + Check: icon('check'), + ClipboardList: icon('clipboard-list'), + CloudOff: icon('cloud-off'), + ExternalLink: icon('external-link'), + GripVertical: icon('grip-vertical'), + MapPin: icon('map-pin'), + Mic: icon('mic'), + MicOff: icon('mic-off'), + Phone: icon('phone'), + PhoneOff: icon('phone-off'), + RadioTower: icon('radio-tower'), + Plus: icon('plus'), + RefreshCw: icon('refresh'), + Trash2: icon('trash'), + Truck: icon('truck'), + UserCog: icon('user-cog'), + UserPlus: icon('user-plus'), + XCircle: icon('x-circle'), + }; +}); + +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ + FocusAwareStatusBar: () => null, +})); + +jest.mock('@/components/ui/html-renderer', () => ({ + HtmlRenderer: () => null, +})); + +// AnimatePresence-driven dialog never mounts under jest — flatten to plain views +jest.mock('@/components/ui/alert-dialog', () => { + const React = require('react'); + const passthrough = (name: string) => (props: any) => React.createElement('View', { ...props, testID: props.testID ?? `mock-${name}` }, props.children); + return { + AlertDialog: ({ children, isOpen }: any) => (isOpen ? children : null), + AlertDialogBackdrop: () => null, + AlertDialogContent: passthrough('alert-content'), + AlertDialogHeader: passthrough('alert-header'), + AlertDialogBody: passthrough('alert-body'), + AlertDialogFooter: passthrough('alert-footer'), + }; +}); + +jest.mock('@/components/command/add-assignment-sheet', () => ({ + AddAssignmentSheet: () => null, +})); + +jest.mock('@/components/command/add-resource-sheet', () => ({ + AddResourceSheet: () => null, +})); + +jest.mock('@/lib/utils', () => ({ + ...jest.requireActual('@/lib/utils'), + getTimeAgoUtc: jest.fn(() => '5 minutes ago'), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: jest.fn(), +})); + +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: jest.fn(), +})); + +jest.mock('@/stores/command/store', () => ({ + useCommandStore: jest.fn(), +})); + +jest.mock('@/stores/roles/store', () => ({ + useRolesStore: jest.fn(), +})); + +import { useCoreStore } from '@/stores/app/core-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { useCommandStore } from '@/stores/command/store'; +import { useRolesStore } from '@/stores/roles/store'; + +import CommandBoard from '../command'; + +const mockUseCoreStore = useCoreStore as unknown as jest.Mock; +const mockUseCallsStore = useCallsStore as unknown as jest.Mock; +const mockUseCommandStore = useCommandStore as unknown as jest.Mock; +const mockUseRolesStore = useRolesStore as unknown as jest.Mock; + +const mockSwitchCommand = jest.fn(); +const mockEndCommand = jest.fn(); +const mockRefreshBoard = jest.fn(); +const mockRemoveRole = jest.fn(); +const mockMoveResourceAssignment = jest.fn(); +const mockReleaseResourceAssignment = jest.fn(); +const mockFetchTimeline = jest.fn(); +const mockRefreshAccountability = jest.fn(); + +const serverBoard = (callId: string, overrides: Record = {}) => ({ + callId, + board: { + Command: { IncidentCommandId: `cmd-${callId}`, DepartmentId: 1, CallId: parseInt(callId, 10), EstablishedByUserId: 'u1', EstablishedOn: '2026-07-19T10:00:00Z', CurrentCommanderUserId: 'u1', IcsLevel: 1, Status: 0 }, + Nodes: [], + Assignments: [], + Objectives: [], + Timers: [], + Annotations: [], + Accountability: [], + Roles: [], + ...overrides, + }, + adHocUnits: [], + adHocPersonnel: [], + isProvisional: false, + lastRefreshed: null, +}); + +const setupStores = ({ boards = {} as Record, activeCallId = null as string | null, calls = [] as any[], users = [] as any[] }) => { + const commandState = { + boards, + activeCallId, + isRefreshing: false, + switchCommand: mockSwitchCommand, + endCommand: mockEndCommand, + refreshBoard: mockRefreshBoard, + assignRole: jest.fn(), + removeRole: mockRemoveRole, + addAdHocUnit: jest.fn(), + releaseAdHocUnitEntry: jest.fn(), + addAdHocPersonnel: jest.fn(), + releaseAdHocPersonnelEntry: jest.fn(), + refreshAccountability: mockRefreshAccountability, + moveResourceAssignment: mockMoveResourceAssignment, + releaseResourceAssignment: mockReleaseResourceAssignment, + startTimer: jest.fn(), + createVoiceChannel: jest.fn(), + fetchVoiceChannels: jest.fn(), + closeVoiceChannels: jest.fn(), + fetchTransmissionLog: jest.fn(), + recordTransmission: jest.fn(), + acknowledgeTimer: jest.fn(), + transferIncidentCommand: jest.fn(), + fetchTimeline: mockFetchTimeline, + }; + mockUseCommandStore.mockImplementation((selector: any) => (selector ? selector(commandState) : commandState)); + + const coreState = { activeCall: null, activePriority: null }; + mockUseCoreStore.mockImplementation((selector: any) => (selector ? selector(coreState) : coreState)); + + const callsState = { calls }; + mockUseCallsStore.mockImplementation((selector: any) => (selector ? selector(callsState) : callsState)); + + const rolesState = { users, roles: [] as any[], fetchUsers: jest.fn() }; + mockUseRolesStore.mockImplementation((selector: any) => (selector ? selector(rolesState) : rolesState)); +}; + +describe('CommandBoard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the zero state with a link to calls when no board is open', () => { + setupStores({}); + + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('command-board-screen')).toBeTruthy(); + expect(getByText('command.empty_heading')).toBeTruthy(); + + fireEvent.press(getByTestId('command-go-to-calls')); + expect(mockPush).toHaveBeenCalledWith('/calls'); + + unmount(); + }); + + it('renders the board sections for the active board', () => { + setupStores({ + boards: { '101': serverBoard('101') }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Structure Fire', Address: '123 Main St', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, unmount } = render(); + + expect(getByTestId('command-active-call')).toBeTruthy(); + expect(getByTestId('command-roles-section')).toBeTruthy(); + expect(getByTestId('command-resources-section')).toBeTruthy(); + expect(getByTestId('command-accountability-section')).toBeTruthy(); + + unmount(); + }); + + it('shows a board switcher when multiple commands are open and switches on tap', () => { + setupStores({ + boards: { '101': serverBoard('101'), '102': serverBoard('102') }, + activeCallId: '101', + calls: [ + { CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }, + { CallId: '102', Number: '26-15', Name: 'MVA', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }, + ], + }); + + const { getByTestId, unmount } = render(); + + expect(getByTestId('command-board-switcher')).toBeTruthy(); + fireEvent.press(getByTestId('command-board-tab-102')); + expect(mockSwitchCommand).toHaveBeenCalledWith('102'); + + unmount(); + }); + + it('renders server roles, ad-hoc units, and PAR accountability rows', () => { + const board = serverBoard('101', { + Roles: [{ IncidentRoleAssignmentId: 'ra-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, UserId: 'u-9', RoleType: 7, AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z' }], + Accountability: [{ UserId: 'u-2', FullName: 'Crew A Leader', LastCheckIn: '2026-07-19T10:30:00Z', NeedsCheckIn: false, MinutesRemaining: 12, Status: 'Green', DurationMinutes: 20, WarningThresholdMinutes: 5 }], + }) as any; + board.adHocUnits = [{ IncidentAdHocUnitId: 'ah-1', DepartmentId: 1, CallId: 101, Name: 'Mutual Aid Engine', Type: 'Engine', CreatedByUserId: 'u1', CreatedOn: '2026-07-19T10:00:00Z' }]; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones' }], + }); + + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('Mutual Aid Engine')).toBeTruthy(); + expect(getByText('Crew A Leader')).toBeTruthy(); + expect(getByText('command.par_green')).toBeTruthy(); + + fireEvent.press(getByTestId('assignment-remove-ra-1')); + expect(mockRemoveRole).toHaveBeenCalledWith('101', 'ra-1'); + + unmount(); + }); + + it('lists tracked department units and personnel as resources with their lane and releases on remove', () => { + const board = serverBoard('101', { + Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }], + Assignments: [ + // Department unit assigned to a lane + { ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }, + // Department person in the unassigned pool (no lane) + { ResourceAssignmentId: 'as-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: '', ResourceKind: 1, ResourceId: 'u-9', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }, + ], + }) as any; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }], + }); + + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('resource-dept-as-1')).toBeTruthy(); + expect(getByTestId('resource-dept-as-2')).toBeTruthy(); + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('Station 1')).toBeTruthy(); + expect(getByTestId('resource-dept-as-2-status')).toBeTruthy(); + expect(getByText('command.unassigned')).toBeTruthy(); + + fireEvent.press(getByTestId('resource-dept-remove-as-1')); + expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-1'); + + unmount(); + }); + + it('prompts to move a resource already assigned to another lane and moves on confirm', async () => { + const board = serverBoard('101', { + Nodes: [ + { CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }, + { CommandStructureNodeId: 'lane-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 2, Name: 'Medical', SortOrder: 1 }, + ], + Assignments: [ + { ResourceAssignmentId: 'as-9', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 4, ResourceId: 'ah-1', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }, + ], + }) as any; + board.adHocUnits = [{ IncidentAdHocUnitId: 'ah-1', DepartmentId: 1, CallId: 101, Name: 'Mutual Aid Engine', Type: 'Engine', CreatedByUserId: 'u1', CreatedOn: '2026-07-19T10:00:00Z' }]; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, queryByTestId, unmount } = render(); + + // Open the assign sheet for the OTHER lane and pick the already-assigned ad-hoc unit + fireEvent.press(getByTestId('lane-assign-lane-2')); + await waitFor(() => expect(getByTestId('resource-kind-tab-2')).toBeTruthy()); + fireEvent.press(getByTestId('resource-kind-tab-2')); + fireEvent.press(getByTestId('resource-option-4-ah-1')); + fireEvent.press(getByTestId('resource-assign-save')); + + // Conflict dialog appears; cancel does nothing + await waitFor(() => expect(getByTestId('move-conflict-dialog')).toBeTruthy()); + fireEvent.press(getByTestId('move-conflict-cancel')); + expect(mockMoveResourceAssignment).not.toHaveBeenCalled(); + + // Repeat and confirm — the existing assignment is moved, not duplicated + fireEvent.press(getByTestId('lane-assign-lane-2')); + await waitFor(() => expect(getByTestId('resource-kind-tab-2')).toBeTruthy()); + fireEvent.press(getByTestId('resource-kind-tab-2')); + fireEvent.press(getByTestId('resource-option-4-ah-1')); + fireEvent.press(getByTestId('resource-assign-save')); + await waitFor(() => expect(getByTestId('move-conflict-dialog')).toBeTruthy()); + fireEvent.press(getByTestId('move-conflict-confirm')); + + await waitFor(() => expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'as-9', 'lane-2')); + expect(queryByTestId('move-conflict-dialog')).toBeNull(); + + unmount(); + }); + + it('ends the active command and supports manual refresh', () => { + setupStores({ + boards: { '101': serverBoard('101') }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('command-end-command')); + expect(mockEndCommand).toHaveBeenCalledWith('101'); + + fireEvent.press(getByTestId('command-refresh')); + expect(mockRefreshBoard).toHaveBeenCalledWith('101'); + expect(mockRefreshAccountability).toHaveBeenCalledWith('101'); + + unmount(); + }); + + it('shows the provisional badge for a board established offline', () => { + const board = serverBoard('101') as any; + board.isProvisional = true; + board.board = null; + + setupStores({ + boards: { '101': board }, + activeCallId: '101', + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, unmount } = render(); + + expect(getByTestId('command-provisional-badge')).toBeTruthy(); + + unmount(); + }); + + it('keeps the vertical layout on a phone in landscape', () => { + const landscapePhone = { fontScale: 1, height: 390, scale: 1, width: 844 }; + act(() => Dimensions.set({ screen: landscapePhone, window: landscapePhone })); + setupStores({ + activeCallId: '101', + boards: { '101': serverBoard('101') }, + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + }); + + const { getByTestId, queryByTestId, unmount } = render(); + + expect(getByTestId('command-structure-section')).toBeTruthy(); + expect(queryByTestId('command-landscape-structure-board')).toBeNull(); + + unmount(); + const portrait = { fontScale: 1, height: 844, scale: 1, width: 390 }; + act(() => Dimensions.set({ screen: portrait, window: portrait })); + }); + + it('uses horizontal swimlanes on landscape tablets and moves a selected resource on lane tap', async () => { + const landscape = { fontScale: 1, height: 800, scale: 1, width: 1200 }; + act(() => Dimensions.set({ screen: landscape, window: landscape })); + const board = serverBoard('101', { + Assignments: [ + { + ResourceAssignmentId: 'resource-assignment-1', + IncidentCommandId: 'cmd-101', + DepartmentId: 1, + CallId: 101, + CommandStructureNodeId: 'lane-1', + ResourceKind: 1, + ResourceId: 'u-9', + AssignedByUserId: 'u1', + AssignedOn: '2026-07-19T10:00:00Z', + RequirementsWarning: false, + }, + ], + Nodes: [ + { CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }, + { CommandStructureNodeId: 'lane-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 6, Name: 'Staging', SortOrder: 1 }, + ], + }); + setupStores({ + activeCallId: '101', + boards: { '101': board }, + calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }], + users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones' }], + }); + + const { getByTestId, unmount } = render(); + + expect(getByTestId('command-landscape-structure-board')).toBeTruthy(); + expect(getByTestId('command-landscape-lanes')).toBeTruthy(); + const resourceCard = getByTestId('landscape-resource-resource-assignment-1'); + fireEvent(resourceCard, 'longPress'); + expect(getByTestId('landscape-drop-target-lane-2')).toBeTruthy(); + fireEvent(resourceCard, 'pressOut'); + await waitFor(() => expect(resourceCard.props.className).not.toContain('bg-primary-50')); + fireEvent.press(resourceCard); + fireEvent.press(getByTestId('landscape-lane-lane-2')); + + await waitFor(() => expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'resource-assignment-1', 'lane-2')); + + unmount(); + const portrait = { fontScale: 1, height: 844, scale: 1, width: 390 }; + act(() => Dimensions.set({ screen: portrait, window: portrait })); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index c1e7f00..dd3c7a4 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -3,13 +3,14 @@ import { NovuProvider } from '@novu/react-native'; import Countly from 'countly-sdk-react-native-bridge'; import * as NavigationBar from 'expo-navigation-bar'; -import { Redirect, SplashScreen, Tabs } from 'expo-router'; -import { CloudAlert, LayoutDashboard, Map, Megaphone, Menu, Settings } from 'lucide-react-native'; +import { Redirect, router, SplashScreen, Tabs } from 'expo-router'; +import { ArrowLeft, ClipboardList, LayoutDashboard, CloudAlert, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { OfflineStatusBar } from '@/components/common/offline-status-bar'; import { NotificationButton } from '@/components/notifications/NotificationButton'; import { NotificationInbox } from '@/components/notifications/NotificationInbox'; import Sidebar from '@/components/sidebar/sidebar'; @@ -31,6 +32,7 @@ import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; +import { useCommandStore } from '@/stores/command/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; @@ -166,6 +168,12 @@ export default function TabLayout() { await useSignalRStore.getState().connectUpdateHub(); await useSignalRStore.getState().connectGeolocationHub(); + // Hydrate incident-command boards from the server Sync Bundle (best-effort; offline-safe) + useCommandStore + .getState() + .syncFromServer() + .catch(() => {}); + hasInitialized.current = true; // Initialize Bluetooth and Audio services (native-only) @@ -200,7 +208,13 @@ export default function TabLayout() { try { // Refresh data - await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles(), useWeatherAlertsStore.getState().fetchActiveAlerts()]); + await Promise.all([ + useCoreStore.getState().fetchConfig(), + useCallsStore.getState().fetchCalls(), + useRolesStore.getState().fetchRoles(), + useWeatherAlertsStore.getState().fetchActiveAlerts(), + useCommandStore.getState().syncFromServer(), + ]); } catch (error) { logger.warn({ message: 'Failed to refresh data on app resume', @@ -270,20 +284,12 @@ export default function TabLayout() { } }, [isActive, appState, refreshDataFromBackground, initializeApp]); - // Force drawer open in landscape (guard with functional update to avoid unnecessary re-render) - useEffect(() => { - if (isLandscape) { - setIsOpen((prev) => (prev ? prev : true)); - } - }, [isLandscape]); - // Get user ID and config for notifications const config = useCoreStore((state) => state.config); - const activeUnitId = useCoreStore((state) => state.activeUnitId); const rights = securityStore((state) => state.rights); // Compute Novu readiness once for consistent gating across the render - const novuReady = !!(activeUnitId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode); + const novuReady = !!(userId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode); // Cache the last known-good Novu config so NovuProvider stays mounted stably // even if a transient state update briefly nullifies one of the values. const lastNovuConfig = useRef<{ @@ -294,7 +300,7 @@ export default function TabLayout() { } | null>(null); if (novuReady) { lastNovuConfig.current = { - subscriberId: `${rights?.DepartmentCode}_Unit_${activeUnitId}`, + subscriberId: `${rights?.DepartmentCode}_User_${userId}`, applicationIdentifier: config!.NovuApplicationId, backendUrl: config!.NovuBackendApiUrl, socketUrl: config!.NovuSocketUrl, @@ -342,14 +348,17 @@ export default function TabLayout() { const mapIcon = useCallback(({ color }: { color: string }) => , []); const callsIcon = useCallback(({ color }: { color: string }) => , []); const incidentsIcon = useCallback(({ color }: { color: string }) => , []); + const commandIcon = useCallback(({ color }: { color: string }) => , []); + const routesIcon = useCallback(({ color }: { color: string }) => , []); const weatherAlertsIcon = useCallback(({ color }: { color: string }) => , []); const settingsIcon = useCallback(({ color }: { color: string }) => , []); // Memoize header left/right renders - const headerLeftMap = useCallback(() => , [isLandscape]); + const headerLeftMap = useCallback(() => , []); + const headerLeftBack = useCallback(() => , []); const headerRightNotification = useCallback( - () => , - [config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode] + () => , + [config, handleOpenNotifications, userId, rights?.DepartmentCode] ); // Memoize per-screen options to prevent new objects every render @@ -370,20 +379,34 @@ export default function TabLayout() { headerShown: true as const, tabBarIcon: callsIcon, tabBarButtonTestID: 'calls-tab' as const, + headerLeft: headerLeftMap, + headerRight: headerRightNotification, + }), + [t, callsIcon, headerLeftMap, headerRightNotification] + ); + + const commandOptions = useMemo( + () => ({ + title: t('tabs.command_board'), + headerShown: true as const, + tabBarIcon: commandIcon, + tabBarButtonTestID: 'command-tab' as const, + headerLeft: headerLeftMap, headerRight: headerRightNotification, }), - [t, callsIcon, headerRightNotification] + [t, commandIcon, headerLeftMap, headerRightNotification] ); const incidentsOptions = useMemo( () => ({ title: t('tabs.incidents'), headerShown: true as const, - tabBarIcon: incidentsIcon, - tabBarButtonTestID: 'incidents-tab' as const, + tabBarIcon: routesIcon, + tabBarButtonTestID: 'routes-tab' as const, + headerLeft: headerLeftMap, headerRight: headerRightNotification, }), - [t, incidentsIcon, headerRightNotification] + [t, routesIcon, headerLeftMap, headerRightNotification] ); // weather-alerts is kept (relevant to IC scene safety) but HIDDEN from the tab bar (href: null); @@ -395,19 +418,37 @@ export default function TabLayout() { headerShown: true as const, tabBarIcon: weatherAlertsIcon, tabBarButtonTestID: 'weather-alerts-tab' as const, + headerLeft: headerLeftMap, + headerRight: headerRightNotification, + }), + [t, weatherAlertsIcon, headerLeftMap, headerRightNotification] + ); + + // POI detail renders inside the tab shell (app header + tab bar stay visible) but has no tab + // bar entry of its own; the header shows a back button instead of the drawer menu. + const poiDetailOptions = useMemo( + () => ({ + href: null, + title: t('routes.poi_detail'), + headerShown: true as const, + headerLeft: headerLeftBack, headerRight: headerRightNotification, }), - [t, weatherAlertsIcon, headerRightNotification] + [t, headerLeftBack, headerRightNotification] ); + // settings stays routable (sidebar menu link) but is hidden from the tab bar. const settingsOptions = useMemo( () => ({ + href: null, title: t('tabs.settings'), headerShown: true as const, tabBarIcon: settingsIcon, tabBarButtonTestID: 'settings-tab' as const, + headerLeft: headerLeftMap, + headerRight: headerRightNotification, }), - [t, settingsIcon] + [t, settingsIcon, headerLeftMap, headerRightNotification] ); if (isFirstTime) { @@ -431,38 +472,42 @@ export default function TabLayout() { ) : null} - {/* Drawer and sidebar only render after init to avoid heavy re-renders during store settling */} + {/* Drawer renders after init to avoid heavy re-renders during store settling. + The menu is always a hamburger-toggled drawer — no persistent panel on tablets. */} {isInitComplete ? ( - isLandscape ? ( - - - - ) : ( - - - - - - - - - ) + + + + + + + + ) : null} {/* Main content area */} - + + {/* Offline / pending-sync status strip */} + {isInitComplete ? : null} - + + + {/* routes, weather-alerts, and settings are registered so their route files resolve, but hidden + from the tab bar (href: null in their options) — reachable via the sidebar menu. + IC shell shows: Map, Calls, Command Board. */} + {/* weather-alerts is registered so its route file resolves, but hidden from the tab bar (href: null). IC shell shows: Map, Calls, Settings. */} + + {/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */} @@ -493,17 +538,13 @@ export default function TabLayout() { interface CreateDrawerMenuButtonProps { setIsOpen: (isOpen: boolean) => void; - isLandscape: boolean; } -const CreateDrawerMenuButton = ({ setIsOpen, isLandscape }: CreateDrawerMenuButtonProps) => { - if (isLandscape) { - return null; - } - +const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { return ( { setIsOpen(true); }} @@ -513,18 +554,36 @@ const CreateDrawerMenuButton = ({ setIsOpen, isLandscape }: CreateDrawerMenuButt ); }; +const CreateHeaderBackButton = () => { + return ( + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/'); + } + }} + > + + + ); +}; + const CreateNotificationButton = ({ config, setIsNotificationsOpen, - activeUnitId, + userId, departmentCode, }: { config: GetConfigResultData | null; setIsNotificationsOpen: () => void; - activeUnitId: string | null; + userId: string | null; departmentCode: string | undefined; }) => { - if (!activeUnitId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl || !departmentCode) { + if (!userId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl || !departmentCode) { return null; } diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index d9acaa3..cc0d62e 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, View } from 'react-native'; import { CallCard } from '@/components/calls/call-card'; +import { StartCommandSheet } from '@/components/command/start-command-sheet'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { Box } from '@/components/ui/box'; @@ -14,9 +15,12 @@ import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { useAnalytics } from '@/hooks/use-analytics'; +import { logger } from '@/lib/logging'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { useCallsStore } from '@/stores/calls/store'; +import { useCommandStore } from '@/stores/command/store'; import { securityStore } from '@/stores/security/store'; +import { useToastStore } from '@/stores/toast/store'; export default function Calls() { const calls = useCallsStore((state) => state.calls); @@ -27,9 +31,43 @@ export default function Calls() { const fetchCallDispatches = useCallsStore((state) => state.fetchCallDispatches); const callDispatches = useCallsStore((state) => state.callDispatches); const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls); + const commandBoards = useCommandStore((state) => state.boards); + const activeBoardCallId = useCommandStore((state) => state.activeCallId); + const showToast = useToastStore((state) => state.showToast); const { t } = useTranslation(); const { trackEvent } = useAnalytics(); const [searchQuery, setSearchQuery] = useState(''); + const [templatePickerCall, setTemplatePickerCall] = useState(null); + + const startCommandForCall = useCallback( + async (call: CallResultData, commandDefinitionId: number | null) => { + try { + await useCommandStore.getState().startCommand(call.CallId, commandDefinitionId); + showToast('success', t('command.start_success')); + router.push('/command'); + } catch (error) { + logger.error({ + message: 'Failed to start command for call', + context: { error, callId: call.CallId }, + }); + showToast('error', t('command.start_error')); + } + }, + [showToast, t] + ); + + // Opens an existing board directly; for a new command, offer the department's + // command templates (ICS structures, event/security staffing plans, ...) first. + const handleStartCommand = useCallback( + (call: CallResultData) => { + if (useCommandStore.getState().boards[call.CallId]) { + startCommandForCall(call, null); + return; + } + setTemplatePickerCall(call); + }, + [startCommandForCall] + ); // Fetch data when screen comes into focus useFocusEffect( @@ -78,7 +116,9 @@ export default function Calls() { return ; } - if (error) { + // Only surface a fetch error when there is no cached data to show — + // offline the list keeps rendering the last synced calls. + if (error && filteredCalls.length === 0) { return ; } @@ -88,7 +128,14 @@ export default function Calls() { data={filteredCalls} renderItem={({ item }: { item: CallResultData }) => ( router.push(`/call/${item.CallId}`)}> - p.Id === item.Priority)} dispatches={callDispatches[item.CallId]} /> + p.Id === item.Priority)} + dispatches={callDispatches[item.CallId]} + isCommandCall={item.CallId === activeBoardCallId} + hasCommand={!!commandBoards[item.CallId]} + onStartCommand={() => handleStartCommand(item)} + /> )} keyExtractor={(item: CallResultData) => item.CallId} @@ -119,6 +166,13 @@ export default function Calls() { {/* Main content */} {renderContent()} + {/* Command template picker for starting a new board */} + setTemplatePickerCall(null)} + onStart={(commandDefinitionId) => (templatePickerCall ? startCommandForCall(templatePickerCall, commandDefinitionId) : undefined)} + /> + {/* FAB button for creating new call - only show if user has permission */} {canUserCreateCalls ? ( diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx new file mode 100644 index 0000000..02e24bc --- /dev/null +++ b/src/app/(app)/command.tsx @@ -0,0 +1,693 @@ +import { router } from 'expo-router'; +import { ClipboardList, CloudOff, ExternalLink, MapPin, RefreshCw, Trash2, UserCog, XCircle } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, useWindowDimensions } from 'react-native'; + +import { AddAssignmentSheet } from '@/components/command/add-assignment-sheet'; +import { AddLaneSheet } from '@/components/command/add-lane-sheet'; +import { AddResourceSheet } from '@/components/command/add-resource-sheet'; +import { type AssignableResourceOption, AssignResourceSheet } from '@/components/command/assign-resource-sheet'; +import { CommandSection } from '@/components/command/command-section'; +import { LandscapeStructureBoard } from '@/components/command/landscape-structure-board'; +import { ObjectivesSection } from '@/components/command/objectives-section'; +import { PersonnelResourceCard, UnitResourceCard } from '@/components/command/resource-cards'; +import { StructureSection } from '@/components/command/structure-section'; +import { SceneClock, TimelineSection } from '@/components/command/timeline-section'; +import { TimersSection } from '@/components/command/timers-section'; +import { TransferCommandSheet } from '@/components/command/transfer-command-sheet'; +import { VoiceSection } from '@/components/command/voice-section'; +import ZeroState from '@/components/common/zero-state'; +import { View } from '@/components/ui'; +import { AlertDialog, AlertDialogBackdrop, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader } from '@/components/ui/alert-dialog'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getIncidentRoleName, getParBadgeAction } from '@/lib/incident-command-utils'; +import { isWeb } from '@/lib/platform'; +import { getTimeAgoUtc } from '@/lib/utils'; +import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { type AssignmentOutcome } from '@/stores/command/store'; +import { useCommandStore } from '@/stores/command/store'; +import { useRolesStore } from '@/stores/roles/store'; +import { useToastStore } from '@/stores/toast/store'; +import { useUnitsStore } from '@/stores/units/store'; + +/** One-line truncation: numberOfLines leaks to the DOM through the styling pipeline on web, so use CSS ellipsis there. */ +const oneLine = isWeb ? ({ isTruncated: true } as const) : ({ numberOfLines: 1 } as const); + +export default function CommandBoard() { + const { t } = useTranslation(); + const { height: viewportHeight, width: viewportWidth } = useWindowDimensions(); + const boards = useCommandStore((state) => state.boards); + const activeBoardCallId = useCommandStore((state) => state.activeCallId); + const switchCommand = useCommandStore((state) => state.switchCommand); + const endCommand = useCommandStore((state) => state.endCommand); + const refreshBoard = useCommandStore((state) => state.refreshBoard); + const isRefreshing = useCommandStore((state) => state.isRefreshing); + const assignRole = useCommandStore((state) => state.assignRole); + const removeRole = useCommandStore((state) => state.removeRole); + const addAdHocUnit = useCommandStore((state) => state.addAdHocUnit); + const releaseAdHocUnitEntry = useCommandStore((state) => state.releaseAdHocUnitEntry); + const addAdHocPersonnel = useCommandStore((state) => state.addAdHocPersonnel); + const releaseAdHocPersonnelEntry = useCommandStore((state) => state.releaseAdHocPersonnelEntry); + const refreshAccountability = useCommandStore((state) => state.refreshAccountability); + const addNode = useCommandStore((state) => state.addNode); + const deleteNode = useCommandStore((state) => state.deleteNode); + const assignResourceToNode = useCommandStore((state) => state.assignResourceToNode); + const moveResourceAssignment = useCommandStore((state) => state.moveResourceAssignment); + const releaseResourceAssignment = useCommandStore((state) => state.releaseResourceAssignment); + const addObjective = useCommandStore((state) => state.addObjective); + const completeObjectiveEntry = useCommandStore((state) => state.completeObjectiveEntry); + const startTimer = useCommandStore((state) => state.startTimer); + const acknowledgeTimer = useCommandStore((state) => state.acknowledgeTimer); + const transferIncidentCommand = useCommandStore((state) => state.transferIncidentCommand); + const fetchTimeline = useCommandStore((state) => state.fetchTimeline); + const createVoiceChannel = useCommandStore((state) => state.createVoiceChannel); + const fetchVoiceChannels = useCommandStore((state) => state.fetchVoiceChannels); + const closeVoiceChannels = useCommandStore((state) => state.closeVoiceChannels); + const fetchTransmissionLog = useCommandStore((state) => state.fetchTransmissionLog); + const recordTransmission = useCommandStore((state) => state.recordTransmission); + + const activeCall = useCoreStore((state) => state.activeCall); + const activePriority = useCoreStore((state) => state.activePriority); + const calls = useCallsStore((state) => state.calls); + const users = useRolesStore((state) => state.users); + const fetchUsers = useRolesStore((state) => state.fetchUsers); + const unitRoles = useRolesStore((state) => state.roles); + const units = useUnitsStore((state) => state.units); + const unitCurrentStatuses = useUnitsStore((state) => state.unitCurrentStatuses); + const fetchUnits = useUnitsStore((state) => state.fetchUnits); + const showToast = useToastStore((state) => state.showToast); + + // Advisory requirement violations warn; forced ones were rejected server-side and error. + const notifyAssignmentOutcome = useCallback( + (outcome: AssignmentOutcome | null) => { + if (outcome?.blocked) { + showToast('error', outcome.blocked); + } else if (outcome?.warning) { + showToast('warning', outcome.warning); + } + }, + [showToast] + ); + + const [isAssignmentSheetOpen, setIsAssignmentSheetOpen] = useState(false); + const [isResourceSheetOpen, setIsResourceSheetOpen] = useState(false); + const [isLaneSheetOpen, setIsLaneSheetOpen] = useState(false); + const [assignTargetNodeId, setAssignTargetNodeId] = useState(null); + /** Pending "already assigned elsewhere — move it?" confirmation. */ + const [moveConflict, setMoveConflict] = useState<{ assignmentId: string; resourceName: string; fromLane: string; toLane: string; targetNodeId: string } | null>(null); + const [isTransferSheetOpen, setIsTransferSheetOpen] = useState(false); + + const boardList = useMemo(() => Object.values(boards), [boards]); + const boardState = activeBoardCallId ? boards[activeBoardCallId] : undefined; + const isLandscapeBoard = viewportWidth > viewportHeight && Math.min(viewportWidth, viewportHeight) >= 600; + + // Unit and personnel rosters back the resource pool — load once when a board is open + useEffect(() => { + if (boardState && units.length === 0) { + fetchUnits(); + } + }, [boardState, units.length, fetchUnits]); + + useEffect(() => { + if (boardState && users.length === 0) { + fetchUsers(); + } + }, [boardState, users.length, fetchUsers]); + + // Incident log + voice channels load alongside the board + const boardCallId = boardState?.callId; + useEffect(() => { + if (boardCallId) { + fetchTimeline(boardCallId); + fetchVoiceChannels(boardCallId); + fetchTransmissionLog(boardCallId); + } + }, [boardCallId, fetchTimeline, fetchVoiceChannels, fetchTransmissionLog]); + + const personName = useCallback( + (userId: string) => { + const user = users.find((u) => u.UserId === userId); + return user ? `${user.FirstName} ${user.LastName}` : userId; + }, + [users] + ); + + const boardLabel = useCallback( + (callId: string) => { + const call = calls.find((c) => c.CallId === callId); + return call ? `#${call.Number}` : `#${callId}`; + }, + [calls] + ); + + const handleViewCall = useCallback(() => { + if (activeBoardCallId) { + router.push(`/call/${activeBoardCallId}`); + } + }, [activeBoardCallId]); + + const handleEndCommand = useCallback(() => { + if (activeBoardCallId) { + endCommand(activeBoardCallId); + } + }, [activeBoardCallId, endCommand]); + + const handleRefresh = useCallback(() => { + if (activeBoardCallId) { + refreshBoard(activeBoardCallId); + refreshAccountability(activeBoardCallId); + fetchTimeline(activeBoardCallId); + fetchVoiceChannels(activeBoardCallId); + fetchTransmissionLog(activeBoardCallId); + } + }, [activeBoardCallId, refreshBoard, refreshAccountability, fetchTimeline, fetchVoiceChannels, fetchTransmissionLog]); + + const handleTransferCommand = useCallback( + async (toUserId: string) => { + if (!activeBoardCallId) { + return; + } + const ok = await transferIncidentCommand(activeBoardCallId, toUserId); + showToast(ok ? 'success' : 'error', ok ? t('command.transfer_success') : t('command.transfer_error')); + }, + [activeBoardCallId, transferIncidentCommand, showToast, t] + ); + + const handleGoToCalls = useCallback(() => { + router.push('/calls'); + }, []); + + // Resolve a resource assignment (kind + id) to a display name using the loaded rosters + const resolveResourceName = useCallback( + (kind: number, resourceId: string) => { + if (kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit) { + return units.find((u) => u.UnitId === resourceId)?.Name ?? resourceId; + } + if (kind === ResourceAssignmentKind.RealPersonnel || kind === ResourceAssignmentKind.LinkedDeptPersonnel) { + const user = users.find((u) => u.UserId === resourceId); + return user ? `${user.FirstName} ${user.LastName}` : resourceId; + } + const boardEntry = activeBoardCallId ? boards[activeBoardCallId] : undefined; + if (kind === ResourceAssignmentKind.AdHocPersonnel) { + return boardEntry?.adHocPersonnel.find((person) => person.IncidentAdHocPersonnelId === resourceId)?.Name ?? resourceId; + } + return boardEntry?.adHocUnits.find((u) => u.IncidentAdHocUnitId === resourceId)?.Name ?? resourceId; + }, + [units, users, boards, activeBoardCallId] + ); + + const resourceOptions = useMemo(() => { + const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined; + const activeAssignments = (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn); + const assignedNodeOf = (kind: ResourceAssignmentKind, id: string) => activeAssignments.find((a) => a.ResourceKind === kind && a.ResourceId === id)?.CommandStructureNodeId; + + const unitOptions = units.map((u) => ({ + kind: ResourceAssignmentKind.RealUnit, + id: u.UnitId, + name: u.Name, + detail: [u.Type, u.GroupName].filter(Boolean).join(' • '), + statusLabel: unitCurrentStatuses.find((s) => s.UnitId === u.UnitId)?.State, + assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealUnit, u.UnitId), + })); + const personnelOptions = users.map((u) => ({ + kind: ResourceAssignmentKind.RealPersonnel, + id: u.UserId, + name: `${u.FirstName} ${u.LastName}`, + detail: [u.GroupName, u.Status].filter(Boolean).join(' • '), + chips: u.Roles ?? [], + assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealPersonnel, u.UserId), + })); + const adHocOptions = (entry?.adHocUnits ?? []).map((u) => ({ + kind: ResourceAssignmentKind.AdHocUnit, + id: u.IncidentAdHocUnitId, + name: u.Name, + detail: [u.Type, u.ExternalAgencyName].filter(Boolean).join(' • '), + assignedNodeId: assignedNodeOf(ResourceAssignmentKind.AdHocUnit, u.IncidentAdHocUnitId), + })); + const adHocPersonnelOptions = (entry?.adHocPersonnel ?? []).map((person) => ({ + kind: ResourceAssignmentKind.AdHocPersonnel, + id: person.IncidentAdHocPersonnelId, + name: person.Name, + detail: [person.Role, person.ExternalAgencyName].filter(Boolean).join(' • '), + assignedNodeId: assignedNodeOf(ResourceAssignmentKind.AdHocPersonnel, person.IncidentAdHocPersonnelId), + })); + return [...unitOptions, ...personnelOptions, ...adHocOptions, ...adHocPersonnelOptions]; + }, [units, users, unitCurrentStatuses, boards, activeBoardCallId]); + + // Department units/personnel tracked on this incident — every active assignment pointing at a + // Resgrid resource, whether it sits in a lane or in the unassigned pool (empty node id). + const deptAssignments = useMemo(() => { + const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined; + const unitKinds = [ResourceAssignmentKind.RealUnit, ResourceAssignmentKind.LinkedDeptUnit, ResourceAssignmentKind.RealPersonnel, ResourceAssignmentKind.LinkedDeptPersonnel]; + return (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn && unitKinds.includes(a.ResourceKind)); + }, [boards, activeBoardCallId]); + + const isUnitKind = useCallback((kind: number) => kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit, []); + + const trackedUnitIds = useMemo(() => deptAssignments.filter((a) => isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]); + const trackedUserIds = useMemo(() => deptAssignments.filter((a) => !isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]); + + const laneName = useCallback( + (nodeId?: string | null) => { + if (!nodeId) { + return t('command.unassigned'); + } + const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined; + return entry?.board?.Nodes.find((n) => n.CommandStructureNodeId === nodeId)?.Name ?? t('command.unassigned'); + }, + [boards, activeBoardCallId, t] + ); + + // Track a department resource on the incident: an assignment with no lane (the unassigned pool) + const handleAddDeptUnit = useCallback( + async (unitId: string) => { + if (!activeBoardCallId) { + return; + } + const outcome = await assignResourceToNode(activeBoardCallId, '', ResourceAssignmentKind.RealUnit, unitId); + notifyAssignmentOutcome(outcome); + }, + [activeBoardCallId, assignResourceToNode, showToast] + ); + + const handleAddDeptPersonnel = useCallback( + async (userId: string) => { + if (!activeBoardCallId) { + return; + } + const outcome = await assignResourceToNode(activeBoardCallId, '', ResourceAssignmentKind.RealPersonnel, userId); + notifyAssignmentOutcome(outcome); + }, + [activeBoardCallId, assignResourceToNode, showToast] + ); + + const handleAssignResourceSave = useCallback( + async (kind: ResourceAssignmentKind, resourceId: string) => { + if (!activeBoardCallId || !assignTargetNodeId) { + return; + } + const targetNodeId = assignTargetNodeId; + setAssignTargetNodeId(null); + + const entry = boards[activeBoardCallId]; + const existing = (entry?.board?.Assignments ?? []).find((a) => !a.ReleasedOn && a.ResourceKind === kind && a.ResourceId === resourceId); + + if (existing) { + // Already in this lane — the picker disables these rows; nothing to do. + if (existing.CommandStructureNodeId === targetNodeId) { + return; + } + // Sitting in the unassigned pool — moving it into a lane needs no confirmation. + if (!existing.CommandStructureNodeId) { + const outcome = await moveResourceAssignment(activeBoardCallId, existing.ResourceAssignmentId, targetNodeId); + notifyAssignmentOutcome(outcome); + return; + } + // In another lane — ask before moving. + setMoveConflict({ + assignmentId: existing.ResourceAssignmentId, + resourceName: resolveResourceName(kind, resourceId), + fromLane: laneName(existing.CommandStructureNodeId), + toLane: laneName(targetNodeId), + targetNodeId, + }); + return; + } + + const outcome = await assignResourceToNode(activeBoardCallId, targetNodeId, kind, resourceId); + notifyAssignmentOutcome(outcome); + }, + [activeBoardCallId, assignTargetNodeId, boards, assignResourceToNode, moveResourceAssignment, resolveResourceName, laneName, showToast] + ); + + const handleConfirmMove = useCallback(async () => { + if (!moveConflict || !activeBoardCallId) { + setMoveConflict(null); + return; + } + const { assignmentId, targetNodeId } = moveConflict; + setMoveConflict(null); + const outcome = await moveResourceAssignment(activeBoardCallId, assignmentId, targetNodeId); + notifyAssignmentOutcome(outcome); + }, [moveConflict, activeBoardCallId, moveResourceAssignment, showToast]); + + const handleMoveResource = useCallback( + async (assignmentId: string, targetNodeId: string) => { + if (!activeBoardCallId) { + return; + } + const outcome = await moveResourceAssignment(activeBoardCallId, assignmentId, targetNodeId); + notifyAssignmentOutcome(outcome); + }, + [activeBoardCallId, moveResourceAssignment, showToast] + ); + + if (!boardState) { + return ( + + + + + + + ); + } + + const activeRoles = (boardState.board?.Roles ?? []).filter((r) => !r.RemovedOn); + const accountability = boardState.board?.Accountability ?? []; + const summaryCall = activeCall?.CallId === boardState.callId ? activeCall : (calls.find((c) => c.CallId === boardState.callId) ?? null); + const summaryPriority = activeCall?.CallId === boardState.callId ? activePriority : null; + + return ( + + + + + {/* Board switcher — the IC may be running several incidents at once */} + {boardList.length > 1 ? ( + + + {boardList.map((b) => ( + switchCommand(b.callId)} + > + {boardLabel(b.callId)} + + ))} + + + ) : null} + + {/* Active call summary — compact one-liner so command info gets the screen */} + + + + {boardLabel(boardState.callId)} + {summaryCall ? ( + + {summaryCall.Name} + + ) : null} + + + {boardState.isProvisional ? ( + + {t('command.provisional_badge')} + + ) : null} + {summaryPriority ? ( + + {summaryPriority.Name} + + ) : null} + {/* Master scene timer — elapsed time since the call was logged */} + + + + + {summaryCall ? ( + + {summaryCall.Address ? ( + <> + + + {summaryCall.Address} + + + ) : ( + + )} + {boardState.board?.Command?.CurrentCommanderUserId ? ( + + {`${t('command.current_commander')}: ${personName(boardState.board.Command.CurrentCommanderUserId)}`} + + ) : null} + + ) : null} + + + + + + + + + + {/* Command structure lanes (Division/Group/Branch/...) with assigned resources */} + {isLandscapeBoard ? ( + setIsLaneSheetOpen(true)} + onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} + onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)} + onMoveResource={handleMoveResource} + onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)} + resolveResourceName={resolveResourceName} + viewportHeight={viewportHeight} + viewportWidth={viewportWidth} + /> + ) : ( + setIsLaneSheetOpen(true)} + onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} + onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)} + onMoveResource={handleMoveResource} + onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)} + resolveResourceName={resolveResourceName} + /> + )} + + {/* PAR / benchmark reminder timers with live countdowns */} + startTimer(boardState.callId, name, seconds)} onAcknowledge={(timerId) => acknowledgeTimer(boardState.callId, timerId)} /> + + {/* Tactical & command PTT channels + transmission log */} + createVoiceChannel(boardState.callId, name)} + onCloseChannels={() => closeVoiceChannels(boardState.callId)} + onTransmission={(channelId, startedOn, endedOn) => recordTransmission(boardState.callId, channelId, startedOn, endedOn)} + /> + + {/* Tactical objectives / benchmarks */} + addObjective(boardState.callId, name, type)} + onComplete={(objectiveId) => completeObjectiveEntry(boardState.callId, objectiveId)} + /> + + {/* ICS role assignments — synced with IncidentRoles API */} + setIsAssignmentSheetOpen(true)} + testID="command-roles-section" + > + {activeRoles.map((assignment) => ( + + + {getIncidentRoleName(t, assignment.RoleType)} + {personName(assignment.UserId)} + + + {assignment.IncidentRoleAssignmentId.startsWith('local-') ? : null} + removeRole(boardState.callId, assignment.IncidentRoleAssignmentId)} className="p-2" testID={`assignment-remove-${assignment.IncidentRoleAssignmentId}`}> + + + + + ))} + + + {/* Incident resources — department units/personnel plus external (ad-hoc) entries */} + setIsResourceSheetOpen(true)} + testID="command-resources-section" + > + {deptAssignments.map((assignment) => + isUnitKind(assignment.ResourceKind) ? ( + releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)} + removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`} + roles={unitRoles.filter((role) => role.UnitId === assignment.ResourceId)} + status={unitCurrentStatuses.find((s) => s.UnitId === assignment.ResourceId)} + testID={`resource-dept-${assignment.ResourceAssignmentId}`} + unit={units.find((u) => u.UnitId === assignment.ResourceId)} + /> + ) : ( + releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)} + person={users.find((u) => u.UserId === assignment.ResourceId)} + removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`} + testID={`resource-dept-${assignment.ResourceAssignmentId}`} + /> + ) + )} + {boardState.adHocPersonnel.map((person) => ( + + + {person.Name} + {[person.Role, person.ExternalAgencyName].filter(Boolean).join(' — ') || t('command.resource_person')} + + + {person.IncidentAdHocPersonnelId.startsWith('local-') ? : null} + releaseAdHocPersonnelEntry(boardState.callId, person.IncidentAdHocPersonnelId)} className="p-2" testID={`resource-person-remove-${person.IncidentAdHocPersonnelId}`}> + + + + + ))} + {boardState.adHocUnits.map((unit) => ( + + + {unit.Name} + {unit.Type ? {unit.Type} : null} + + + {unit.IncidentAdHocUnitId.startsWith('local-') ? : null} + releaseAdHocUnitEntry(boardState.callId, unit.IncidentAdHocUnitId)} className="p-2" testID={`resource-remove-${unit.IncidentAdHocUnitId}`}> + + + + + ))} + + + {/* Accountability / PAR — computed server-side from check-in timers (read-only) */} + + + + {t('command.accountability_section')} + ({accountability.length}) + + + + + {accountability.length === 0 ? ( + {t('command.no_accountability')} + ) : ( + + {accountability.map((entry) => ( + + + {entry.FullName} + {entry.LastCheckIn ? `${t('command.last_check_in')}: ${getTimeAgoUtc(entry.LastCheckIn)}` : t('command.no_check_in_yet')} + + + {entry.Status === 'Green' ? t('command.par_green') : entry.Status === 'Warning' ? t('command.par_warning') : t('command.par_critical')} + + + ))} + + )} + + + {/* Auto-logged, time-stamped incident log */} + fetchTimeline(boardState.callId)} /> + + + + setIsAssignmentSheetOpen(false)} onSave={(roleType, userId) => assignRole(boardState.callId, roleType, userId)} /> + setIsResourceSheetOpen(false)} + units={units} + personnel={users} + unitCurrentStatuses={unitCurrentStatuses} + trackedUnitIds={trackedUnitIds} + trackedUserIds={trackedUserIds} + onAddUnit={handleAddDeptUnit} + onAddPersonnel={handleAddDeptPersonnel} + onSaveExternal={(kind, name, detail, agency) => (kind === 'person' ? addAdHocPersonnel(boardState.callId, name, detail, agency) : addAdHocUnit(boardState.callId, name, detail))} + /> + setIsLaneSheetOpen(false)} onSave={(name, nodeType, color, limits) => addNode(boardState.callId, name, nodeType, color, limits)} /> + setIsTransferSheetOpen(false)} + personnel={users} + currentCommanderUserId={boardState.board?.Command?.CurrentCommanderUserId} + onTransfer={handleTransferCommand} + /> + setAssignTargetNodeId(null)} + options={resourceOptions} + resolveLaneName={laneName} + targetNodeId={assignTargetNodeId} + onSave={handleAssignResourceSave} + /> + + {/* "Already assigned to another lane" confirmation */} + setMoveConflict(null)}> + + + + {t('command.move_conflict_title')} + + + + {moveConflict ? t('command.move_conflict_message', { resource: moveConflict.resourceName, lane: moveConflict.fromLane, target: moveConflict.toLane }) : ''} + + + + + + + + + + ); +} diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 5ed65fb..b7e2f8c 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,5 +1,5 @@ import { router, Stack, useFocusEffect } from 'expo-router'; -import { NavigationIcon } from 'lucide-react-native'; +import { Layers as LayersIcon, NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { Loading } from '@/components/common/loading'; +import { MapLayersSheet } from '@/components/maps/map-layers-sheet'; import MapPins from '@/components/maps/map-pins'; import Mapbox from '@/components/maps/mapbox'; import PinDetailModal from '@/components/maps/pin-detail-modal'; @@ -15,6 +16,8 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { useCommandMapOverlay } from '@/hooks/use-command-map-overlay'; +import { useMapGeolocationUpdates } from '@/hooks/use-map-geolocation-updates'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; @@ -69,8 +72,11 @@ function MapContent() { setIsBannerDismissed(false); }, [extremeAlerts.length]); - // Active unit (used to gate custom map-layer loading) - const activeUnitId = useCoreStore((state) => state.activeUnitId); + // Route overlay state + const activeInstance = useRoutesStore((state) => state.activeInstance); + const instanceStops = useRoutesStore((state) => state.instanceStops); + const fetchStopsForInstance = useRoutesStore((state) => state.fetchStopsForInstance); + const [showRouteOverlay, setShowRouteOverlay] = useState(true); // Map layers state const activeLayers = useMapsStore((state) => state.activeLayers); @@ -88,6 +94,13 @@ function MapContent() { const pulseAnim = useRef(new Animated.Value(1)).current; useMapSignalRUpdates(setMapPins); + // Live unit/personnel position deltas from the GeolocationHub — moves markers in place + useMapGeolocationUpdates(setMapPins); + + // Lane label + color enrichment for resources on the active command board + const commandOverlay = useCommandMapOverlay(); + + const [isLayersSheetOpen, setIsLayersSheetOpen] = useState(false); // Stable initial camera settings so the native Camera renders at the // correct position from the very first frame (fixes Android/iOS centering). @@ -110,18 +123,23 @@ function MapContent() { }; }, [locationLatitude, locationLongitude, isMapLocked]); - // Fetch custom map-layer config when an active unit is set + // Fetch map layers (department-level, no unit context needed) + useEffect(() => { + fetchActiveLayers(); + }, [fetchActiveLayers]); + + // Fetch stops when active instance changes useEffect(() => { - if (activeUnitId) { - fetchActiveLayers(); + if (activeInstance?.RouteInstanceId) { + fetchStopsForInstance(activeInstance.RouteInstanceId); } - }, [activeUnitId, fetchActiveLayers]); + }, [activeInstance?.RouteInstanceId, fetchStopsForInstance]); // Fetch GeoJSON for enabled layers useEffect(() => { activeLayers.forEach((layer) => { - if (layerToggles[layer.LayerId] && !cachedGeoJSON[layer.LayerId]) { - fetchLayerGeoJSON(layer.LayerId); + if (layerToggles[layer.Id] && !cachedGeoJSON[layer.Id]) { + fetchLayerGeoJSON(layer.Id, layer.LayerSource); } }); }, [activeLayers, layerToggles, cachedGeoJSON, fetchLayerGeoJSON]); @@ -476,21 +494,21 @@ function MapContent() { ) : null} - + {/* Custom map layer overlays */} {activeLayers.map((layer) => - layerToggles[layer.LayerId] && cachedGeoJSON[layer.LayerId] ? ( - + layerToggles[layer.Id] && cachedGeoJSON[layer.Id] ? ( + ) : null} + {/* Layers / maps browser control */} + setIsLayersSheetOpen(true)} testID="layers-button"> + + + {/* Recenter Button - only show when map is not locked and user has moved the map */} {showRecenterButton ? ( @@ -518,6 +541,9 @@ function MapContent() { {/* Pin Detail Modal */} + + {/* Layer toggles + custom/indoor maps browser */} + setIsLayersSheetOpen(false)} /> ); } @@ -587,6 +613,18 @@ const styles = StyleSheet.create({ alignItems: 'center', // elevation and shadow properties are handled by themedStyles }, + layersButton: { + position: 'absolute', + bottom: 76, + right: 20, + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#3b82f6', + justifyContent: 'center', + alignItems: 'center', + // elevation and shadow properties are handled by themedStyles + }, // Web-only CSS pulse animation (replaces Animated.loop which falls back to JS driver on web) markerPulseWeb: { // No JS-driven transform on web — the outer ring animates via CSS instead diff --git a/src/app/(app)/poi/[id].tsx b/src/app/(app)/poi/[id].tsx new file mode 100644 index 0000000..0d5b009 --- /dev/null +++ b/src/app/(app)/poi/[id].tsx @@ -0,0 +1,136 @@ +import { useLocalSearchParams } from 'expo-router'; +import { MapPin, Navigation } from 'lucide-react-native'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import StaticMap from '@/components/maps/static-map'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { openMapsWithDirections } from '@/lib/navigation'; +import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils'; +import { useLocationStore } from '@/stores/app/location-store'; +import { usePoisStore } from '@/stores/pois/store'; +import { useToastStore } from '@/stores/toast/store'; + +export default function PoiDetailScreen() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const poiId = Array.isArray(id) ? id[0] : id; + const poiTypes = usePoisStore((state) => state.poiTypes); + const selectedPoi = usePoisStore((state) => state.selectedPoi); + const isLoadingDetail = usePoisStore((state) => state.isLoadingDetail); + const error = usePoisStore((state) => state.error); + const fetchPoi = usePoisStore((state) => state.fetchPoi); + const fetchPoiTypes = usePoisStore((state) => state.fetchPoiTypes); + const clearSelectedPoi = usePoisStore((state) => state.clearSelectedPoi); + const showToast = useToastStore((state) => state.showToast); + const userLatitude = useLocationStore((state) => state.latitude); + const userLongitude = useLocationStore((state) => state.longitude); + + useEffect(() => { + fetchPoiTypes(); + if (poiId) { + fetchPoi(poiId); + } + + return () => { + clearSelectedPoi(); + }; + }, [clearSelectedPoi, fetchPoi, fetchPoiTypes, poiId]); + + const poiTypesById = useMemo(() => createPoiTypeMap(poiTypes), [poiTypes]); + const poi = selectedPoi && String(selectedPoi.PoiId) === String(poiId) ? selectedPoi : null; + + const handleRoute = async () => { + if (!poi) { + return; + } + + const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiSelectionLabel(poi, poiTypesById), userLatitude || undefined, userLongitude || undefined); + if (!success) { + showToast('error', t('routes.failed_to_open_poi_maps')); + } + }; + + if (isLoadingDetail && !poi) { + return ( + + + + ); + } + + if (!poi) { + return ; + } + + const displayName = getPoiDisplayName(poi, poiTypesById); + const poiTypeName = getPoiTypeName(poi, poiTypesById) || t('routes.poi_type_unknown'); + const selectionLabel = getPoiSelectionLabel(poi, poiTypesById); + const destinationEnabled = isPoiDestinationEnabled(poi, poiTypesById); + + return ( + + + + {displayName} + + + {poiTypeName} + + {destinationEnabled ? ( + + {t('routes.poi_destination_enabled')} + + ) : null} + + + + + + + + + + + + {poi.Address ? ( + + {t('routes.poi_address')} + {poi.Address} + + ) : null} + + {poi.Note ? ( + + {t('routes.poi_note')} + {poi.Note} + + ) : null} + + + {t('routes.poi_coordinates')} + + {t('routes.poi_coordinates_value', { + latitude: poi.Latitude.toFixed(6), + longitude: poi.Longitude.toFixed(6), + })} + + + + + + + ); +} diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index e13655a..2c8bae2 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -13,7 +13,6 @@ import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sh import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { ThemeItem } from '@/components/settings/theme-item'; import { ToggleItem } from '@/components/settings/toggle-item'; -import { UnitSelectionBottomSheet } from '@/components/settings/unit-selection-bottom-sheet'; import { FocusAwareStatusBar, ScrollView } from '@/components/ui'; import { AlertDialog, AlertDialogBackdrop, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader } from '@/components/ui/alert-dialog'; import { Box } from '@/components/ui/box'; @@ -28,9 +27,7 @@ import { logger } from '@/lib/logging'; import { getBaseApiUrl } from '@/lib/storage/app'; import { openLinkInBrowser } from '@/lib/utils'; import { clearAllAppData } from '@/services/app-reset.service'; -import { useCoreStore } from '@/stores/app/core-store'; import { useServerUrlStore } from '@/stores/app/server-url-store'; -import { useUnitsStore } from '@/stores/units/store'; export default function Settings() { const { t } = useTranslation(); @@ -40,26 +37,16 @@ export default function Settings() { const [showLoginInfo, setShowLoginInfo] = React.useState(false); const { login, status, isAuthenticated } = useAuth(); const [showServerUrl, setShowServerUrl] = React.useState(false); - const [showUnitSelection, setShowUnitSelection] = React.useState(false); const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false); - const activeUnit = useCoreStore((state) => state.activeUnit); - const units = useUnitsStore((state) => state.units); const serverUrl = useServerUrlStore((s) => s.url); - const activeUnitName = React.useMemo(() => { - if (!activeUnit) return t('settings.none_selected'); - return activeUnit?.Name || t('common.unknown'); - }, [activeUnit, t]); - /** * Handles logout confirmation - clears all data and signs out */ const handleLogoutConfirm = useCallback(async () => { setShowLogoutConfirm(false); - trackEvent('user_logout_confirmed', { - hadActiveUnit: !!activeUnit, - }); + trackEvent('user_logout_confirmed', {}); // Clear all app data first using the centralized service try { @@ -73,7 +60,7 @@ export default function Settings() { // Then sign out await signOut(); - }, [signOut, trackEvent, activeUnit]); + }, [signOut, trackEvent]); const handleLoginInfoSubmit = async (data: { username: string; password: string }) => { logger.info({ @@ -92,11 +79,8 @@ export default function Settings() { // Track when settings view is rendered useEffect(() => { - trackEvent('settings_view_rendered', { - hasActiveUnit: !!activeUnit, - unitName: activeUnit?.Name || 'none', - }); - }, [trackEvent, activeUnit]); + trackEvent('settings_view_rendered', {}); + }, [trackEvent]); return ( @@ -119,7 +103,6 @@ export default function Settings() { setShowServerUrl(true)} textStyle="text-info-600" /> setShowLoginInfo(true)} textStyle="text-info-600" /> - setShowUnitSelection(true)} textStyle="text-info-600" /> setShowLogoutConfirm(true)} textStyle="text-error-600" /> @@ -152,7 +135,6 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> setShowServerUrl(false)} /> - setShowUnitSelection(false)} /> {/* Logout Confirmation Dialog */} setShowLogoutConfirm(false)}> diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 86aa06b..386f1c3 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native' import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; +import { StartCommandSheet } from '@/components/command/start-command-sheet'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; // Import a static map component instead of react-native-maps @@ -24,12 +25,11 @@ import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; -import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; +import { useCommandStore } from '@/stores/command/store'; import { securityStore } from '@/stores/security/store'; -import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; import { useCallDetailMenu } from '../../components/calls/call-detail-menu'; @@ -37,7 +37,6 @@ import CallFilesModal from '../../components/calls/call-files-modal'; import CallImagesModal from '../../components/calls/call-images-modal'; import CallNotesModal from '../../components/calls/call-notes-modal'; import { CloseCallBottomSheet } from '../../components/calls/close-call-bottom-sheet'; -import { StatusBottomSheet } from '../../components/status/status-bottom-sheet'; export default function CallDetail() { const { id } = useLocalSearchParams(); @@ -62,16 +61,14 @@ export default function CallDetail() { const fetchCallDetail = useCallDetailStore((state) => state.fetchCallDetail); const reset = useCallDetailStore((state) => state.reset); const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls); - const activeCall = useCoreStore((state) => state.activeCall); - const activeStatuses = useCoreStore((state) => state.activeStatuses); - const activeUnit = useCoreStore((state) => state.activeUnit); - const setStatusBottomSheetOpen = useStatusBottomSheetStore((state) => state.setIsOpen); - const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall); + const activeBoardCallId = useCommandStore((state) => state.activeCallId); + const hasCommandBoard = useCommandStore((state) => !!(callId && state.boards[callId])); const [isNotesModalOpen, setIsNotesModalOpen] = useState(false); const [isImagesModalOpen, setIsImagesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false); const [isSettingActive, setIsSettingActive] = useState(false); + const [isTemplatePickerOpen, setIsTemplatePickerOpen] = useState(false); const showToast = useToastStore((state) => state.showToast); const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); const startPolling = useCheckInTimerStore((state) => state.startPolling); @@ -110,32 +107,40 @@ export default function CallDetail() { setIsCloseCallModalOpen(true); }, []); - const handleSetActive = async () => { + const startCommandWithTemplate = async (commandDefinitionId: number | null) => { if (!call) return; setIsSettingActive(true); try { - // Set this call as the active call in the core store - await useCoreStore.getState().setActiveCall(call.CallId); + // Open (or create) the command board for this call and make it the active board + await useCommandStore.getState().startCommand(call.CallId, commandDefinitionId); - // Pre-select the current call and open the status bottom sheet without a pre-selected status - setSelectedCall(call); - setStatusBottomSheetOpen(true); // No status provided, will start with status selection + showToast('success', t('command.start_success')); - // Show success message - showToast('success', t('call_detail.set_active_success')); + // Take the user to the command board for this call + router.push('/command'); } catch (error) { logger.error({ - message: 'Failed to set call as active', + message: 'Failed to start command for call', context: { error, callId: call.CallId }, }); - showToast('error', t('call_detail.set_active_error')); + showToast('error', t('command.start_error')); } finally { setIsSettingActive(false); } }; + const handleStartCommand = () => { + if (!call) return; + // Existing boards open directly; new commands pick a template first + if (hasCommandBoard) { + startCommandWithTemplate(null); + return; + } + setIsTemplatePickerOpen(true); + }; + // Initialize the call detail menu hook const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ onEditCall: handleEditCall, @@ -491,13 +496,25 @@ export default function CallDetail() { {call.Name} ({call.Number}) - {/* Show "Set Active" button if this call is not the active call and there is an active unit */} - {activeUnit && activeCall?.CallId !== call.CallId && ( - - )} + @@ -568,8 +585,8 @@ export default function CallDetail() { {/* Close Call Bottom Sheet */} setIsCloseCallModalOpen(false)} callId={callId} /> - {/* Status Bottom Sheet */} - + {/* Command template picker for starting a new board */} + setIsTemplatePickerOpen(false)} onStart={(commandDefinitionId) => startCommandWithTemplate(commandDefinitionId)} /> {/* Call Detail Menu ActionSheet */} diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index d2d9a29..f321fec 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -2,6 +2,10 @@ import { render, screen } from '@testing-library/react-native'; import React from 'react'; // Mock Platform before any other imports +jest.mock('@/components/command/start-command-sheet', () => ({ + StartCommandSheet: () => null, +})); + jest.mock('react-native', () => ({ Platform: { OS: 'ios', @@ -106,8 +110,6 @@ const mockSecurityStore = { const mockCoreStore = { activeCall: null, - activeStatuses: [], - activeUnit: null, setActiveCall: jest.fn(), }; @@ -116,11 +118,6 @@ const mockLocationStore = { longitude: -73.9851, }; -const mockStatusBottomSheetStore = { - setIsOpen: jest.fn(), - setSelectedCall: jest.fn(), -}; - const mockToastStore = { showToast: jest.fn(), }; @@ -142,10 +139,6 @@ jest.mock('@/stores/app/location-store', () => ({ useLocationStore: jest.fn(), })); -jest.mock('@/stores/status/store', () => ({ - useStatusBottomSheetStore: jest.fn(), -})); - jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn(), })); @@ -243,10 +236,6 @@ jest.mock('../../../components/calls/close-call-bottom-sheet', () => ({ CloseCallBottomSheet: () =>

, })); -jest.mock('../../../components/status/status-bottom-sheet', () => ({ - StatusBottomSheet: () =>
Status Sheet
, -})); - // Mock UI components jest.mock('@/components/ui', () => ({ FocusAwareStatusBar: () => null, @@ -341,7 +330,6 @@ describe('CallDetail', () => { const { securityStore, useSecurityStore } = require('@/stores/security/store'); const { useCoreStore } = require('@/stores/app/core-store'); const { useLocationStore } = require('@/stores/app/location-store'); - const { useStatusBottomSheetStore } = require('@/stores/status/store'); const { useToastStore } = require('@/stores/toast/store'); beforeEach(() => { @@ -371,13 +359,6 @@ describe('CallDetail', () => { return mockLocationStore; }); - useStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(mockStatusBottomSheetStore); - } - return mockStatusBottomSheetStore; - }); - useToastStore.mockImplementation((selector: any) => { if (selector) { return selector(mockToastStore); diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 7875ebf..ba4de2b 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -1,14 +1,14 @@ import { render, waitFor, fireEvent } from '@testing-library/react-native'; -import { useLocalSearchParams } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import React from 'react'; import { useWindowDimensions } from 'react-native'; import { useAnalytics } from '@/hooks/use-analytics'; import { useCoreStore } from '@/stores/app/core-store'; +import { useCommandStore } from '@/stores/command/store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useLocationStore } from '@/stores/app/location-store'; -import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; import { securityStore } from '@/stores/security/store'; @@ -17,6 +17,10 @@ import CallDetail from '../[id]'; // Mock UI components that might use NativeWind +jest.mock('@/components/command/start-command-sheet', () => ({ + StartCommandSheet: () => null, +})); + jest.mock('@/components/ui', () => ({ FocusAwareStatusBar: jest.fn().mockImplementation(() => null), SafeAreaView: jest.fn().mockImplementation(({ children }) => children), @@ -215,6 +219,10 @@ jest.mock('@/stores/app/core-store', () => ({ useCoreStore: jest.fn(), })); +jest.mock('@/stores/command/store', () => ({ + useCommandStore: jest.fn(), +})); + jest.mock('@/stores/calls/detail-store', () => ({ useCallDetailStore: jest.fn(), })); @@ -223,10 +231,6 @@ jest.mock('@/stores/app/location-store', () => ({ useLocationStore: jest.fn(), })); -jest.mock('@/stores/status/store', () => ({ - useStatusBottomSheetStore: jest.fn(), -})); - jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn(), })); @@ -264,10 +268,6 @@ jest.mock('../../../components/calls/close-call-bottom-sheet', () => ({ CloseCallBottomSheet: () => null, })); -jest.mock('../../../components/status/status-bottom-sheet', () => ({ - StatusBottomSheet: () => null, -})); - jest.mock('@/components/maps/static-map', () => { return function StaticMap() { return null; @@ -429,9 +429,9 @@ const mockTrackEvent = jest.fn(); const mockUseAnalytics = useAnalytics as jest.MockedFunction; const mockUseLocalSearchParams = useLocalSearchParams as jest.MockedFunction; const mockUseCoreStore = useCoreStore as jest.MockedFunction; +const mockUseCommandStore = useCommandStore as unknown as jest.Mock; const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction; const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseStatusBottomSheetStore = useStatusBottomSheetStore as jest.MockedFunction; const mockUseToastStore = useToastStore as jest.MockedFunction; const mockSecurityStore = securityStore as jest.MockedFunction; @@ -448,16 +448,6 @@ describe('CallDetail', () => { const defaultCoreStore = { activeCall: null, - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, }; const defaultLocationStore = { @@ -465,11 +455,6 @@ describe('CallDetail', () => { longitude: -74.0060, }; - const defaultStatusBottomSheetStore = { - setIsOpen: jest.fn(), - setSelectedCall: jest.fn(), - }; - const defaultSecurityStore = { rights: { CanCreateCalls: true }, getRights: jest.fn(), @@ -509,11 +494,9 @@ describe('CallDetail', () => { return defaultCoreStore; }); - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(defaultStatusBottomSheetStore); - } - return defaultStatusBottomSheetStore; + mockUseCommandStore.mockImplementation((selector: any) => { + const state = { boards: {}, activeCallId: null }; + return selector ? selector(state) : state; }); mockUseCallDetailStore.mockImplementation((selector: any) => { @@ -668,7 +651,7 @@ describe('CallDetail', () => { }); }); - describe('Set Active functionality', () => { + describe('Start Command functionality', () => { const mockCall = { CallId: 'test-call-id', Name: 'Test Call', @@ -683,249 +666,7 @@ describe('CallDetail', () => { FileCount: 0, }; - it('should show "Set Active" button when call is not the active call', () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, // Different call is active - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; - }); - - const { getByText, getAllByText, debug, getByTestId } = render(); - - // Debug the rendered elements - debug(); - - // Should show "Set Active" button - try finding by testID first - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - }); - - it('should not show "Set Active" button when call is already the active call', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'test-call-id' }, // Same call is active - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - const { queryByText } = render(); - - // Should not show "Set Active" button - expect(queryByText('call_detail.set_active')).toBeNull(); - }); - - it('should not show "Set Active" button when there is no active unit', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, // Different call is active - activeUnit: null, // No active unit - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - const { queryByText, queryByTestId } = render(); - - // Should not show "Set Active" button when no active unit - expect(queryByText('call_detail.set_active')).toBeNull(); - expect(queryByTestId('button-call_detail.set_active')).toBeNull(); - }); - - it('should open status bottom sheet when "Set Active" button is pressed', async () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - const mockSetActiveCall = jest.fn().mockResolvedValue(undefined); - const mockShowToast = jest.fn(); - - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, // Different call is active - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - // Mock the core store getState method - useCoreStore.getState = jest.fn().mockReturnValue({ - setActiveCall: mockSetActiveCall, - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseToastStore.mockImplementation((selector: any) => { - const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; - }); - - const { getByTestId } = render(); - - // Press the "Set Active" button - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - fireEvent.press(setActiveButton); - - await waitFor(() => { - // Should call setActiveCall with the call ID - expect(mockSetActiveCall).toHaveBeenCalledWith(mockCall.CallId); - - // Should call setSelectedCall with the current call - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - - // Should call setIsOpen with true but no pre-selected status (for status selection) - expect(mockSetIsOpen).toHaveBeenCalledWith(true); - - // Should show success toast - expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.set_active_success'); - }); - }); - - it('should show loading state when setting call as active', async () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - let resolveSetActiveCall: () => void; - const mockSetActiveCall = jest.fn().mockImplementation(() => { - return new Promise((resolve) => { - resolveSetActiveCall = resolve; - }); - }); - const mockShowToast = jest.fn(); - + const setCallDetail = () => { mockUseCallDetailStore.mockImplementation((selector: any) => { const store = { call: mockCall, @@ -936,330 +677,81 @@ describe('CallDetail', () => { fetchCallDetail: jest.fn(), reset: jest.fn(), }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - useCoreStore.getState = jest.fn().mockReturnValue({ - setActiveCall: mockSetActiveCall, - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseToastStore.mockImplementation((selector: any) => { - const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; - }); - - const { getByTestId } = render(); - - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - - // Button should be enabled initially - expect(setActiveButton.props.disabled).toBe(false); - - // Press the button - fireEvent.press(setActiveButton); - - // Button should be disabled during loading - await waitFor(() => { - expect(setActiveButton.props.disabled).toBe(true); + return selector ? selector(store) : store; }); + }; - // Resolve the promise to complete the operation - resolveSetActiveCall!(); + it('shows the Start Command button when the call has no command board', () => { + setCallDetail(); - // Button should be enabled again after completion - await waitFor(() => { - expect(setActiveButton.props.disabled).toBe(false); - }); + const { getByTestId, toJSON } = render(); + expect(getByTestId('start-command-button')).toBeTruthy(); + expect(JSON.stringify(toJSON())).toContain('command.start_command'); }); - it('should disable button during loading and re-enable after error', async () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - const mockSetActiveCall = jest.fn().mockRejectedValue(new Error('Network error')); - const mockShowToast = jest.fn(); - - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - useCoreStore.getState = jest.fn().mockReturnValue({ - setActiveCall: mockSetActiveCall, - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseToastStore.mockImplementation((selector: any) => { - const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; + it('shows Open Command Board and the Active Command badge when this call is the active board', () => { + setCallDetail(); + mockUseCommandStore.mockImplementation((selector: any) => { + const state = { boards: { 'test-call-id': { callId: 'test-call-id' } }, activeCallId: 'test-call-id' }; + return selector ? selector(state) : state; }); - const { getByTestId } = render(); - - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - - // Button should be enabled initially - expect(setActiveButton.props.disabled).toBe(false); - - // Press the button - fireEvent.press(setActiveButton); - - // Wait for the error to be handled and button to be re-enabled - await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.set_active_error'); - expect(setActiveButton.props.disabled).toBe(false); - }); + const { getByTestId, toJSON } = render(); + expect(getByTestId('start-command-button')).toBeTruthy(); + const json = JSON.stringify(toJSON()); + expect(json).toContain('command.open_board'); + expect(json).toContain('command.active_badge'); }); - it('should handle error when setting call as active fails', async () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - const mockSetActiveCall = jest.fn().mockRejectedValue(new Error('Network error')); + it('starts command for the call and navigates to the command board', async () => { + const mockSetActiveCall = jest.fn(() => Promise.resolve()); const mockShowToast = jest.fn(); + const mockPush = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, // Different call is active - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - // Mock the core store getState method to return a failing setActiveCall - useCoreStore.getState = jest.fn().mockReturnValue({ - setActiveCall: mockSetActiveCall, + setCallDetail(); + // Existing board → button starts directly (no template picker) + mockUseCommandStore.mockImplementation((selector: any) => { + const state = { boards: { 'test-call-id': { callId: 'test-call-id' } }, activeCallId: null }; + return selector ? selector(state) : state; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; - }); - + (mockUseCommandStore as any).getState = jest.fn(() => ({ startCommand: mockSetActiveCall })); mockUseToastStore.mockImplementation((selector: any) => { const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; + return selector ? selector(store) : store; }); + (useRouter as jest.Mock).mockReturnValue({ back: jest.fn(), push: mockPush }); const { getByTestId } = render(); - - // Press the "Set Active" button - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - fireEvent.press(setActiveButton); + fireEvent.press(getByTestId('start-command-button')); await waitFor(() => { - // Should call setActiveCall with the call ID - expect(mockSetActiveCall).toHaveBeenCalledWith(mockCall.CallId); - - // Should show error toast - expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.set_active_error'); - - // Should not open status bottom sheet when there's an error - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetIsOpen).not.toHaveBeenCalled(); + expect(mockSetActiveCall).toHaveBeenCalledWith(mockCall.CallId, null); + expect(mockShowToast).toHaveBeenCalledWith('success', 'command.start_success'); + expect(mockPush).toHaveBeenCalledWith('/command'); }); }); - it('should select the first available status if no "En Route" status is found', async () => { - const mockSetIsOpen = jest.fn(); - const mockSetSelectedCall = jest.fn(); - const mockSetActiveCall = jest.fn().mockResolvedValue(undefined); + it('shows an error toast when starting command fails', async () => { + const mockSetActiveCall = jest.fn(() => Promise.reject(new Error('boom'))); const mockShowToast = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => { - const store = { - call: mockCall, - callExtraData: null, - callPriority: null, - isLoading: false, - error: null, - fetchCallDetail: jest.fn(), - reset: jest.fn(), - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseCoreStore.mockImplementation((selector: any) => { - const store = { - activeCall: { CallId: 'different-call-id' }, // Different call is active - activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists - activeStatuses: { - UnitType: '1', - StatusId: '1', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - if (selector) { - return selector(store); - } - return store; - }); - - // Mock the core store getState method - useCoreStore.getState = jest.fn().mockReturnValue({ - setActiveCall: mockSetActiveCall, - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - setIsOpen: mockSetIsOpen, - setSelectedCall: mockSetSelectedCall, - }; - if (selector) { - return selector(store); - } - return store; + setCallDetail(); + // Existing board → button starts directly (no template picker) + mockUseCommandStore.mockImplementation((selector: any) => { + const state = { boards: { 'test-call-id': { callId: 'test-call-id' } }, activeCallId: null }; + return selector ? selector(state) : state; }); - + (mockUseCommandStore as any).getState = jest.fn(() => ({ startCommand: mockSetActiveCall })); mockUseToastStore.mockImplementation((selector: any) => { const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; + return selector ? selector(store) : store; }); const { getByTestId } = render(); - - // Press the "Set Active" button - const setActiveButton = getByTestId('button-call_detail.set_active'); - expect(setActiveButton).toBeTruthy(); - fireEvent.press(setActiveButton); + fireEvent.press(getByTestId('start-command-button')); await waitFor(() => { - // Should call setActiveCall with the call ID - expect(mockSetActiveCall).toHaveBeenCalledWith(mockCall.CallId); - - // Should call setSelectedCall with the current call - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - - // Should call setIsOpen with true but no pre-selected status (for status selection) - expect(mockSetIsOpen).toHaveBeenCalledWith(true); - - // Should show success toast - expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.set_active_success'); + expect(mockShowToast).toHaveBeenCalledWith('error', 'command.start_error'); }); }); }); diff --git a/src/app/maps/index.tsx b/src/app/maps/index.tsx index db250a2..e464021 100644 --- a/src/app/maps/index.tsx +++ b/src/app/maps/index.tsx @@ -112,12 +112,12 @@ export default function MapsHome() { ); const renderLayerToggle = (layer: ActiveLayerSummary) => ( - + {layer.Name} - toggleLayer(layer.LayerId)} /> + toggleLayer(layer.Id)} /> ); diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 8796fbb..ff3374e 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -1,7 +1,8 @@ import { useRouter } from 'expo-router'; -import { Bell, ChevronRight, MapPin, Users } from 'lucide-react-native'; +import { ChevronRight, ClipboardList, MapPin, Users } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Image, Pressable, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; @@ -19,18 +20,18 @@ type OnboardingItemProps = { const SLIDES = [ { - title: 'Resgrid Unit', - description: "Track your unit's location and status in real-time with our advanced mapping and AVL system", - icon: , + titleKey: 'onboarding.boardTitle', + descriptionKey: 'onboarding.boardDescription', + icon: , }, { - title: 'Instant Notifications', - description: 'Receive immediate alerts for emergencies and important updates from your department', - icon: , + titleKey: 'onboarding.awarenessTitle', + descriptionKey: 'onboarding.awarenessDescription', + icon: , }, { - title: 'Interact with Calls', - description: 'Seamlessly view call information and interact with your team members for efficient emergency response', + titleKey: 'onboarding.coordinateTitle', + descriptionKey: 'onboarding.coordinateDescription', icon: , }, ]; @@ -57,6 +58,7 @@ const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ curren ); export default function Onboarding() { + const { t } = useTranslation(); const [_, setIsFirstTime] = useIsFirstTime(); const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding); const router = useRouter(); @@ -167,9 +169,17 @@ export default function Onboarding() { - + {SLIDES.map((slide) => ( - + ))} @@ -181,18 +191,18 @@ export default function Onboarding() { {currentIndex < SLIDE_COUNT - 1 ? ( - Skip + {t('onboarding.skip')} - Next + {t('onboarding.next')} ) : ( - Let's Get Started + {t('onboarding.getStarted')} )} diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index e8209e3..12a7c30 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -1,9 +1,10 @@ -import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native'; +import { AlertTriangle, ClipboardList, MapPin, Phone, Timer } from 'lucide-react-native'; import React, { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Animated, ScrollView, StyleSheet } from 'react-native'; import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; import { HtmlRenderer } from '@/components/ui/html-renderer'; import { Icon } from '@/components/ui/icon'; @@ -32,9 +33,15 @@ interface CallCardProps { showTimerIcon?: boolean; isTimerOverdue?: boolean; dispatches?: DispatchedEventResultData[]; + /** When set, renders a "Start Command" / "Open Command Board" action for this call. */ + onStartCommand?: () => void; + /** True when this call's board is the one currently shown on the Command Board tab. */ + isCommandCall?: boolean; + /** True when a command board exists for this call (it may not be the active one). */ + hasCommand?: boolean; } -export const CallCard: React.FC = ({ call, priority, showTimerIcon = false, isTimerOverdue = false, dispatches }) => { +export const CallCard: React.FC = ({ call, priority, showTimerIcon = false, isTimerOverdue = false, dispatches, onStartCommand, isCommandCall = false, hasCommand = false }) => { const { t } = useTranslation(); const textColor = invertColor(getColor(call, priority), true); const pulseAnim = useRef(new Animated.Value(1)).current; @@ -179,6 +186,20 @@ export const CallCard: React.FC = ({ call, priority, showTimerIco )} + + {/* Start Command action — opens (or creates) the IC board for this call. + Multiple boards can exist at once; the active one shows a badge. */} + {onStartCommand ? ( + + ) : null} ); }; diff --git a/src/components/check-in-timers/check-in-bottom-sheet.tsx b/src/components/check-in-timers/check-in-bottom-sheet.tsx index fdb5512..2df340b 100644 --- a/src/components/check-in-timers/check-in-bottom-sheet.tsx +++ b/src/components/check-in-timers/check-in-bottom-sheet.tsx @@ -10,7 +10,6 @@ import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import type { CheckInResult } from '@/stores/check-in-timers/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; @@ -34,22 +33,20 @@ interface CheckInBottomSheetProps { export const CheckInBottomSheet: React.FC = ({ isOpen, onClose, callId }) => { const { t } = useTranslation(); - const activeUnit = useCoreStore((state) => state.activeUnit); const latitude = useLocationStore((state) => state.latitude); const longitude = useLocationStore((state) => state.longitude); const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn); const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn); const showToast = useToastStore((state) => state.showToast); - const defaultType = activeUnit ? 1 : 0; - const [selectedType, setSelectedType] = useState(defaultType); + // IC users always check in as personnel — there is no unit context in this app. + const [selectedType, setSelectedType] = useState(0); const [note, setNote] = useState(''); const handleConfirm = useCallback(async () => { const input: PerformCheckInInput = { CallId: callId, CheckInType: selectedType, - UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, Latitude: latitude?.toString(), Longitude: longitude?.toString(), Note: note || undefined, @@ -68,7 +65,7 @@ export const CheckInBottomSheet: React.FC = ({ isOpen, } else { showToast('error', t('check_in.check_in_error')); } - }, [callId, selectedType, activeUnit, latitude, longitude, note, performCheckInAction, showToast, t, onClose]); + }, [callId, selectedType, latitude, longitude, note, performCheckInAction, showToast, t, onClose]); return ( diff --git a/src/components/command/__tests__/add-lane-sheet.test.tsx b/src/components/command/__tests__/add-lane-sheet.test.tsx new file mode 100644 index 0000000..401286d --- /dev/null +++ b/src/components/command/__tests__/add-lane-sheet.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + return { + Check: (props: any) => React.createElement('View', { ...props, testID: 'mock-check-icon' }), + }; +}); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null), +})); + +import { CommandNodeType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { AddLaneSheet } from '../add-lane-sheet'; + +describe('AddLaneSheet', () => { + it('saves a lane with color and parsed optional limits', () => { + const onSave = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.changeText(getByTestId('lane-name-input'), 'Fire Attack'); + fireEvent.press(getByTestId('lane-type-1')); + fireEvent.press(getByTestId('lane-color-e74c3c')); + + fireEvent.press(getByTestId('lane-limits-toggle')); + fireEvent.changeText(getByTestId('limit-min-units'), '1'); + fireEvent.changeText(getByTestId('limit-max-units'), '3'); + fireEvent.changeText(getByTestId('limit-min-riding'), '2'); + fireEvent.changeText(getByTestId('limit-max-time'), '30'); + + fireEvent.press(getByTestId('lane-save')); + + expect(onSave).toHaveBeenCalledWith('Fire Attack', CommandNodeType.Group, '#e74c3c', { minUnits: 1, maxUnits: 3, minUnitPersonnel: 2, maxTimeInRole: 30 }); + + unmount(); + }); + + it('passes undefined limits when none are entered', () => { + const onSave = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.changeText(getByTestId('lane-name-input'), 'Staging'); + fireEvent.press(getByTestId('lane-save')); + + expect(onSave).toHaveBeenCalledWith('Staging', CommandNodeType.Division, undefined, undefined); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/add-resource-sheet.test.tsx b/src/components/command/__tests__/add-resource-sheet.test.tsx new file mode 100644 index 0000000..d45fef0 --- /dev/null +++ b/src/components/command/__tests__/add-resource-sheet.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + return { + Check: (props: any) => React.createElement('View', { ...props, testID: 'mock-check-icon' }), + }; +}); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null), +})); + +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; + +import { AddResourceSheet } from '../add-resource-sheet'; + +const units = [ + { UnitId: 'unit-1', Name: 'Engine 1', Type: 'Engine', GroupName: 'Station 1' }, + { UnitId: 'unit-2', Name: 'Ladder 7', Type: 'Ladder', GroupName: 'Station 2' }, +] as UnitResultData[]; + +const personnel = [ + { UserId: 'user-1', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Available', StatusColor: '#00ff00' }, + { UserId: 'user-2', FirstName: 'Alex', LastName: 'Reed', GroupName: 'Station 2', Status: 'Responding', StatusColor: '' }, +] as PersonnelInfoResultData[]; + +const baseProps = { + isOpen: true, + onClose: jest.fn(), + units, + personnel, + trackedUnitIds: [] as string[], + trackedUserIds: [] as string[], + onAddUnit: jest.fn(), + onAddPersonnel: jest.fn(), + onSaveExternal: jest.fn(), +}; + +describe('AddResourceSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to the department units roster and adds a unit on tap', () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('Engine 1')).toBeTruthy(); + expect(getByText('Engine • Station 1')).toBeTruthy(); + + fireEvent.press(getByTestId('roster-unit-unit-1')); + expect(baseProps.onAddUnit).toHaveBeenCalledWith('unit-1'); + + unmount(); + }); + + it('marks already-tracked units and does not add them again', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('mock-check-icon')).toBeTruthy(); + fireEvent.press(getByTestId('roster-unit-unit-1')); + expect(baseProps.onAddUnit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('switches to personnel, filters by search, and adds a person on tap', () => { + const { getByTestId, getByText, queryByText, unmount } = render(); + + fireEvent.press(getByTestId('resource-tab-personnel')); + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('Station 1 • Available')).toBeTruthy(); + + fireEvent.changeText(getByTestId('resource-roster-search'), 'alex'); + expect(queryByText('Sam Jones')).toBeNull(); + + fireEvent.press(getByTestId('roster-person-user-2')); + expect(baseProps.onAddPersonnel).toHaveBeenCalledWith('user-2'); + + unmount(); + }); + + it('adds an external person with role and agency from the external tab', () => { + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('resource-tab-external')); + fireEvent.press(getByTestId('resource-kind-person')); + fireEvent.changeText(getByTestId('resource-name-input'), 'Pat Smith'); + fireEvent.changeText(getByTestId('resource-note-input'), 'Medic'); + fireEvent.changeText(getByTestId('resource-agency-input'), 'Red Cross'); + fireEvent.press(getByTestId('resource-save')); + + expect(baseProps.onSaveExternal).toHaveBeenCalledWith('person', 'Pat Smith', 'Medic', 'Red Cross'); + expect(baseProps.onClose).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/assign-resource-sheet.test.tsx b/src/components/command/__tests__/assign-resource-sheet.test.tsx new file mode 100644 index 0000000..e7c19b3 --- /dev/null +++ b/src/components/command/__tests__/assign-resource-sheet.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + return { + Check: (props: any) => React.createElement('View', { ...props, testID: 'mock-check-icon' }), + }; +}); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null), +})); + +import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { type AssignableResourceOption, AssignResourceSheet } from '../assign-resource-sheet'; + +const options: AssignableResourceOption[] = [ + { kind: ResourceAssignmentKind.RealUnit, id: 'unit-1', name: 'Engine 1', detail: 'Engine • Station 1', statusLabel: 'Enroute', assignedNodeId: 'lane-1' }, + { kind: ResourceAssignmentKind.RealUnit, id: 'unit-2', name: 'Brush 1', detail: 'Brush • Station 1', assignedNodeId: 'lane-2' }, + { kind: ResourceAssignmentKind.RealUnit, id: 'unit-3', name: 'Utility 2', assignedNodeId: '' }, + { kind: ResourceAssignmentKind.RealPersonnel, id: 'user-1', name: 'Sam Jones', detail: 'Station 1 • Available', chips: ['Medic', 'Driver'] }, +]; + +const baseProps = { + isOpen: true, + onClose: jest.fn(), + options, + targetNodeId: 'lane-1', + resolveLaneName: (nodeId?: string | null) => (nodeId === 'lane-2' ? 'Division B' : 'command.unassigned'), + onSave: jest.fn(), +}; + +describe('AssignResourceSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables resources already in the target lane and cannot select them', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('resource-option-in-lane-0-unit-1')).toBeTruthy(); + fireEvent.press(getByTestId('resource-option-0-unit-1')); + fireEvent.press(getByTestId('resource-assign-save')); + expect(baseProps.onSave).not.toHaveBeenCalled(); + + unmount(); + }); + + it('shows the current lane badge for resources assigned elsewhere and the pool badge for unassigned', () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('resource-option-other-lane-0-unit-2')).toBeTruthy(); + expect(getByText('Division B')).toBeTruthy(); + expect(getByText('command.unassigned')).toBeTruthy(); + expect(getByText('Engine • Station 1')).toBeTruthy(); + expect(getByText('Enroute')).toBeTruthy(); + + unmount(); + }); + + it('shows personnel detail and role chips and saves a selectable resource', () => { + const { getByTestId, getByText, unmount } = render(); + + fireEvent.press(getByTestId('resource-kind-tab-1')); + expect(getByText('Station 1 • Available')).toBeTruthy(); + expect(getByText('Medic')).toBeTruthy(); + expect(getByText('Driver')).toBeTruthy(); + + fireEvent.press(getByTestId('resource-option-1-user-1')); + fireEvent.press(getByTestId('resource-assign-save')); + expect(baseProps.onSave).toHaveBeenCalledWith(ResourceAssignmentKind.RealPersonnel, 'user-1'); + expect(baseProps.onClose).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/resource-cards.test.tsx b/src/components/command/__tests__/resource-cards.test.tsx new file mode 100644 index 0000000..a285aff --- /dev/null +++ b/src/components/command/__tests__/resource-cards.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${Object.values(params).join('/')}` : key), + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + CloudOff: icon('cloud-off'), + MapPin: icon('map-pin'), + Trash2: icon('trash'), + Truck: icon('truck'), + }; +}); + +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; +import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +import { PersonnelResourceCard, UnitResourceCard } from '../resource-cards'; + +const unit = { UnitId: 'unit-1', Name: 'Engine 1', Type: 'Engine', GroupName: 'Station 1' } as UnitResultData; + +const status = { UnitId: 'unit-1', State: 'Responding', StateStyle: '#d35400', DestinationName: 'Staging', Eta: '5 min' } as UnitStatusResultData; + +const roles = [ + { UnitId: 'unit-1', UnitRoleId: 'r-1', Name: 'Driver', UserId: 'u-1', FullName: 'Sam Jones', UpdatedOn: '' }, + { UnitId: 'unit-1', UnitRoleId: 'r-2', Name: 'Officer', UserId: '', FullName: '', UpdatedOn: '' }, +] as ActiveUnitRoleResultData[]; + +const person = { + UserId: 'u-9', + FirstName: 'Alex', + LastName: 'Reed', + GroupName: 'Station 2', + IdentificationNumber: 'ID-42', + Status: 'Available', + StatusColor: '#27ae60', + Staffing: 'On Shift', + StaffingColor: '#2980b9', + Roles: ['Medic', 'Driver'], +} as PersonnelInfoResultData; + +describe('UnitResourceCard', () => { + it('shows roster info, live status, destination, and role seats with assignees', () => { + const onRelease = jest.fn(); + const { getByText, getByTestId, unmount } = render( + + ); + + expect(getByText('Engine 1')).toBeTruthy(); + expect(getByText('Engine • Station 1')).toBeTruthy(); + expect(getByTestId('card-1-status')).toBeTruthy(); + expect(getByText('Responding')).toBeTruthy(); + expect(getByText('Staging • command.unit_eta:5 min')).toBeTruthy(); + expect(getByText('command.unit_roles_count:1/2')).toBeTruthy(); + expect(getByText('Driver')).toBeTruthy(); + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('command.role_open')).toBeTruthy(); + expect(getByText('Division A')).toBeTruthy(); + + fireEvent.press(getByTestId('remove-1')); + expect(onRelease).toHaveBeenCalled(); + + unmount(); + }); + + it('renders without roster data using just the resolved name', () => { + const { getByText, queryByTestId, unmount } = render(); + + expect(getByText('unit-77')).toBeTruthy(); + expect(queryByTestId('card-2-status')).toBeNull(); + expect(queryByTestId('card-2-roles')).toBeNull(); + expect(getByText('command.unassigned')).toBeTruthy(); + + unmount(); + }); +}); + +describe('PersonnelResourceCard', () => { + it('shows group, id number, status, staffing, and department role chips', () => { + const { getByText, getByTestId, unmount } = render( + + ); + + expect(getByText('Alex Reed')).toBeTruthy(); + expect(getByText('AR')).toBeTruthy(); + expect(getByText('Station 2 • ID-42')).toBeTruthy(); + expect(getByTestId('card-3-status')).toBeTruthy(); + expect(getByTestId('card-3-staffing')).toBeTruthy(); + expect(getByText('Medic')).toBeTruthy(); + expect(getByText('Driver')).toBeTruthy(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/timeline-section.test.tsx b/src/components/command/__tests__/timeline-section.test.tsx new file mode 100644 index 0000000..7ccaf4b --- /dev/null +++ b/src/components/command/__tests__/timeline-section.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + return { + RefreshCw: (props: any) => React.createElement('View', { ...props, testID: 'mock-refresh-icon' }), + }; +}); + +import type { CommandLogEntry } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { SceneClock, TimelineSection } from '../timeline-section'; + +const entry = (id: string, description: string): CommandLogEntry => + ({ + CommandLogEntryId: id, + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 101, + EntryType: 0, + Description: description, + OccurredOn: '2026-07-19T10:00:00Z', + }) as CommandLogEntry; + +describe('TimelineSection', () => { + it('renders log entries and refreshes on demand', () => { + const onRefresh = jest.fn(); + const { getByTestId, getByText, unmount } = render(); + + expect(getByText("Timer 'PAR Check' started")).toBeTruthy(); + fireEvent.press(getByTestId('command-timeline-refresh')); + expect(onRefresh).toHaveBeenCalled(); + + unmount(); + }); + + it('shows the empty state and paginates long logs', () => { + const many = Array.from({ length: 20 }, (_, i) => entry(`e-${i}`, `Entry ${i}`)); + const { getByTestId, getByText, queryByText, rerender, unmount } = render(); + + expect(getByText('command.empty_timeline')).toBeTruthy(); + + rerender(); + expect(queryByText('Entry 16')).toBeNull(); + fireEvent.press(getByTestId('command-timeline-more')); + expect(getByText('Entry 16')).toBeTruthy(); + + unmount(); + }); +}); + +describe('SceneClock', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-19T11:01:05Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('shows elapsed incident time and ticks forward', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('command-scene-clock').props.children).toBe('01:01:05'); + + unmount(); + }); + + it('renders nothing without a start time', () => { + const { queryByTestId, unmount } = render(); + expect(queryByTestId('command-scene-clock')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/command/__tests__/timers-section.test.tsx b/src/components/command/__tests__/timers-section.test.tsx new file mode 100644 index 0000000..b5354ef --- /dev/null +++ b/src/components/command/__tests__/timers-section.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${Object.values(params).join('/')}` : key), + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + AlarmClock: icon('alarm-clock'), + Check: icon('check'), + Plus: icon('plus'), + }; +}); + +import type { IncidentTimer } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { TimersSection } from '../timers-section'; + +const timer = (overrides: Partial): IncidentTimer => + ({ + IncidentTimerId: 't-1', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 101, + TimerType: 3, + ScopeType: 0, + Name: 'PAR Check', + IntervalSeconds: 900, + StartedOn: '2026-07-19T10:00:00Z', + Status: 0, + ...overrides, + }) as IncidentTimer; + +describe('TimersSection', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-19T10:10:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders a running timer with its countdown and acknowledges on tap', () => { + const onAcknowledge = jest.fn(); + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('PAR Check')).toBeTruthy(); + expect(getByText('command.timer_due_in:05:00')).toBeTruthy(); + + fireEvent.press(getByTestId('timer-ack-t-1')); + expect(onAcknowledge).toHaveBeenCalledWith('t-1'); + + unmount(); + }); + + it('flags an overdue timer', () => { + const { getByText, unmount } = render(); + + expect(getByText('command.timer_overdue')).toBeTruthy(); + expect(getByText('command.timer_overdue_by:05:00')).toBeTruthy(); + + unmount(); + }); + + it('hides stopped timers and shows the empty state', () => { + const { getByText, unmount } = render(); + + expect(getByText('command.empty_timers')).toBeTruthy(); + + unmount(); + }); + + it('starts a new timer from the inline form with a preset interval', () => { + const onStartTimer = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('command-timers-add')); + fireEvent.changeText(getByTestId('timer-name-input'), 'Rehab rotation'); + fireEvent.press(getByTestId('timer-preset-20')); + fireEvent.press(getByTestId('timer-start')); + + expect(onStartTimer).toHaveBeenCalledWith('Rehab rotation', 1200); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/transfer-command-sheet.test.tsx b/src/components/command/__tests__/transfer-command-sheet.test.tsx new file mode 100644 index 0000000..c3aea99 --- /dev/null +++ b/src/components/command/__tests__/transfer-command-sheet.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null), +})); + +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; + +import { TransferCommandSheet } from '../transfer-command-sheet'; + +const personnel = [ + { UserId: 'u-1', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Available' }, + { UserId: 'u-2', FirstName: 'Alex', LastName: 'Reed', GroupName: 'Station 2', Status: 'On Scene' }, +] as PersonnelInfoResultData[]; + +describe('TransferCommandSheet', () => { + it('marks the current commander, prevents selecting them, and transfers to another user', () => { + const onTransfer = jest.fn(); + const onClose = jest.fn(); + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('command.current_commander')).toBeTruthy(); + + // Commander row is disabled — pressing it selects nothing + fireEvent.press(getByTestId('transfer-person-u-1')); + fireEvent.press(getByTestId('transfer-confirm')); + expect(onTransfer).not.toHaveBeenCalled(); + + fireEvent.press(getByTestId('transfer-person-u-2')); + fireEvent.press(getByTestId('transfer-confirm')); + expect(onTransfer).toHaveBeenCalledWith('u-2'); + expect(onClose).toHaveBeenCalled(); + + unmount(); + }); + + it('filters personnel by search', () => { + const { getByTestId, queryByText, unmount } = render(); + + fireEvent.changeText(getByTestId('transfer-search'), 'alex'); + expect(queryByText('Sam Jones')).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/voice-section.test.tsx b/src/components/command/__tests__/voice-section.test.tsx new file mode 100644 index 0000000..c6dd172 --- /dev/null +++ b/src/components/command/__tests__/voice-section.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${Object.values(params).join('/')}` : key), + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + Mic: icon('mic'), + MicOff: icon('mic-off'), + Phone: icon('phone'), + PhoneOff: icon('phone-off'), + Plus: icon('plus'), + RadioTower: icon('radio-tower'), + }; +}); + +const mockConnectToRoom = jest.fn(); +const mockDisconnect = jest.fn(); +const mockSetMic = jest.fn(); +const mockFetchVoiceSettings = jest.fn(); + +let mockLiveKitState: Record; + +jest.mock('@/stores/app/livekit-store', () => ({ + useLiveKitStore: Object.assign((selector: any) => selector(mockLiveKitState), { getState: () => mockLiveKitState }), +})); + +import type { IncidentVoiceChannel, VoiceTransmissionLog } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { VoiceSection } from '../voice-section'; + +const channel: IncidentVoiceChannel = { DepartmentVoiceChannelId: 'ch-1', DepartmentId: 1, CallId: 101, Name: 'Tactical 1', IsOnDemand: true }; + +const log: VoiceTransmissionLog = { VoiceTransmissionLogId: 'tx-1', DepartmentId: 1, CallId: 101, DepartmentVoiceChannelId: 'ch-1', UserId: 'u-1', StartedOn: '2026-07-19T10:00:00Z', EndedOn: '2026-07-19T10:00:04Z' }; + +const baseProps = { + callId: '101', + channels: [channel], + transmissionLog: [log], + personName: (userId: string) => (userId === 'u-1' ? 'Sam Jones' : userId), + onCreateChannel: jest.fn(), + onCloseChannels: jest.fn(), + onTransmission: jest.fn(), +}; + +describe('VoiceSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLiveKitState = { + availableRooms: [{ Id: 'ch-1', Name: 'Tactical 1', Token: 'tok-1' }], + currentRoomInfo: null, + isMicrophoneEnabled: false, + connectToRoom: mockConnectToRoom, + disconnectFromRoom: mockDisconnect, + setMicrophoneEnabled: mockSetMic, + fetchVoiceSettings: mockFetchVoiceSettings, + }; + }); + + it('lists channels and joins via the LiveKit room token', async () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('voice-channel-ch-1')).toBeTruthy(); + fireEvent.press(getByTestId('voice-join-ch-1')); + await Promise.resolve(); + expect(mockConnectToRoom).toHaveBeenCalledWith(expect.objectContaining({ Id: 'ch-1' }), 'tok-1'); + + unmount(); + }); + + it('shows PTT and leave controls when connected and records a transmission on mic release', () => { + mockLiveKitState.currentRoomInfo = { Id: 'ch-1', Name: 'Tactical 1' }; + mockLiveKitState.isMicrophoneEnabled = true; + const { getByTestId, rerender, unmount } = render(); + + expect(getByTestId('voice-ptt-ch-1')).toBeTruthy(); + + // Mic released → the completed transmission is logged + mockLiveKitState = { ...mockLiveKitState, isMicrophoneEnabled: false }; + rerender(); + expect(baseProps.onTransmission).toHaveBeenCalledWith('ch-1', expect.any(String), expect.any(String)); + + fireEvent.press(getByTestId('voice-leave-ch-1')); + expect(mockDisconnect).toHaveBeenCalled(); + + unmount(); + }); + + it('creates a channel from a preset name', () => { + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('command-voice-add')); + fireEvent.press(getByTestId('channel-preset-channel_preset_tactical')); + fireEvent.press(getByTestId('channel-create')); + expect(baseProps.onCreateChannel).toHaveBeenCalledWith('command.channel_preset_tactical'); + + unmount(); + }); + + it('renders the transmission log with speaker, channel, and duration', () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('command-transmission-log')).toBeTruthy(); + expect(getByText('Sam Jones')).toBeTruthy(); + expect(getByText('command.transmission_seconds:4')).toBeTruthy(); + + unmount(); + }); +}); diff --git a/src/components/command/__tests__/work-time-light.test.tsx b/src/components/command/__tests__/work-time-light.test.tsx new file mode 100644 index 0000000..96e5869 --- /dev/null +++ b/src/components/command/__tests__/work-time-light.test.tsx @@ -0,0 +1,56 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + CloudOff: icon('cloud-off'), + GripVertical: icon('grip-vertical'), + Plus: icon('plus'), + Trash2: icon('trash'), + UserPlus: icon('user-plus'), + }; +}); + +import { WorkTimeLight, workTimeColor } from '../landscape-structure-board'; + +describe('WorkTimeLight', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-20T10:30:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('shows elapsed minutes without a rotation badge inside the lane limit', () => { + const { getByText, queryByTestId, unmount } = render(); + + expect(getByText('10m')).toBeTruthy(); + expect(queryByTestId('wtl-rotation')).toBeNull(); + + unmount(); + }); + + it('flags rotation-due when the lane MaxTimeInRole is exceeded', () => { + const { getByText, getByTestId, unmount } = render(); + + expect(getByText('30m')).toBeTruthy(); + expect(getByTestId('wtl-rotation')).toBeTruthy(); + expect(getByText('command.rotation_due')).toBeTruthy(); + + unmount(); + }); + + it('keeps the default fatigue thresholds when no lane limit is set', () => { + expect(workTimeColor(10)).toBe('#22c55e'); + expect(workTimeColor(25)).toBe('#f59e0b'); + expect(workTimeColor(45)).toBe('#ef4444'); + }); +}); diff --git a/src/components/command/add-assignment-sheet.tsx b/src/components/command/add-assignment-sheet.tsx new file mode 100644 index 0000000..b469c57 --- /dev/null +++ b/src/components/command/add-assignment-sheet.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getIncidentRoleName, ICS_ROLE_PRESETS } from '@/lib/incident-command-utils'; +import { type IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useRolesStore } from '@/stores/roles/store'; + +interface AddAssignmentSheetProps { + isOpen: boolean; + onClose: () => void; + /** Assign a NIMS/ICS position to a department member. */ + onSave: (roleType: IncidentRoleType, userId: string) => void; +} + +export const AddAssignmentSheet: React.FC = ({ isOpen, onClose, onSave }) => { + const { t } = useTranslation(); + const users = useRolesStore((state) => state.users); + const [roleType, setRoleType] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(null); + const [search, setSearch] = useState(''); + + const filteredUsers = useMemo(() => { + const normalized = search.trim().toLowerCase(); + const list = normalized ? users.filter((u) => `${u.FirstName} ${u.LastName}`.toLowerCase().includes(normalized)) : users; + return list.slice(0, 25); + }, [users, search]); + + const handleSave = useCallback(() => { + if (roleType === null || !selectedUserId) { + return; + } + onSave(roleType, selectedUserId); + setRoleType(null); + setSelectedUserId(null); + setSearch(''); + onClose(); + }, [roleType, selectedUserId, onSave, onClose]); + + return ( + + + {t('command.add_assignment')} + + + {t('command.role_label')} + + {ICS_ROLE_PRESETS.map((preset) => ( + + ))} + + + + + {t('command.person_label')} + + + + + + {filteredUsers.map((user) => ( + setSelectedUserId(user.UserId)} + > + + {user.FirstName} {user.LastName} + + + ))} + {filteredUsers.length === 0 ? {t('command.no_personnel_found')} : null} + + + + + + + + ); +}; diff --git a/src/components/command/add-lane-sheet.tsx b/src/components/command/add-lane-sheet.tsx new file mode 100644 index 0000000..e47c7d0 --- /dev/null +++ b/src/components/command/add-lane-sheet.tsx @@ -0,0 +1,201 @@ +import { Check } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { COMMAND_NODE_TYPES, getCommandNodeTypeName } from '@/lib/incident-command-utils'; +import { CommandNodeType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** Lane identification colors — markers of resources in the lane inherit these on the map. */ +export const LANE_COLORS = ['#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c', '#3498db', '#9b59b6', '#7f8c8d']; + +/** Quick-fill lane names spanning ICS, security, and event operations. */ +const LANE_NAME_SUGGESTION_KEYS = [ + 'lane_suggestion_medical', + 'lane_suggestion_patrol', + 'lane_suggestion_security_post', + 'lane_suggestion_customer_service', + 'lane_suggestion_gate', + 'lane_suggestion_stage', + 'lane_suggestion_parking', + 'lane_suggestion_vendor', + 'lane_suggestion_staging', + 'lane_suggestion_operations', +]; + +/** Optional per-lane limits: 0/undefined = no limit. */ +export interface LaneLimits { + minUnits?: number; + maxUnits?: number; + minUnitPersonnel?: number; + maxUnitPersonnel?: number; + minTimeInRole?: number; + maxTimeInRole?: number; +} + +interface AddLaneSheetProps { + isOpen: boolean; + onClose: () => void; + /** Add a structural lane (Division/Group/Branch/...) to the command board. */ + onSave: (name: string, nodeType: CommandNodeType, color?: string, limits?: LaneLimits) => void; +} + +export const AddLaneSheet: React.FC = ({ isOpen, onClose, onSave }) => { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [nodeType, setNodeType] = useState(CommandNodeType.Division); + const [color, setColor] = useState(undefined); + const [showLimits, setShowLimits] = useState(false); + const [limits, setLimits] = useState>({ minUnits: '', maxUnits: '', minUnitPersonnel: '', maxUnitPersonnel: '', minTimeInRole: '', maxTimeInRole: '' }); + + const setLimit = useCallback((key: keyof LaneLimits, value: string) => { + setLimits((current) => ({ ...current, [key]: value.replace(/[^0-9]/g, '') })); + }, []); + + const handleSave = useCallback(() => { + if (!name.trim()) { + return; + } + const parsedLimits: LaneLimits = {}; + (Object.keys(limits) as (keyof LaneLimits)[]).forEach((key) => { + const parsed = parseInt(limits[key], 10); + if (!Number.isNaN(parsed) && parsed > 0) { + parsedLimits[key] = parsed; + } + }); + onSave(name.trim(), nodeType, color, Object.keys(parsedLimits).length > 0 ? parsedLimits : undefined); + setName(''); + setNodeType(CommandNodeType.Division); + setColor(undefined); + setShowLimits(false); + setLimits({ minUnits: '', maxUnits: '', minUnitPersonnel: '', maxUnitPersonnel: '', minTimeInRole: '', maxTimeInRole: '' }); + onClose(); + }, [name, nodeType, color, limits, onSave, onClose]); + + return ( + + + {t('command.add_lane')} + + + {t('command.lane_type_label')} + + {COMMAND_NODE_TYPES.map((type) => ( + + ))} + + + + + {t('command.lane_name_label')} + + {LANE_NAME_SUGGESTION_KEYS.map((key) => ( + + ))} + + + + + + + + {t('command.lane_color_label')} + + {LANE_COLORS.map((swatch) => ( + setColor(color === swatch ? undefined : swatch)} + testID={`lane-color-${swatch.slice(1)}`} + > + {color === swatch ? : null} + + ))} + + + + + + {showLimits ? ( + + + + {t('command.limit_min_units')} + + setLimit('minUnits', v)} testID="limit-min-units" /> + + + + {t('command.limit_max_units')} + + setLimit('maxUnits', v)} testID="limit-max-units" /> + + + + {t('command.limit_units_help')} + + + {t('command.limit_min_riding')} + + setLimit('minUnitPersonnel', v)} testID="limit-min-riding" /> + + + + {t('command.limit_max_riding')} + + setLimit('maxUnitPersonnel', v)} testID="limit-max-riding" /> + + + + {t('command.limit_riding_help')} + + + {t('command.limit_min_time')} + + setLimit('minTimeInRole', v)} testID="limit-min-time" /> + + + + {t('command.limit_max_time')} + + setLimit('maxTimeInRole', v)} testID="limit-max-time" /> + + + + {t('command.limit_time_help')} + + ) : null} + + + + + + ); +}; + +const styles = StyleSheet.create({ + swatch: { + width: 32, + height: 32, + borderRadius: 16, + }, +}); diff --git a/src/components/command/add-resource-sheet.tsx b/src/components/command/add-resource-sheet.tsx new file mode 100644 index 0000000..d94353e --- /dev/null +++ b/src/components/command/add-resource-sheet.tsx @@ -0,0 +1,234 @@ +import { Check } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; +import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +export type AdHocResourceKind = 'unit' | 'person'; + +type ResourceTab = 'units' | 'personnel' | 'external'; + +interface AddResourceSheetProps { + isOpen: boolean; + onClose: () => void; + /** Department unit roster — the default resource pool. */ + units: UnitResultData[]; + /** Department personnel roster — the default resource pool. */ + personnel: PersonnelInfoResultData[]; + /** Live per-unit statuses, for the status badge on unit rows. */ + unitCurrentStatuses?: UnitStatusResultData[]; + /** Unit ids already tracked on this incident (rendered as added, not addable twice). */ + trackedUnitIds: string[]; + /** User ids already tracked on this incident. */ + trackedUserIds: string[]; + /** Track a department unit on the incident. */ + onAddUnit: (unitId: string) => void; + /** Track a department person on the incident. */ + onAddPersonnel: (userId: string) => void; + /** Add an external (non-Resgrid) unit or person: mutual aid, temp staff, volunteer, contractor. */ + onSaveExternal: (kind: AdHocResourceKind, name: string, detail?: string, agency?: string) => void; +} + +// eslint-disable-next-line max-lines-per-function +export const AddResourceSheet: React.FC = ({ isOpen, onClose, units, personnel, unitCurrentStatuses = [], trackedUnitIds, trackedUserIds, onAddUnit, onAddPersonnel, onSaveExternal }) => { + const { t } = useTranslation(); + const [tab, setTab] = useState('units'); + const [search, setSearch] = useState(''); + const [kind, setKind] = useState('unit'); + const [name, setName] = useState(''); + const [detail, setDetail] = useState(''); + const [agency, setAgency] = useState(''); + + const normalizedSearch = search.trim().toLowerCase(); + + const visibleUnits = useMemo(() => { + const list = normalizedSearch ? units.filter((u) => `${u.Name} ${u.Type} ${u.GroupName}`.toLowerCase().includes(normalizedSearch)) : units; + return list.slice(0, 50); + }, [units, normalizedSearch]); + + const visiblePersonnel = useMemo(() => { + const list = normalizedSearch ? personnel.filter((p) => `${p.FirstName} ${p.LastName} ${p.GroupName}`.toLowerCase().includes(normalizedSearch)) : personnel; + return list.slice(0, 50); + }, [personnel, normalizedSearch]); + + const trackedUnits = useMemo(() => new Set(trackedUnitIds), [trackedUnitIds]); + const trackedUsers = useMemo(() => new Set(trackedUserIds), [trackedUserIds]); + + const handleSaveExternal = useCallback(() => { + if (!name.trim()) { + return; + } + onSaveExternal(kind, name.trim(), detail.trim() || undefined, agency.trim() || undefined); + setName(''); + setDetail(''); + setAgency(''); + onClose(); + }, [kind, name, detail, agency, onSaveExternal, onClose]); + + const handleTabChange = useCallback((next: ResourceTab) => { + setTab(next); + setSearch(''); + }, []); + + return ( + + + {t('command.add_resource')} + + {/* Department units / personnel are the default; external covers mutual aid & non-Resgrid people */} + + + + + + + {tab !== 'external' ? ( + <> + + + + + + + {tab === 'units' + ? visibleUnits.map((unit) => { + const isTracked = trackedUnits.has(unit.UnitId); + const subtitle = [unit.Type, unit.GroupName].filter(Boolean).join(' • '); + const liveState = unitCurrentStatuses.find((s) => s.UnitId === unit.UnitId)?.State; + return ( + onAddUnit(unit.UnitId)} + testID={`roster-unit-${unit.UnitId}`} + > + + + {unit.Name} + {subtitle ? {subtitle} : null} + + {liveState ? ( + + {liveState} + + ) : null} + {isTracked ? : null} + + + ); + }) + : visiblePersonnel.map((person) => { + const isTracked = trackedUsers.has(person.UserId); + const subtitle = [person.GroupName, person.Status].filter(Boolean).join(' • '); + return ( + onAddPersonnel(person.UserId)} + testID={`roster-person-${person.UserId}`} + > + + + {person.StatusColor ? : null} + + {`${person.FirstName} ${person.LastName}`} + {subtitle ? {subtitle} : null} + + + {isTracked ? : null} + + {person.Roles && person.Roles.length > 0 ? ( + + {person.Roles.map((role) => ( + + {role} + + ))} + + ) : null} + + ); + })} + {(tab === 'units' ? visibleUnits : visiblePersonnel).length === 0 ? ( + {tab === 'units' ? t('command.no_resources_found') : t('command.no_personnel_found')} + ) : null} + + + + ) : ( + <> + {/* External unit vs external person */} + + + + + + + {kind === 'unit' ? t('command.resource_name_label') : t('command.person_label')} + + + + + + + {kind === 'unit' ? t('command.note_label') : t('command.person_role_label')} + + + + + + {kind === 'person' ? ( + + {t('command.agency_label')} + + + + + ) : null} + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + rosterList: { + maxHeight: 320, + }, + statusDot: { + width: 10, + height: 10, + borderRadius: 5, + }, +}); diff --git a/src/components/command/assign-resource-sheet.tsx b/src/components/command/assign-resource-sheet.tsx new file mode 100644 index 0000000..a0e8280 --- /dev/null +++ b/src/components/command/assign-resource-sheet.tsx @@ -0,0 +1,153 @@ +import { Check } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; + +export interface AssignableResourceOption { + kind: ResourceAssignmentKind; + id: string; + name: string; + /** Roster detail line: unit type/group or person group/status. */ + detail?: string; + /** Small chips: department roles for personnel. */ + chips?: string[]; + /** Live status text (units), rendered as a badge. */ + statusLabel?: string; + /** Lane the resource currently sits in: undefined = untracked, '' = unassigned pool, else node id. */ + assignedNodeId?: string | null; +} + +interface AssignResourceSheetProps { + isOpen: boolean; + onClose: () => void; + /** Department units, personnel, and ad-hoc resources that can be assigned to the lane. */ + options: AssignableResourceOption[]; + /** The lane being assigned to — options already in it are not selectable. */ + targetNodeId: string | null; + /** Resolves a node id to its display name (used for the "already in lane X" badge). */ + resolveLaneName: (nodeId?: string | null) => string; + onSave: (kind: ResourceAssignmentKind, resourceId: string) => void; +} + +const KIND_TABS: { labelKey: string; kinds: ResourceAssignmentKind[] }[] = [ + { labelKey: 'command.resource_kind_units', kinds: [ResourceAssignmentKind.RealUnit, ResourceAssignmentKind.LinkedDeptUnit] }, + { labelKey: 'command.resource_kind_personnel', kinds: [ResourceAssignmentKind.RealPersonnel, ResourceAssignmentKind.LinkedDeptPersonnel] }, + { labelKey: 'command.resource_kind_external', kinds: [ResourceAssignmentKind.AdHocUnit, ResourceAssignmentKind.AdHocPersonnel] }, +]; + +// eslint-disable-next-line max-lines-per-function +export const AssignResourceSheet: React.FC = ({ isOpen, onClose, options, targetNodeId, resolveLaneName, onSave }) => { + const { t } = useTranslation(); + const [tabIndex, setTabIndex] = useState(0); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(null); + + const visibleOptions = useMemo(() => { + const kinds = KIND_TABS[tabIndex].kinds; + const normalized = search.trim().toLowerCase(); + const list = options.filter((o) => kinds.includes(o.kind)); + return (normalized ? list.filter((o) => `${o.name} ${o.detail ?? ''}`.toLowerCase().includes(normalized)) : list).slice(0, 30); + }, [options, tabIndex, search]); + + const handleSave = useCallback(() => { + if (!selected) { + return; + } + onSave(selected.kind, selected.id); + setSelected(null); + setSearch(''); + onClose(); + }, [selected, onSave, onClose]); + + return ( + + + {t('command.assign_resource')} + + + {KIND_TABS.map((tab, index) => ( + + ))} + + + + + + + + + {visibleOptions.map((option) => { + const isSelected = selected?.id === option.id && selected?.kind === option.kind; + const inTargetLane = option.assignedNodeId != null && option.assignedNodeId !== '' && option.assignedNodeId === targetNodeId; + const inOtherLane = option.assignedNodeId != null && option.assignedNodeId !== '' && option.assignedNodeId !== targetNodeId; + const inPool = option.assignedNodeId === ''; + return ( + setSelected(option)} + > + + + {option.name} + {option.detail ? {option.detail} : null} + + {option.statusLabel ? ( + + {option.statusLabel} + + ) : null} + {inTargetLane ? ( + + + {t('command.in_this_lane')} + + ) : inOtherLane ? ( + + {resolveLaneName(option.assignedNodeId)} + + ) : inPool ? ( + + {t('command.unassigned')} + + ) : null} + + {option.chips && option.chips.length > 0 ? ( + + {option.chips.map((chip) => ( + + {chip} + + ))} + + ) : null} + + ); + })} + {visibleOptions.length === 0 ? {t('command.no_resources_found')} : null} + + + + + + + ); +}; diff --git a/src/components/command/command-section.tsx b/src/components/command/command-section.tsx new file mode 100644 index 0000000..9274de1 --- /dev/null +++ b/src/components/command/command-section.tsx @@ -0,0 +1,39 @@ +import { Plus } from 'lucide-react-native'; +import React from 'react'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; + +interface CommandSectionProps { + title: string; + count: number; + addLabel: string; + emptyText: string; + onAdd: () => void; + testID: string; + children?: React.ReactNode; +} + +/** Card wrapper for an ICS board section (roles, resources, accountability). */ +export const CommandSection: React.FC = ({ title, count, addLabel, emptyText, onAdd, testID, children }) => { + return ( + + + + {title} + ({count}) + + + + + {count === 0 ? {emptyText} : {children}} + + ); +}; diff --git a/src/components/command/landscape-structure-board.tsx b/src/components/command/landscape-structure-board.tsx new file mode 100644 index 0000000..931d521 --- /dev/null +++ b/src/components/command/landscape-structure-board.tsx @@ -0,0 +1,420 @@ +import { CloudOff, GripVertical, Plus, Trash2, UserPlus } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Animated, PanResponder, ScrollView, View as NativeView } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getCommandNodeTypeName } from '@/lib/incident-command-utils'; +import { isWeb } from '@/lib/platform'; +import { parseUtcMs } from '@/lib/utils'; +import type { CommandStructureNode, ResourceAssignment } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** + * Lane and resource cards are pressable containers that hold their own action buttons. + * On web a "button" role renders a semantic + + + + {selectedAssignment ? t('command.move_selected_hint', { resource: resolveResourceName(selectedAssignment.ResourceKind, selectedAssignment.ResourceId) }) : t('command.drag_move_hint')} + + + {activeNodes.length === 0 ? ( + {t('command.empty_structure')} + ) : ( + + + {activeNodes.map((node) => { + const laneAssignments = activeAssignments.filter((assignment) => assignment.CommandStructureNodeId === node.CommandStructureNodeId); + const laneUnitCount = laneAssignments.filter((a) => a.ResourceKind === 0 || a.ResourceKind === 2 || a.ResourceKind === 4).length; + const isUnderstaffed = !!node.MinUnits && laneUnitCount < node.MinUnits; + const isMoveTarget = selectedAssignment ? selectedAssignment.CommandStructureNodeId !== node.CommandStructureNodeId : false; + return ( + { + laneRefs.current[node.CommandStructureNodeId] = lane; + }} + style={{ minHeight: laneHeight, width: laneWidth }} + > + handleLanePress(node.CommandStructureNodeId)} + testID={`landscape-lane-${node.CommandStructureNodeId}`} + > + + + + {node.Name} + + + {getCommandNodeTypeName(t, node.NodeType)} + {node.MaxUnits ? ` • ${t('command.lane_unit_capacity', { count: laneUnitCount, max: node.MaxUnits })}` : ''} + + + + {isUnderstaffed ? ( + + {t('command.lane_understaffed', { count: laneUnitCount, min: node.MinUnits })} + + ) : null} + {node.CommandStructureNodeId.startsWith('local-') ? : null} + onAssignResource(node.CommandStructureNodeId)} + testID={`landscape-lane-assign-${node.CommandStructureNodeId}`} + > + + + onDeleteLane(node.CommandStructureNodeId)} + testID={`landscape-lane-delete-${node.CommandStructureNodeId}`} + > + + + + + + {laneAssignments.length === 0 ? ( + {t('command.no_resources_in_lane')} + ) : ( + + {laneAssignments.map((assignment) => ( + setDraggingAssignmentId(null)} + onDragStart={setDraggingAssignmentId} + onDrop={(assignmentId, pageX, pageY) => void handleDrop(assignmentId, pageX, pageY)} + onRelease={onReleaseResource} + onSelect={handleSelect} + /> + ))} + + )} + {draggingAssignmentId && laneAssignments.every((assignment) => assignment.ResourceAssignmentId !== draggingAssignmentId) ? ( + + ) : null} + + + ); + })} + + + )} + + ); +}; diff --git a/src/components/command/objectives-section.tsx b/src/components/command/objectives-section.tsx new file mode 100644 index 0000000..434b04a --- /dev/null +++ b/src/components/command/objectives-section.tsx @@ -0,0 +1,118 @@ +import { CheckCircle2, Circle, CloudOff, Plus } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getObjectiveTypeName, OBJECTIVE_TYPES } from '@/lib/incident-command-utils'; +import { type TacticalObjective, TacticalObjectiveStatus, TacticalObjectiveType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +interface ObjectivesSectionProps { + objectives: TacticalObjective[]; + onAdd: (name: string, objectiveType: TacticalObjectiveType) => void; + onComplete: (tacticalObjectiveId: string) => void; +} + +/** Tactical objectives / benchmarks with completion tracking. */ +export const ObjectivesSection: React.FC = ({ objectives, onAdd, onComplete }) => { + const { t } = useTranslation(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [name, setName] = useState(''); + const [objectiveType, setObjectiveType] = useState(TacticalObjectiveType.General); + + const sorted = [...objectives].sort((a, b) => a.SortOrder - b.SortOrder); + const completedCount = sorted.filter((o) => o.Status === TacticalObjectiveStatus.Complete).length; + + const handleSave = useCallback(() => { + if (!name.trim()) { + return; + } + onAdd(name.trim(), objectiveType); + setName(''); + setObjectiveType(TacticalObjectiveType.General); + setIsSheetOpen(false); + }, [name, objectiveType, onAdd]); + + return ( + + + + {t('command.objectives_section')} + + ({completedCount}/{sorted.length}) + + + + + + {sorted.length === 0 ? ( + {t('command.empty_objectives')} + ) : ( + + {sorted.map((objective) => { + const isComplete = objective.Status === TacticalObjectiveStatus.Complete; + return ( + + (isComplete ? undefined : onComplete(objective.TacticalObjectiveId))} className="flex-1" testID={`objective-complete-${objective.TacticalObjectiveId}`} disabled={isComplete}> + + + + {objective.Name} + {getObjectiveTypeName(t, objective.ObjectiveType)} + + {objective.TacticalObjectiveId.startsWith('local-') ? : null} + + + {isComplete ? ( + + {t('command.objective_completed')} + + ) : null} + + ); + })} + + )} + + setIsSheetOpen(false)} snapPoints={[55]}> + + {t('command.add_objective')} + + + {t('command.objective_type_label')} + + {OBJECTIVE_TYPES.map((type) => ( + + ))} + + + + + {t('command.objective_name_label')} + + + + + + + + + + ); +}; diff --git a/src/components/command/resource-cards.tsx b/src/components/command/resource-cards.tsx new file mode 100644 index 0000000..e7d2fdb --- /dev/null +++ b/src/components/command/resource-cards.tsx @@ -0,0 +1,199 @@ +import { CloudOff, MapPin, Trash2, Truck } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import { isWeb } from '@/lib/platform'; +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import type { ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; +import type { UnitResultData } from '@/models/v4/units/unitResultData'; +import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; + +/** Server status colors arrive as hex ('#3498db') or legacy css labels — only trust hex. */ +const asHexColor = (value?: string | null) => (value && /^#[0-9a-fA-F]{3,8}$/.test(value.trim()) ? value.trim() : undefined); + +/** One-line truncation: numberOfLines leaks to the DOM through the styling pipeline on web, so use CSS ellipsis there. */ +const oneLine = isWeb ? ({ isTruncated: true } as const) : ({ numberOfLines: 1 } as const); + +const ServerColorBadge: React.FC<{ label: string; color?: string | null; testID?: string }> = ({ label, color, testID }) => { + const hex = asHexColor(color); + return ( + + {label} + + ); +}; + +const LaneBadge: React.FC<{ label: string }> = ({ label }) => ( + + {label} + +); + +interface CardShellProps { + isLocal: boolean; + onRelease: () => void; + testID: string; + removeTestID: string; + children: React.ReactNode; +} + +/** Shared card chrome: rounded container + offline marker + release button. */ +const CardShell: React.FC = ({ isLocal, onRelease, testID, removeTestID, children }) => { + const { t } = useTranslation(); + return ( + + + {children} + + + {isLocal ? : null} + + + + + + ); +}; + +interface UnitResourceCardProps { + /** Display name (falls back to the raw resource id upstream). */ + name: string; + unit?: UnitResultData; + /** Live status snapshot for this unit, when loaded. */ + status?: UnitStatusResultData; + /** This unit's role seats with their current assignees (FullName empty = open). */ + roles: ActiveUnitRoleResultData[]; + laneLabel: string; + isLocal: boolean; + onRelease: () => void; + testID: string; + removeTestID: string; +} + +export const UnitResourceCard: React.FC = ({ name, unit, status, roles, laneLabel, isLocal, onRelease, testID, removeTestID }) => { + const { t } = useTranslation(); + const subtitle = [unit?.Type, unit?.GroupName].filter(Boolean).join(' • '); + const filledRoles = roles.filter((role) => !!role.FullName || !!role.UserId); + + return ( + + + + + + + + {name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {status?.State ? : null} + + + {status?.DestinationName || status?.Eta ? ( + + + + {[status?.DestinationName, status?.Eta ? t('command.unit_eta', { eta: status.Eta }) : ''].filter(Boolean).join(' • ')} + + + ) : null} + + {roles.length > 0 ? ( + + {t('command.unit_roles_count', { filled: filledRoles.length, total: roles.length })} + {roles.map((role) => ( + + + {role.Name} + + + {role.FullName || t('command.role_open')} + + + ))} + + ) : null} + + + + + + ); +}; + +interface PersonnelResourceCardProps { + name: string; + person?: PersonnelInfoResultData; + laneLabel: string; + isLocal: boolean; + onRelease: () => void; + testID: string; + removeTestID: string; +} + +export const PersonnelResourceCard: React.FC = ({ name, person, laneLabel, isLocal, onRelease, testID, removeTestID }) => { + const initials = person ? `${person.FirstName?.[0] ?? ''}${person.LastName?.[0] ?? ''}`.toUpperCase() : name.slice(0, 2).toUpperCase(); + const subtitle = [person?.GroupName, person?.IdentificationNumber].filter(Boolean).join(' • '); + const statusDot = asHexColor(person?.StatusColor); + + return ( + + + + {initials} + {statusDot ? : null} + + + + {name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {person?.Status ? : null} + {person?.Staffing ? : null} + + + + + {(person?.Roles ?? []).map((role) => ( + + {role} + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + badgeFallback: { + backgroundColor: '#6b7280', + }, + statusDot: { + position: 'absolute', + right: -1, + bottom: -1, + width: 10, + height: 10, + borderRadius: 5, + borderWidth: 1.5, + borderColor: '#ffffff', + }, +}); diff --git a/src/components/command/start-command-sheet.tsx b/src/components/command/start-command-sheet.tsx new file mode 100644 index 0000000..63e14eb --- /dev/null +++ b/src/components/command/start-command-sheet.tsx @@ -0,0 +1,109 @@ +import { ClipboardList, LayoutTemplate } from 'lucide-react-native'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { type CommandResultData, getAllCommands } from '@/api/incidentCommand/commands'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; + +interface StartCommandSheetProps { + isOpen: boolean; + onClose: () => void; + /** Start the board — optionally from a department command template (null = blank board). */ + onStart: (commandDefinitionId: number | null) => void; +} + +/** + * Template picker shown when starting a command. Departments define templates per + * operation type — an ICS structure for a fire, or an event template with lanes + * like Medical Booth / Patrols / Customer Service for a concert or fair. + */ +export const StartCommandSheet: React.FC = ({ isOpen, onClose, onStart }) => { + const { t } = useTranslation(); + const [templates, setTemplates] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + if (!isOpen || isLoaded) { + return; + } + getAllCommands() + .then((result) => { + setTemplates(Array.isArray(result.Data) ? result.Data : []); + setIsLoaded(true); + }) + .catch((error) => { + // Offline or endpoint unavailable — blank board stays available + logger.warn({ message: 'Failed to load command templates', context: { error } }); + setIsLoaded(true); + }); + }, [isOpen, isLoaded]); + + const handleStart = (commandDefinitionId: number | null) => { + onClose(); + onStart(commandDefinitionId); + }; + + return ( + + + {t('command.start_command_title')} + {t('command.start_command_description')} + + + + handleStart(null)} testID="template-blank"> + + + + {t('command.template_blank')} + {t('command.template_blank_description')} + + + + + {templates.map((template) => ( + handleStart(template.CommandDefinitionId)} + testID={`template-${template.CommandDefinitionId}`} + > + + + + {template.Name} + {template.Description ? {template.Description} : null} + {t('command.template_lane_count', { count: template.Lanes?.length ?? 0 })} + {(template.Lanes ?? []).some((lane) => lane.Color) ? ( + + {(template.Lanes ?? []) + .filter((lane) => lane.Color) + .slice(0, 8) + .map((lane) => ( + + ))} + + ) : null} + + + + ))} + + + + + ); +}; diff --git a/src/components/command/structure-section.tsx b/src/components/command/structure-section.tsx new file mode 100644 index 0000000..3b3d1e7 --- /dev/null +++ b/src/components/command/structure-section.tsx @@ -0,0 +1,166 @@ +import { CloudOff, Plus, Trash2, UserPlus } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getCommandNodeTypeName } from '@/lib/incident-command-utils'; +import { type CommandStructureNode, type ResourceAssignment } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { WorkTimeLight } from './landscape-structure-board'; + +interface StructureSectionProps { + nodes: CommandStructureNode[]; + assignments: ResourceAssignment[]; + resolveResourceName: (kind: number, resourceId: string) => string; + onAddLane: () => void; + onDeleteLane: (nodeId: string) => void; + onAssignResource: (nodeId: string) => void; + onMoveResource: (assignmentId: string, targetNodeId: string) => void | Promise; + onReleaseResource: (assignmentId: string) => void; +} + +/** ICS command structure — lanes (Division/Group/Branch/...) with their assigned resources. */ +export const StructureSection: React.FC = ({ nodes, assignments, resolveResourceName, onAddLane, onDeleteLane, onAssignResource, onMoveResource, onReleaseResource }) => { + const { t } = useTranslation(); + const [selectedAssignmentId, setSelectedAssignmentId] = useState(null); + + const activeNodes = nodes.filter((n) => !n.DeletedOn).sort((a, b) => a.SortOrder - b.SortOrder); + const activeAssignments = assignments.filter((a) => !a.ReleasedOn); + const selectedAssignment = activeAssignments.find((assignment) => assignment.ResourceAssignmentId === selectedAssignmentId); + + const handleSelectResource = useCallback((assignmentId: string) => { + setSelectedAssignmentId((current) => (current === assignmentId ? null : assignmentId)); + }, []); + + const handleLanePress = useCallback( + async (targetNodeId: string) => { + if (!selectedAssignmentId) { + return; + } + const assignment = activeAssignments.find((item) => item.ResourceAssignmentId === selectedAssignmentId); + setSelectedAssignmentId(null); + if (assignment && assignment.CommandStructureNodeId !== targetNodeId) { + await onMoveResource(assignment.ResourceAssignmentId, targetNodeId); + } + }, + [activeAssignments, onMoveResource, selectedAssignmentId] + ); + + return ( + + + + {t('command.structure_section')} + ({activeNodes.length}) + + + + + {activeAssignments.length > 0 ? ( + + {selectedAssignment ? t('command.move_selected_hint', { resource: resolveResourceName(selectedAssignment.ResourceKind, selectedAssignment.ResourceId) }) : t('command.drag_move_hint')} + + ) : null} + + {activeNodes.length === 0 ? ( + {t('command.empty_structure')} + ) : ( + + {activeNodes.map((node) => { + const laneAssignments = activeAssignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId); + const laneUnitCount = laneAssignments.filter((a) => a.ResourceKind === 0 || a.ResourceKind === 2 || a.ResourceKind === 4).length; + const isUnderstaffed = !!node.MinUnits && laneUnitCount < node.MinUnits; + return ( + void handleLanePress(node.CommandStructureNodeId)} + testID={`lane-${node.CommandStructureNodeId}`} + > + + + + {node.Name} + {isUnderstaffed ? ( + + {t('command.lane_understaffed', { count: laneUnitCount, min: node.MinUnits })} + + ) : null} + {node.CommandStructureNodeId.startsWith('local-') ? : null} + + + {getCommandNodeTypeName(t, node.NodeType)} + {node.MaxUnits ? ` • ${t('command.lane_unit_capacity', { count: laneUnitCount, max: node.MaxUnits })}` : ''} + + + + onAssignResource(node.CommandStructureNodeId)} className="p-2" testID={`lane-assign-${node.CommandStructureNodeId}`}> + + + onDeleteLane(node.CommandStructureNodeId)} className="p-2" testID={`lane-delete-${node.CommandStructureNodeId}`}> + + + + + + {laneAssignments.length === 0 ? ( + {t('command.no_resources_in_lane')} + ) : ( + + {laneAssignments.map((assignment) => { + const isSelected = selectedAssignmentId === assignment.ResourceAssignmentId; + return ( + setSelectedAssignmentId(assignment.ResourceAssignmentId)} + onPress={() => handleSelectResource(assignment.ResourceAssignmentId)} + testID={`lane-resource-${assignment.ResourceAssignmentId}`} + > + + + {resolveResourceName(assignment.ResourceKind, assignment.ResourceId)} + {assignment.ResourceAssignmentId.startsWith('local-') ? : null} + {assignment.RequirementsWarning ? ( + + {t('command.requirements_warning')} + + ) : null} + {isSelected ? ( + + {t('command.selected')} + + ) : null} + + + onReleaseResource(assignment.ResourceAssignmentId)} className="p-1" testID={`lane-resource-release-${assignment.ResourceAssignmentId}`}> + + + + + ); + })} + + )} + + ); + })} + + )} + + ); +}; diff --git a/src/components/command/timeline-section.tsx b/src/components/command/timeline-section.tsx new file mode 100644 index 0000000..97db826 --- /dev/null +++ b/src/components/command/timeline-section.tsx @@ -0,0 +1,99 @@ +import { RefreshCw } from 'lucide-react-native'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { parseUtcMs } from '@/lib/utils'; +import type { CommandLogEntry } from '@/models/v4/incidentCommand/incidentCommandModels'; + +const VISIBLE_BATCH = 15; + +interface TimelineSectionProps { + entries: CommandLogEntry[]; + onRefresh: () => void; +} + +const formatTime = (iso: string) => { + const ms = parseUtcMs(iso); + return ms === null ? '' : new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +}; + +/** Auto-logged, time-stamped incident log — every board action the server records. */ +export const TimelineSection: React.FC = ({ entries, onRefresh }) => { + const { t } = useTranslation(); + const [visibleCount, setVisibleCount] = useState(VISIBLE_BATCH); + + const visible = entries.slice(0, visibleCount); + + return ( + + + + {t('command.timeline_section')} + ({entries.length}) + + + + + {entries.length === 0 ? ( + {t('command.empty_timeline')} + ) : ( + + {visible.map((entry) => ( + + {formatTime(entry.OccurredOn)} + {entry.Description} + + ))} + {entries.length > visibleCount ? ( + + ) : null} + + )} + + ); +}; + +interface SceneClockProps { + /** ISO timestamp the incident clock counts from (call logged time). */ + startedOn?: string | null; +} + +/** Master scene timer — elapsed incident time, ticking every second (Tablet Command style). */ +export const SceneClock: React.FC = ({ startedOn }) => { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!startedOn) { + return; + } + const interval = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(interval); + }, [startedOn]); + + const startMs = parseUtcMs(startedOn); + if (startMs === null) { + return null; + } + + const totalSeconds = Math.max(0, Math.floor((nowMs - startMs) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const text = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + return ( + + {text} + + ); +}; diff --git a/src/components/command/timers-section.tsx b/src/components/command/timers-section.tsx new file mode 100644 index 0000000..ef50034 --- /dev/null +++ b/src/components/command/timers-section.tsx @@ -0,0 +1,143 @@ +import { AlarmClock, Check, Plus } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { parseUtcMs } from '@/lib/utils'; +import type { IncidentTimer } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** IncidentTimerStatus.Stopped — stopped timers are hidden. */ +const TIMER_STATUS_STOPPED = 3; + +const INTERVAL_PRESETS_MINUTES = [10, 15, 20]; + +interface TimersSectionProps { + timers: IncidentTimer[]; + onStartTimer: (name: string, intervalSeconds: number) => void; + onAcknowledge: (incidentTimerId: string) => void; +} + +const formatClock = (totalSeconds: number) => { + const s = Math.abs(Math.floor(totalSeconds)); + const hours = Math.floor(s / 3600); + const minutes = Math.floor((s % 3600) / 60); + const seconds = s % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +}; + +/** PAR / benchmark reminder timers with live countdowns — mirrors Tablet Command's PAR alerting. */ +// eslint-disable-next-line max-lines-per-function +export const TimersSection: React.FC = ({ timers, onStartTimer, onAcknowledge }) => { + const { t } = useTranslation(); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [isAdding, setIsAdding] = useState(false); + const [name, setName] = useState(''); + const [minutes, setMinutes] = useState(15); + + const activeTimers = useMemo(() => timers.filter((timer) => timer.Status !== TIMER_STATUS_STOPPED), [timers]); + + // One ticking clock for every countdown in the section + useEffect(() => { + if (activeTimers.length === 0) { + return; + } + const interval = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(interval); + }, [activeTimers.length]); + + const handleStart = useCallback(() => { + const trimmed = name.trim() || t('command.timer_default_name'); + onStartTimer(trimmed, minutes * 60); + setName(''); + setMinutes(15); + setIsAdding(false); + }, [name, minutes, onStartTimer, t]); + + return ( + + + + {t('command.timers_section')} + ({activeTimers.length}) + + + + + {isAdding ? ( + + + + + + {INTERVAL_PRESETS_MINUTES.map((preset) => ( + + ))} + + + + ) : null} + + {activeTimers.length === 0 ? ( + {t('command.empty_timers')} + ) : ( + + {activeTimers.map((timer) => { + const dueMs = parseUtcMs(timer.NextDueOn); + const remainingSeconds = dueMs !== null ? Math.round((dueMs - nowMs) / 1000) : null; + const isOverdue = remainingSeconds !== null && remainingSeconds <= 0; + return ( + + + + + {timer.Name} + + {remainingSeconds === null ? '' : isOverdue ? t('command.timer_overdue_by', { time: formatClock(remainingSeconds) }) : t('command.timer_due_in', { time: formatClock(remainingSeconds) })} + + + + + {isOverdue ? ( + + {t('command.timer_overdue')} + + ) : null} + + + + ); + })} + + )} + + ); +}; diff --git a/src/components/command/transfer-command-sheet.tsx b/src/components/command/transfer-command-sheet.tsx new file mode 100644 index 0000000..03b0b9d --- /dev/null +++ b/src/components/command/transfer-command-sheet.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; + +interface TransferCommandSheetProps { + isOpen: boolean; + onClose: () => void; + personnel: PersonnelInfoResultData[]; + /** User id of the current incident commander — shown as such and not selectable. */ + currentCommanderUserId?: string; + onTransfer: (toUserId: string) => void; +} + +/** Hand incident command to another user — Tablet Command's "transfer of command". */ +export const TransferCommandSheet: React.FC = ({ isOpen, onClose, personnel, currentCommanderUserId, onTransfer }) => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(null); + + const visiblePersonnel = useMemo(() => { + const normalized = search.trim().toLowerCase(); + const list = normalized ? personnel.filter((p) => `${p.FirstName} ${p.LastName} ${p.GroupName}`.toLowerCase().includes(normalized)) : personnel; + return list.slice(0, 50); + }, [personnel, search]); + + const handleTransfer = useCallback(() => { + if (!selectedUserId) { + return; + } + onTransfer(selectedUserId); + setSelectedUserId(null); + setSearch(''); + onClose(); + }, [selectedUserId, onTransfer, onClose]); + + return ( + + + {t('command.transfer_command')} + {t('command.transfer_command_hint')} + + + + + + + + {visiblePersonnel.map((person) => { + const isCommander = person.UserId === currentCommanderUserId; + const isSelected = selectedUserId === person.UserId; + const subtitle = [person.GroupName, person.Status].filter(Boolean).join(' • '); + return ( + setSelectedUserId(person.UserId)} + testID={`transfer-person-${person.UserId}`} + > + + + {`${person.FirstName} ${person.LastName}`} + {subtitle ? {subtitle} : null} + + {isCommander ? ( + + {t('command.current_commander')} + + ) : null} + + + ); + })} + {visiblePersonnel.length === 0 ? {t('command.no_personnel_found')} : null} + + + + + + + ); +}; diff --git a/src/components/command/voice-section.tsx b/src/components/command/voice-section.tsx new file mode 100644 index 0000000..f99d64a --- /dev/null +++ b/src/components/command/voice-section.tsx @@ -0,0 +1,215 @@ +import { Mic, MicOff, Phone, PhoneOff, Plus, RadioTower } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { parseUtcMs } from '@/lib/utils'; +import type { IncidentVoiceChannel, VoiceTransmissionLog } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +/** Quick-create channel name keys — the two channels every incident wants first. */ +const CHANNEL_PRESET_KEYS = ['channel_preset_tactical', 'channel_preset_command']; + +interface VoiceSectionProps { + callId: string; + channels: IncidentVoiceChannel[]; + transmissionLog: VoiceTransmissionLog[]; + personName: (userId: string) => string; + onCreateChannel: (name: string) => void; + onCloseChannels: () => void; + /** Record one completed local PTT transmission (start/end ISO strings). */ + onTransmission: (departmentVoiceChannelId: string, startedOn: string, endedOn: string) => void; +} + +const formatLogTime = (iso: string) => { + const ms = parseUtcMs(iso); + return ms === null ? '' : new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +}; + +const transmissionSeconds = (log: VoiceTransmissionLog) => { + const start = parseUtcMs(log.StartedOn); + const end = parseUtcMs(log.EndedOn); + if (start === null || end === null) { + return null; + } + return Math.max(0, Math.round((end - start) / 1000)); +}; + +/** Tactical/command PTT channels for the incident (Resgrid Voice/LiveKit addon) plus the transmission log. */ +// eslint-disable-next-line max-lines-per-function +export const VoiceSection: React.FC = ({ callId, channels, transmissionLog, personName, onCreateChannel, onCloseChannels, onTransmission }) => { + const { t } = useTranslation(); + const [isAdding, setIsAdding] = useState(false); + const [channelName, setChannelName] = useState(''); + + const availableRooms = useLiveKitStore((state) => state.availableRooms); + const currentRoomInfo = useLiveKitStore((state) => state.currentRoomInfo); + const isMicrophoneEnabled = useLiveKitStore((state) => state.isMicrophoneEnabled); + const connectToRoom = useLiveKitStore((state) => state.connectToRoom); + const disconnectFromRoom = useLiveKitStore((state) => state.disconnectFromRoom); + const setMicrophoneEnabled = useLiveKitStore((state) => state.setMicrophoneEnabled); + const fetchVoiceSettings = useLiveKitStore((state) => state.fetchVoiceSettings); + + // Voice settings carry the LiveKit tokens for every channel (incident channels included) + useEffect(() => { + if (channels.length > 0 && availableRooms.length === 0) { + fetchVoiceSettings(); + } + }, [channels.length, availableRooms.length, fetchVoiceSettings]); + + const connectedChannel = channels.find((c) => currentRoomInfo?.Id === c.DepartmentVoiceChannelId); + + // Local PTT transmission log: mic-on → mic-off on an incident channel = one transmission + const transmitStartRef = useRef(null); + useEffect(() => { + if (!connectedChannel) { + transmitStartRef.current = null; + return; + } + if (isMicrophoneEnabled && !transmitStartRef.current) { + transmitStartRef.current = new Date().toISOString(); + } else if (!isMicrophoneEnabled && transmitStartRef.current) { + const startedOn = transmitStartRef.current; + transmitStartRef.current = null; + onTransmission(connectedChannel.DepartmentVoiceChannelId, startedOn, new Date().toISOString()); + } + }, [isMicrophoneEnabled, connectedChannel, onTransmission]); + + const handleJoin = useCallback( + async (channel: IncidentVoiceChannel) => { + let room = availableRooms.find((r) => r.Id === channel.DepartmentVoiceChannelId); + if (!room) { + await fetchVoiceSettings(); + room = useLiveKitStore.getState().availableRooms.find((r) => r.Id === channel.DepartmentVoiceChannelId); + } + if (room?.Token) { + await connectToRoom(room, room.Token); + } + }, + [availableRooms, connectToRoom, fetchVoiceSettings] + ); + + const handleCreate = useCallback(() => { + const trimmed = channelName.trim(); + if (!trimmed) { + return; + } + onCreateChannel(trimmed); + setChannelName(''); + setIsAdding(false); + }, [channelName, onCreateChannel]); + + return ( + + + + {t('command.voice_section')} + ({channels.length}) + + + {channels.length > 0 ? ( + + ) : null} + + + + + {isAdding ? ( + + + {CHANNEL_PRESET_KEYS.map((key) => ( + + ))} + + + + + + + + + ) : null} + + {channels.length === 0 ? ( + {t('command.empty_channels')} + ) : ( + + {channels.map((channel) => { + const isConnected = connectedChannel?.DepartmentVoiceChannelId === channel.DepartmentVoiceChannelId; + return ( + + + + {channel.Name} + {isConnected ? ( + + {t('command.channel_connected')} + + ) : null} + + + {isConnected ? ( + <> + + + + ) : ( + + )} + + + ); + })} + + )} + + {transmissionLog.length > 0 ? ( + + {t('command.transmission_log')} + {transmissionLog.slice(0, 10).map((log) => { + const seconds = transmissionSeconds(log); + const channelLabel = channels.find((c) => c.DepartmentVoiceChannelId === log.DepartmentVoiceChannelId)?.Name ?? ''; + return ( + + {formatLogTime(log.StartedOn)} + {personName(log.UserId)} + {channelLabel ? {channelLabel} : null} + {seconds !== null ? {t('command.transmission_seconds', { count: seconds })} : null} + + ); + })} + + ) : null} + + ); +}; diff --git a/src/components/common/offline-status-bar.tsx b/src/components/common/offline-status-bar.tsx new file mode 100644 index 0000000..1761e2a --- /dev/null +++ b/src/components/common/offline-status-bar.tsx @@ -0,0 +1,57 @@ +import { CloudOff, RefreshCw } from 'lucide-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { QueuedEventStatus } from '@/models/offline-queue/queued-event'; +import { offlineEventManager } from '@/services/offline-event-manager.service'; +import { useOfflineQueueStore } from '@/stores/offline-queue/store'; + +/** + * Thin status bar shown above the tab content: + * - offline: warns that data is stale and writes are queued + * - online with queued writes: shows the pending count and a manual "Sync Now" action + */ +export const OfflineStatusBar: React.FC = () => { + const { t } = useTranslation(); + const isConnected = useOfflineQueueStore((state) => state.isConnected); + const isNetworkReachable = useOfflineQueueStore((state) => state.isNetworkReachable); + const queuedEvents = useOfflineQueueStore((state) => state.queuedEvents); + const isProcessing = useOfflineQueueStore((state) => state.isProcessing); + + const pendingCount = useMemo(() => queuedEvents.filter((event) => event.status !== QueuedEventStatus.COMPLETED).length, [queuedEvents]); + + const isOffline = !isConnected || !isNetworkReachable; + + const handleSyncNow = useCallback(() => { + offlineEventManager.syncNow(); + }, []); + + if (isOffline) { + return ( + + + {pendingCount > 0 ? t('offline.offline_with_pending', { count: pendingCount }) : t('offline.offline_message')} + + ); + } + + if (pendingCount > 0) { + return ( + + {t('offline.pending_count', { count: pendingCount })} + + + + {isProcessing ? t('offline.syncing') : t('offline.sync_now')} + + + + ); + } + + return null; +}; diff --git a/src/components/maps/__tests__/command-marker-actions.test.tsx b/src/components/maps/__tests__/command-marker-actions.test.tsx new file mode 100644 index 0000000..91fa850 --- /dev/null +++ b/src/components/maps/__tests__/command-marker-actions.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const mockMoveResourceAssignment = jest.fn().mockResolvedValue(null); +const mockReleaseResourceAssignment = jest.fn().mockResolvedValue(undefined); +const mockAssignResourceToNode = jest.fn().mockResolvedValue(null); + +let mockCommandState: Record; + +jest.mock('@/stores/command/store', () => ({ + useCommandStore: (selector: any) => selector(mockCommandState), +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: (selector: any) => selector({ showToast: jest.fn() }), +})); + +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; + +import { CommandMarkerActions } from '../command-marker-actions'; + +const board = { + Command: { IncidentCommandId: 'cmd-1' }, + Nodes: [ + { CommandStructureNodeId: 'lane-1', Name: 'Division A', Color: '#e74c3c', NodeType: 0, SortOrder: 0 }, + { CommandStructureNodeId: 'lane-2', Name: 'Medical', Color: null, NodeType: 2, SortOrder: 1 }, + ], + Assignments: [{ ResourceAssignmentId: 'as-1', CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: '5', AssignedOn: '2026-07-19T10:00:00Z' }], +}; + +const unitPin = { Id: 'u5', Title: 'Engine 1', Latitude: 39, Longitude: -119, Type: 1 } as MapMakerInfoData; +const untrackedPin = { Id: 'u9', Title: 'Brush 1', Latitude: 39, Longitude: -119, Type: 1 } as MapMakerInfoData; +const poiPin = { Id: 'poi-1', Title: 'Hydrant', Latitude: 39, Longitude: -119, Type: 4 } as MapMakerInfoData; + +describe('CommandMarkerActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCommandState = { + boards: { '101': { callId: '101', board } }, + activeCallId: '101', + moveResourceAssignment: mockMoveResourceAssignment, + releaseResourceAssignment: mockReleaseResourceAssignment, + assignResourceToNode: mockAssignResourceToNode, + }; + }); + + it('shows lane chips for a tracked unit and moves it to a tapped lane', async () => { + const onDone = jest.fn(); + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('Division A')).toBeTruthy(); + fireEvent.press(getByTestId('marker-lane-lane-2')); + await waitFor(() => expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'as-1', 'lane-2')); + expect(onDone).toHaveBeenCalled(); + + unmount(); + }); + + it('releases a tracked resource from the command', async () => { + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('marker-release')); + await waitFor(() => expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-1')); + + unmount(); + }); + + it('offers Add to Command for an untracked department unit', async () => { + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('marker-add-to-command')); + await waitFor(() => expect(mockAssignResourceToNode).toHaveBeenCalledWith('101', '', 0, '9')); + + unmount(); + }); + + it('renders nothing for non-resource markers or without an active board', () => { + const { queryByTestId, rerender, unmount } = render(); + expect(queryByTestId('command-marker-actions')).toBeNull(); + + mockCommandState = { ...mockCommandState, activeCallId: null }; + rerender(); + expect(queryByTestId('command-marker-actions')).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/maps/command-marker-actions.tsx b/src/components/maps/command-marker-actions.tsx new file mode 100644 index 0000000..da26abb --- /dev/null +++ b/src/components/maps/command-marker-actions.tsx @@ -0,0 +1,153 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { VStack } from '@/components/ui/vstack'; +import { useCommandMapOverlay } from '@/hooks/use-command-map-overlay'; +import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useCommandStore } from '@/stores/command/store'; +import { useToastStore } from '@/stores/toast/store'; + +interface CommandMarkerActionsProps { + pin: MapMakerInfoData; + /** Close the parent sheet after a successful action. */ + onDone?: () => void; +} + +/** + * Command-board actions for a tapped unit/personnel map marker while a command is active: + * shows the resource's lane, moves it between lanes, releases it, or adds an untracked + * department resource to the board. + */ +export const CommandMarkerActions: React.FC = ({ pin, onDone }) => { + const { t } = useTranslation(); + const overlay = useCommandMapOverlay(); + const boards = useCommandStore((state) => state.boards); + const activeCallId = useCommandStore((state) => state.activeCallId); + const moveResourceAssignment = useCommandStore((state) => state.moveResourceAssignment); + const releaseResourceAssignment = useCommandStore((state) => state.releaseResourceAssignment); + const assignResourceToNode = useCommandStore((state) => state.assignResourceToNode); + const showToast = useToastStore((state) => state.showToast); + + const board = activeCallId ? boards[activeCallId]?.board : undefined; + const info = overlay[pin.Id]; + // GetMapDataAndMarkers: units are Type 1 with `u{unitId}` ids, personnel Type 3 with `p{userId}` — + // prefix alone is not enough (POI ids can also start with 'p'). + const isUnitMarker = pin.Type === 1 && pin.Id.startsWith('u'); + const isPersonnelMarker = pin.Type === 3 && pin.Id.startsWith('p'); + + const lanes = (board?.Nodes ?? []).filter((n) => !n.DeletedOn); + + const handleMove = useCallback( + async (targetNodeId: string) => { + if (!activeCallId || !info || info.nodeId === targetNodeId) { + return; + } + const outcome = await moveResourceAssignment(activeCallId, info.assignmentId, targetNodeId); + if (outcome?.blocked) { + showToast('error', outcome.blocked); + return; + } + if (outcome?.warning) { + showToast('warning', outcome.warning); + } + onDone?.(); + }, + [activeCallId, info, moveResourceAssignment, showToast, onDone] + ); + + const handleRelease = useCallback(async () => { + if (!activeCallId || !info) { + return; + } + await releaseResourceAssignment(activeCallId, info.assignmentId); + onDone?.(); + }, [activeCallId, info, releaseResourceAssignment, onDone]); + + const handleAddToCommand = useCallback(async () => { + if (!activeCallId) { + return; + } + const kind = isUnitMarker ? ResourceAssignmentKind.RealUnit : ResourceAssignmentKind.RealPersonnel; + const outcome = await assignResourceToNode(activeCallId, '', kind, pin.Id.slice(1)); + if (outcome?.blocked) { + showToast('error', outcome.blocked); + return; + } + if (outcome?.warning) { + showToast('warning', outcome.warning); + } + showToast('success', t('command.added_to_command')); + }, [activeCallId, isUnitMarker, assignResourceToNode, pin.Id, showToast, t]); + + // Only unit/personnel markers get command actions, and only while a board is active + if (!board || (!isUnitMarker && !isPersonnelMarker)) { + return null; + } + + return ( + + {t('command.map_command_section')} + + {info ? ( + <> + + {/* Unassigned pool chip + one chip per lane; the current location is highlighted */} + handleMove('')} + disabled={info.nodeId === ''} + testID="marker-lane-unassigned" + > + {t('command.unassigned')} + + {lanes.map((lane) => { + const isCurrent = info.nodeId === lane.CommandStructureNodeId; + return ( + handleMove(lane.CommandStructureNodeId)} + disabled={isCurrent} + testID={`marker-lane-${lane.CommandStructureNodeId}`} + > + {lane.Color ? : null} + {lane.Name} + + ); + })} + + + + ) : ( + + + {t('command.not_on_board')} + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + laneDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 4, + }, +}); diff --git a/src/components/maps/map-layers-sheet.tsx b/src/components/maps/map-layers-sheet.tsx new file mode 100644 index 0000000..bf8a2df --- /dev/null +++ b/src/components/maps/map-layers-sheet.tsx @@ -0,0 +1,73 @@ +import { router } from 'expo-router'; +import { Building2, Search } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Switch } from '@/components/ui/switch'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useMapsStore } from '@/stores/maps/store'; + +interface MapLayersSheetProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * On-map control: toggle department GeoJSON layers and jump to the + * custom/indoor maps browser and unified map search. + */ +export const MapLayersSheet: React.FC = ({ isOpen, onClose }) => { + const { t } = useTranslation(); + const activeLayers = useMapsStore((state) => state.activeLayers); + const layerToggles = useMapsStore((state) => state.layerToggles); + const toggleLayer = useMapsStore((state) => state.toggleLayer); + + const navigate = (path: string) => { + onClose(); + router.push(path as never); + }; + + return ( + + + {t('maps.map_layers')} + + {activeLayers.length === 0 ? ( + {t('maps.no_layers')} + ) : ( + + + {activeLayers.map((layer) => ( + + + {layer.Color ? : null} + {layer.Name} + + toggleLayer(layer.Id)} testID={`layer-toggle-${layer.Id}`} /> + + ))} + + + )} + + + + + + + + ); +}; diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx index 39b4fed..6e60bae 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -1,7 +1,9 @@ import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import Mapbox from '@/components/maps/mapbox'; import { type MAP_ICONS } from '@/constants/map-icons'; +import { type CommandMarkerInfo } from '@/hooks/use-command-map-overlay'; import { isPoiMarker } from '@/lib/poi-marker-utils'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -12,6 +14,8 @@ type MapIconKey = keyof typeof MAP_ICONS; interface MapPinsProps { pins: MapMakerInfoData[]; + /** Active command board context by marker id — lane label + color enrichment. */ + commandOverlay?: Record; onPinPress?: (pin: MapMakerInfoData) => void; } @@ -22,12 +26,14 @@ interface MapPinsProps { * POI markers use the SVG shape + icon rendering (per the "POI Map Icon Renderer" * reference document). Non-POI markers use PNG images from the MAP_ICONS lookup. */ -const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void }) => { +const MapPin = React.memo(({ pin, commandInfo, onPinPress }: { pin: MapMakerInfoData; commandInfo?: CommandMarkerInfo; onPinPress?: (pin: MapMakerInfoData) => void }) => { + const { t } = useTranslation(); const handlePress = useCallback(() => { onPinPress?.(pin); }, [onPinPress, pin]); const poi = isPoiMarker(pin); + const laneLabel = commandInfo ? commandInfo.laneName || t('command.unassigned') : undefined; return ( ) : ( - + )} ); @@ -50,11 +56,11 @@ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPr MapPin.displayName = 'MapPin'; -const MapPins: React.FC = ({ pins, onPinPress }) => { +const MapPins: React.FC = ({ pins, commandOverlay, onPinPress }) => { return ( <> {pins.map((pin) => ( - + ))} ); diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index 3eef6b7..7844c70 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -14,6 +14,25 @@ import { Env } from '@/lib/env'; // Set the access token globally mapboxgl.accessToken = Env.IC_MAPBOX_PUBKEY; +// Prototype-level guard: mapbox-gl's pointer handlers (mouseover/mousemove) call +// Map#unproject synchronously from DOM events. During style load or map teardown the +// transform's elevation/terrain data can be half-initialized, which throws +// "Cannot read properties of undefined (reading '0')" deep in pointRayIntersection. +// The instance-level patch below doesn't cover every internal call path, so guard the +// prototype once for all instances and return a harmless coordinate on failure. +const mapPrototype = mapboxgl.Map.prototype as unknown as { unproject: (point: unknown) => unknown; __unprojectGuarded?: boolean }; +if (!mapPrototype.__unprojectGuarded) { + mapPrototype.__unprojectGuarded = true; + const protoUnproject = mapPrototype.unproject; + mapPrototype.unproject = function (point: unknown) { + try { + return protoUnproject.call(this, point); + } catch { + return new mapboxgl.LngLat(0, 0); + } + }; +} + // Context to share map instance with child components export const MapContext = React.createContext(null); @@ -338,9 +357,28 @@ export const MapView = forwardRef( return () => { if (map.current) { - (map.current as any).__removed = true; - map.current.remove(); + const dyingMap = map.current; + (dyingMap as any).__removed = true; map.current = null; + + // Stop pointer events from reaching mapbox's DOM handlers while the map is + // torn down — a hover racing remove() can otherwise throw from half-destroyed + // transform internals. + try { + const canvas = dyingMap.getCanvas(); + if (canvas) { + canvas.style.pointerEvents = 'none'; + } + } catch { + // Canvas may already be gone + } + + try { + dyingMap.remove(); + } catch { + // remove() can throw if an in-flight interaction is being processed — + // the map is discarded either way. + } } }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index 3b6b687..64ee466 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -4,6 +4,7 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import { CommandMarkerActions } from '@/components/maps/command-marker-actions'; import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -119,6 +120,9 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC {t('map.pin_color')} )} + + {/* Move/assign/release against the active command board (units & personnel only) */} + diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 57ddd83..b533408 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -11,12 +11,16 @@ interface PinMarkerProps { imagePath?: MapIconKey; poiImage?: MapIconKey; title: string; + /** Command lane name — shown under the title when the resource is on the active board. */ + laneLabel?: string; + /** Command lane color — ring around the icon for at-a-glance lane identification. */ + accentColor?: string | null; size?: number; markerRef?: React.ComponentRef | null; onPress?: () => void; } -const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, onPress }) => { +const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, laneLabel, accentColor, size = 32, onPress }) => { const { colorScheme } = useColorScheme(); // Prefer poiImage (new field) over imagePath (null for POIs after backend fix), @@ -27,10 +31,15 @@ const PinMarker: React.FC = React.memo(({ imagePath, poiImage, t return ( - + {title} + {laneLabel ? ( + + {laneLabel} + + ) : null} ); }); @@ -53,6 +62,21 @@ const styles = StyleSheet.create({ fontWeight: '600', textAlign: 'center', }, + accentRing: { + borderWidth: 3, + }, + laneLabel: { + marginTop: 1, + overflow: 'hidden', + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: 4, + backgroundColor: '#6b7280', + fontSize: 9, + fontWeight: '700', + color: '#ffffff', + textAlign: 'center', + }, }); export default PinMarker; diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 4acbfff..9389e21 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -39,13 +39,14 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo - {/* Marker pin for the location */} - + {/* Marker pin for the location. MarkerView (not PointAnnotation) — custom children + inside PointAnnotation don't render reliably on iOS/Android with @rnmapbox/maps. */} + - + {/* Show user location if requested */} {showUserLocation && } diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 53f55a1..b1cfe14 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { FlatList } from '@/components/ui/flat-list'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; import { Text } from '@/components/ui/text'; +import { useAuthStore } from '@/lib/auth'; import { useCoreStore } from '@/stores/app/core-store'; import { useToastStore } from '@/stores/toast/store'; import { type NotificationPayload } from '@/types/notification'; @@ -25,7 +26,7 @@ interface NotificationInboxProps { } export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { - const activeUnitId = useCoreStore((state) => state.activeUnitId); + 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); @@ -237,7 +238,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; } @@ -292,7 +293,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = - ) : !activeUnitId || !config ? ( + ) : !userId || !config ? ( Unable to load notifications diff --git a/src/components/push-notification/push-notification-status.tsx b/src/components/push-notification/push-notification-status.tsx deleted file mode 100644 index f24f9bd..0000000 --- a/src/components/push-notification/push-notification-status.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -import { Box } from '@/components/ui/box'; -import { Button } from '@/components/ui/button'; -import { Text } from '@/components/ui/text'; -import { usePushNotifications } from '@/services/push-notification'; -import { useCoreStore } from '@/stores/app/core-store'; - -export function PushNotificationStatus() { - const { pushToken } = usePushNotifications(); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const activeUnit = useCoreStore((state) => state.activeUnit); - - return ( - - Push Notification Status - - - Active Unit: {activeUnit ? activeUnit.Name : 'None'} - Unit ID: {activeUnitId ? activeUnitId : 'None'} - - - - Push Token: {pushToken ? `${pushToken.substring(0, 20)}...` : 'Not registered'} - Status: {pushToken ? 'Registered' : 'Not Registered'} - - - ); -} diff --git a/src/components/roles/__tests__/role-assignment-item.test.tsx b/src/components/roles/__tests__/role-assignment-item.test.tsx deleted file mode 100644 index bee59c3..0000000 --- a/src/components/roles/__tests__/role-assignment-item.test.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; -import React from 'react'; - -import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; -import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; - -import { RoleAssignmentItem } from '../role-assignment-item'; - -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValue?: string | Record) => { - if (typeof defaultValue === 'string') return defaultValue; - if (typeof defaultValue === 'object' && defaultValue.defaultValue) return defaultValue.defaultValue; - return key; - }, - }), -})); - -// Mock nativewind -jest.mock('nativewind', () => ({ - useColorScheme: () => ({ colorScheme: 'light' }), - cssInterop: jest.fn(), -})); - -// Mock the RoleUserSelectionModal to simplify testing -let mockModalOnSelectUser: ((userId?: string) => void) | undefined; -jest.mock('../role-user-selection-modal', () => ({ - RoleUserSelectionModal: ({ isOpen, onClose, roleName, selectedUserId, users, onSelectUser }: any) => { - const { View, Text, TouchableOpacity } = require('react-native'); - mockModalOnSelectUser = onSelectUser; - if (!isOpen) return null; - return ( - - {roleName} - { onSelectUser(undefined); onClose(); }}> - Unassigned - - {users.map((user: any) => ( - { onSelectUser(user.UserId); onClose(); }}> - {`${user.FirstName} ${user.LastName}`} - - ))} - - ); - }, -})); - -// Mock UI components -jest.mock('@/components/ui/text', () => ({ - Text: ({ children, className, testID }: any) => { - const { Text } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/vstack', () => ({ - VStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/hstack', () => ({ - HStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -describe('RoleAssignmentItem', () => { - const mockOnAssignUser = jest.fn(); - - const mockRole: UnitRoleResultData = { - UnitRoleId: 'role1', - Name: 'Captain', - UnitId: 'unit1', - }; - - const mockUsers: PersonnelInfoResultData[] = [ - { - UserId: 'user1', - FirstName: 'John', - LastName: 'Doe', - EmailAddress: 'john.doe@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: 'group1', - GroupName: 'Engine 1', - StatusId: '', - Status: 'Available', - StatusColor: '#00ff00', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: 'On Shift', - StaffingColor: '#0000ff', - StaffingTimestamp: '', - Roles: ['Firefighter'], - }, - { - UserId: 'user2', - FirstName: 'Jane', - LastName: 'Smith', - EmailAddress: 'jane.smith@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: '', - GroupName: '', - StatusId: '', - Status: '', - StatusColor: '', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: '', - StaffingColor: '', - StaffingTimestamp: '', - Roles: [], - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - mockModalOnSelectUser = undefined; - }); - - it('renders the role name', () => { - render( - - ); - - expect(screen.getByText('Captain')).toBeTruthy(); - }); - - it('displays "Unassigned" when no user is assigned', () => { - render( - - ); - - expect(screen.getByText('Unassigned')).toBeTruthy(); - }); - - it('displays assigned user name inline when user is assigned', () => { - const assignedUser = mockUsers[0]; - - render( - - ); - - expect(screen.getByText('John Doe')).toBeTruthy(); - }); - - it('displays user details inline (group, status, staffing)', () => { - const assignedUser = mockUsers[0]; - - render( - - ); - - expect(screen.getByText('Engine 1')).toBeTruthy(); - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('On Shift')).toBeTruthy(); - }); - - it('opens selection modal on tap', () => { - render( - - ); - - // Modal should not be visible initially - expect(screen.queryByTestId('user-selection-modal')).toBeNull(); - - // Tap the item - fireEvent.press(screen.getByTestId('role-assignment-role1')); - - // Modal should now be visible - expect(screen.getByTestId('user-selection-modal')).toBeTruthy(); - expect(screen.getByTestId('modal-role-name')).toBeTruthy(); - }); - - it('shows all users including those assigned to other roles in the modal', () => { - const currentAssignments = [ - { roleId: 'role2', userId: 'user2', roleName: 'Engineer' }, // user2 is assigned to a different role - ]; - - render( - - ); - - // Open modal - fireEvent.press(screen.getByTestId('role-assignment-role1')); - - // Should show both users (no filtering) - expect(screen.getByTestId('select-user-user1')).toBeTruthy(); - expect(screen.getByTestId('select-user-user2')).toBeTruthy(); - // Unassigned option should always be present - expect(screen.getByTestId('select-unassigned')).toBeTruthy(); - }); - - it('shows all users in the modal including those assigned to same and other roles', () => { - const assignedUser = mockUsers[0]; - const currentAssignments = [ - { roleId: 'role1', userId: 'user1', roleName: 'Captain' }, // user1 is assigned to this role - { roleId: 'role2', userId: 'user2', roleName: 'Engineer' }, // user2 is assigned to a different role - ]; - - render( - - ); - - // Open modal - fireEvent.press(screen.getByTestId('role-assignment-role1')); - - // Should show both users (no filtering, all users visible) - expect(screen.getByTestId('select-user-user1')).toBeTruthy(); - expect(screen.getByTestId('select-user-user2')).toBeTruthy(); - }); - - it('calls onAssignUser when selecting a user in the modal', () => { - render( - - ); - - // Open modal - fireEvent.press(screen.getByTestId('role-assignment-role1')); - - // Select user1 - fireEvent.press(screen.getByTestId('select-user-user1')); - - expect(mockOnAssignUser).toHaveBeenCalledWith('user1'); - }); - - it('calls onAssignUser with undefined when selecting Unassigned', () => { - const assignedUser = mockUsers[0]; - - render( - - ); - - // Open modal - fireEvent.press(screen.getByTestId('role-assignment-role1')); - - // Select Unassigned - fireEvent.press(screen.getByTestId('select-unassigned')); - - expect(mockOnAssignUser).toHaveBeenCalledWith(undefined); - }); - - it('has proper accessibility role on the pressable', () => { - render( - - ); - - const pressable = screen.getByTestId('role-assignment-role1'); - expect(pressable.props.accessibilityRole).toBe('button'); - }); -}); \ No newline at end of file diff --git a/src/components/roles/__tests__/role-user-selection-modal.test.tsx b/src/components/roles/__tests__/role-user-selection-modal.test.tsx deleted file mode 100644 index a9ab2a9..0000000 --- a/src/components/roles/__tests__/role-user-selection-modal.test.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; -import React from 'react'; - -import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; - -import { RoleUserSelectionModal } from '../role-user-selection-modal'; - -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValue?: string | Record) => { - if (typeof defaultValue === 'string') return defaultValue; - if (typeof defaultValue === 'object' && defaultValue.defaultValue) return defaultValue.defaultValue; - return key; - }, - }), -})); - -// Mock nativewind -jest.mock('nativewind', () => ({ - useColorScheme: () => ({ colorScheme: 'light' }), - cssInterop: jest.fn(), -})); - -// Mock Modal components -jest.mock('@/components/ui/modal', () => ({ - Modal: ({ children, isOpen }: any) => { - if (!isOpen) return null; - const { View } = require('react-native'); - return {children}; - }, - ModalBackdrop: () => null, - ModalBody: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, - ModalContent: ({ children, testID }: any) => { - const { View } = require('react-native'); - return {children}; - }, - ModalHeader: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -// Mock other UI components -jest.mock('@/components/ui/text', () => ({ - Text: ({ children, testID }: any) => { - const { Text } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/vstack', () => ({ - VStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/hstack', () => ({ - HStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/box', () => ({ - Box: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/divider', () => ({ - Divider: () => null, -})); - -jest.mock('@/components/ui/input', () => ({ - Input: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, - InputField: ({ placeholder, value, onChangeText, testID }: any) => { - const { TextInput } = require('react-native'); - return ; - }, - InputIcon: () => null, - InputSlot: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -describe('RoleUserSelectionModal', () => { - const mockOnClose = jest.fn(); - const mockOnSelectUser = jest.fn(); - - const mockUsers: PersonnelInfoResultData[] = [ - { - UserId: 'user1', - FirstName: 'John', - LastName: 'Doe', - EmailAddress: 'john@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: 'group1', - GroupName: 'Engine 1', - StatusId: '', - Status: 'Available', - StatusColor: '#00ff00', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: 'On Shift', - StaffingColor: '#0000ff', - StaffingTimestamp: '', - Roles: ['Firefighter'], - }, - { - UserId: 'user2', - FirstName: 'Jane', - LastName: 'Smith', - EmailAddress: 'jane@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: 'group2', - GroupName: 'Ladder 5', - StatusId: '', - Status: 'Not Available', - StatusColor: '#ff0000', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: '', - StaffingColor: '', - StaffingTimestamp: '', - Roles: [], - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('does not render when closed', () => { - render( - - ); - - expect(screen.queryByTestId('role-user-selection-modal')).toBeNull(); - }); - - it('renders when open', () => { - render( - - ); - - expect(screen.getByTestId('role-user-selection-modal')).toBeTruthy(); - }); - - it('shows the unassigned option', () => { - render( - - ); - - expect(screen.getByTestId('unassigned-option')).toBeTruthy(); - }); - - it('calls onSelectUser with undefined and closes when unassigned is tapped', () => { - render( - - ); - - fireEvent.press(screen.getByTestId('unassigned-option')); - - expect(mockOnSelectUser).toHaveBeenCalledWith(undefined); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it('renders user items in the list', () => { - render( - - ); - - expect(screen.getByTestId('user-item-user1')).toBeTruthy(); - expect(screen.getByTestId('user-item-user2')).toBeTruthy(); - }); - - it('calls onSelectUser with userId and closes when a user is tapped', () => { - render( - - ); - - fireEvent.press(screen.getByTestId('user-item-user1')); - - expect(mockOnSelectUser).toHaveBeenCalledWith('user1'); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it('shows search input', () => { - render( - - ); - - expect(screen.getByTestId('user-search-input')).toBeTruthy(); - }); - - it('filters users by search query', () => { - render( - - ); - - // Type a search query - fireEvent.changeText(screen.getByTestId('user-search-input'), 'John'); - - // Only John should be visible - expect(screen.getByTestId('user-item-user1')).toBeTruthy(); - expect(screen.queryByTestId('user-item-user2')).toBeNull(); - }); - - it('filters users by group name', () => { - render( - - ); - - fireEvent.changeText(screen.getByTestId('user-search-input'), 'Ladder'); - - expect(screen.queryByTestId('user-item-user1')).toBeNull(); - expect(screen.getByTestId('user-item-user2')).toBeTruthy(); - }); - - it('shows empty state when no users match search', () => { - render( - - ); - - fireEvent.changeText(screen.getByTestId('user-search-input'), 'zzzzz'); - - expect(screen.queryByTestId('user-item-user1')).toBeNull(); - expect(screen.queryByTestId('user-item-user2')).toBeNull(); - }); - - it('highlights the currently selected user', () => { - render( - - ); - - // Both should be present - selected just has different styling - expect(screen.getByTestId('user-item-user1')).toBeTruthy(); - expect(screen.getByTestId('user-item-user2')).toBeTruthy(); - }); -}); diff --git a/src/components/roles/__tests__/roles-bottom-sheet.test.tsx b/src/components/roles/__tests__/roles-bottom-sheet.test.tsx deleted file mode 100644 index e49a91f..0000000 --- a/src/components/roles/__tests__/roles-bottom-sheet.test.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { render, screen } from '@testing-library/react-native'; -import React from 'react'; - -import { useAnalytics } from '@/hooks/use-analytics'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; -import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; -import { type ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; - -import { RolesBottomSheet } from '../roles-bottom-sheet'; - -// Mock the stores -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/roles/store'); -jest.mock('@/stores/toast/store'); - -// Mock use-analytics hook -jest.mock('@/hooks/use-analytics'); - -// Mock the CustomBottomSheet component -jest.mock('@/components/ui/bottom-sheet', () => ({ - CustomBottomSheet: ({ children, isOpen }: any) => { - if (!isOpen) return null; - return
{children}
; - }, -})); - -// Mock the RoleAssignmentItem component -jest.mock('../role-assignment-item', () => ({ - RoleAssignmentItem: ({ role }: any) => { - const { Text } = require('react-native'); - return ( - Role: {role.Name} - ); - }, -})); - -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValue?: string) => defaultValue || key, - }), -})); - -// Mock nativewind -jest.mock('nativewind', () => ({ - useColorScheme: () => ({ colorScheme: 'light' }), - cssInterop: jest.fn(), -})); - -// Mock logger -jest.mock('@/lib/logging', () => ({ - logger: { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - }, -})); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseRolesStore = useRolesStore as jest.MockedFunction; -const mockUseToastStore = useToastStore as jest.MockedFunction; - -describe('RolesBottomSheet', () => { - const mockOnClose = jest.fn(); - const mockFetchRolesForUnit = jest.fn(); - const mockFetchUsers = jest.fn(); - const mockFetchAllForUnit = jest.fn(); - const mockAssignRoles = jest.fn(); - const mockShowToast = jest.fn(); - const mockTrackEvent = jest.fn(); - - const mockActiveUnit: UnitResultData = { - UnitId: 'unit1', - Name: 'Unit 1', - Type: 'Engine', - DepartmentId: 'dept1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '', - GroupName: '', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - }; - - const mockRoles: UnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - Name: 'Captain', - UnitId: 'unit1', - }, - { - UnitRoleId: 'role2', - Name: 'Engineer', - UnitId: 'unit1', - }, - ]; - - const mockUsers: PersonnelInfoResultData[] = [ - { - UserId: 'user1', - FirstName: 'John', - LastName: 'Doe', - EmailAddress: 'john.doe@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: '', - GroupName: '', - StatusId: '', - Status: '', - StatusColor: '', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: '', - StaffingColor: '', - StaffingTimestamp: '', - Roles: [], - }, - ]; - - const mockUnitRoleAssignments: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - - // Setup analytics mock - (useAnalytics as jest.MockedFunction).mockReturnValue({ - trackEvent: mockTrackEvent, - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit }); - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: mockRoles, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: mockRoles, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - showToast: mockShowToast, - } as any) : { - showToast: mockShowToast, - } as any); - - // Mock the getState functions - useRolesStore.getState = jest.fn().mockReturnValue({ - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - }); - - useToastStore.getState = jest.fn().mockReturnValue({ - showToast: mockShowToast, - }); - }); - - it('renders correctly when opened', () => { - render(); - - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - expect(screen.getByText('Unit 1')).toBeTruthy(); - expect(screen.getByText('Cancel')).toBeTruthy(); - expect(screen.getByText('Save')).toBeTruthy(); - }); - - it('does not render when not opened', () => { - render(); - - expect(screen.queryByText('Unit Role Assignments')).toBeNull(); - }); - - it('fetches roles and users when opened', () => { - render(); - - expect(mockFetchAllForUnit).toHaveBeenCalledWith('unit1'); - }); - - it('renders role assignment items', () => { - render(); - - expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); - expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); - }); - - it('displays error state correctly', () => { - const errorMessage = 'Failed to load roles'; - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: [], - unitRoleAssignments: [], - users: [], - isLoading: false, - error: errorMessage, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: [], - unitRoleAssignments: [], - users: [], - isLoading: false, - error: errorMessage, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - expect(screen.getByText(errorMessage)).toBeTruthy(); - }); - - it('handles missing active unit gracefully', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }); - - render(); - - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - expect(screen.queryByText('Unit 1')).toBeNull(); - }); - - it('filters roles by active unit', () => { - const rolesWithDifferentUnits = [ - ...mockRoles, - { - UnitRoleId: 'role3', - Name: 'Chief', - UnitId: 'unit2', // Different unit - }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: rolesWithDifferentUnits, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: rolesWithDifferentUnits, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - // Should only show roles for the active unit - expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); - expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); - expect(screen.queryByTestId('role-item-Chief')).toBeNull(); - }); - - it('has functional buttons', () => { - render(); - - expect(screen.getByText('Cancel')).toBeTruthy(); - expect(screen.getByText('Save')).toBeTruthy(); - }); - - describe('User assignment bug fixes', () => { - it('should prevent duplicate role assignments for roles with same name', () => { - // Add roles with same name but different IDs to test the fix - const rolesWithSameName: UnitRoleResultData[] = [ - { - UnitRoleId: 'role-1', - UnitId: 'unit1', - Name: 'Firefighter', - }, - { - UnitRoleId: 'role-2', - UnitId: 'unit1', - Name: 'Firefighter', // Same name, different ID - }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: rolesWithSameName, - unitRoleAssignments: [], - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: rolesWithSameName, - unitRoleAssignments: [], - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - // Both roles should be rendered with distinct IDs - const firefighterRoles = screen.getAllByTestId(/role-item-Firefighter/); - expect(firefighterRoles).toHaveLength(2); - }); - - it('should handle empty user assignments correctly', () => { - // Test with assignments that have empty user IDs - const assignmentsWithEmpty: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - UserId: 'user1', - Name: 'Captain', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role2', - UnitId: 'unit1', - UserId: '', // Empty user ID - Name: 'Engineer', - FullName: '', - UpdatedOn: new Date().toISOString(), - }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: mockRoles, - unitRoleAssignments: assignmentsWithEmpty, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: mockRoles, - unitRoleAssignments: assignmentsWithEmpty, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - // Component should render without errors despite empty user assignments - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - }); - - it('should not send empty RoleId fields when saving roles', async () => { - const { fireEvent, act } = require('@testing-library/react-native'); - - // Setup roles with some having empty assignments - const rolesWithMixedAssignments: UnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - Name: 'Captain', - UnitId: 'unit1', - }, - { - UnitRoleId: 'role2', - Name: 'Engineer', - UnitId: 'unit1', - }, - { - UnitRoleId: '', // Empty RoleId - should be filtered out - Name: 'Invalid Role', - UnitId: 'unit1', - }, - ]; - - const assignmentsWithEmpty: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - UserId: 'user1', // Valid assignment - Name: 'Captain', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role2', - UnitId: 'unit1', - UserId: '', // Empty user assignment - should be filtered out - Name: 'Engineer', - FullName: '', - UpdatedOn: new Date().toISOString(), - }, - ]; - - // Create a test component that simulates user interaction - const TestComponent = () => { - const [pendingAssignments, setPendingAssignments] = React.useState([ - { roleId: 'role1', userId: 'user1' }, // Valid assignment - { roleId: '', userId: 'user1' }, // Empty roleId - should be filtered out - { roleId: 'role2', userId: '' }, // Empty userId - should be filtered out - ]); - - React.useEffect(() => { - // Override the roles store to include our test pending assignments - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: rolesWithMixedAssignments, - unitRoleAssignments: assignmentsWithEmpty, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: rolesWithMixedAssignments, - unitRoleAssignments: assignmentsWithEmpty, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - }, []); - - // Mock the component internals by calling the save handler directly - const handleSave = async () => { - const activeUnit = { UnitId: 'unit1', Name: 'Unit 1' }; - const allUnitRoles = rolesWithMixedAssignments - .map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const currentAssignment = assignmentsWithEmpty.find((a) => a.UnitRoleId === role.UnitRoleId && a.UnitId === activeUnit.UnitId); - const assignedUserId = pendingAssignment?.userId || currentAssignment?.UserId || ''; - - return { - RoleId: role.UnitRoleId, - UserId: assignedUserId, - Name: '', - }; - }) - .filter((role) => { - // Only include roles that have valid RoleId and assigned UserId - return role.RoleId && role.RoleId.trim() !== '' && role.UserId && role.UserId.trim() !== ''; - }); - - await mockAssignRoles({ - UnitId: activeUnit.UnitId, - Roles: allUnitRoles, - }); - }; - - const { TouchableOpacity, Text } = require('react-native'); - return ( - - Save - - ); - }; - - render(); - - // Simulate saving by calling our test handler - const testSaveButton = screen.getByTestId('test-save-button'); - - await act(async () => { - fireEvent.press(testSaveButton); - }); - - // Verify that assignRoles was called with only valid role assignments - expect(mockAssignRoles).toHaveBeenCalledWith({ - UnitId: 'unit1', - Roles: [ - { - RoleId: 'role1', - UserId: 'user1', - Name: '', - }, - // Note: role2 should be filtered out because it has empty UserId - // Note: the invalid role should be filtered out because it has empty RoleId - ], - }); - }); - - it('should filter out roles with empty or whitespace-only RoleId or UserId', async () => { - const { fireEvent, act } = require('@testing-library/react-native'); - - const rolesWithWhitespace: UnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - Name: 'Captain', - UnitId: 'unit1', - }, - { - UnitRoleId: ' ', // Whitespace-only RoleId - Name: 'Invalid Role', - UnitId: 'unit1', - }, - ]; - - const assignmentsWithWhitespace: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - UserId: ' ', // Whitespace-only user assignment - Name: 'Captain', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - // Create a test component that simulates the filtering logic - const TestComponent = () => { - const pendingAssignments: any[] = []; // No pending assignments - - const handleSave = async () => { - const activeUnit = { UnitId: 'unit1', Name: 'Unit 1' }; - const allUnitRoles = rolesWithWhitespace - .map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const currentAssignment = assignmentsWithWhitespace.find((a) => a.UnitRoleId === role.UnitRoleId && a.UnitId === activeUnit.UnitId); - const assignedUserId = pendingAssignment?.userId || currentAssignment?.UserId || ''; - - return { - RoleId: role.UnitRoleId, - UserId: assignedUserId, - Name: '', - }; - }) - .filter((role) => { - // Only include roles that have valid RoleId and assigned UserId - return role.RoleId && role.RoleId.trim() !== '' && role.UserId && role.UserId.trim() !== ''; - }); - - await mockAssignRoles({ - UnitId: activeUnit.UnitId, - Roles: allUnitRoles, - }); - }; - - const { TouchableOpacity, Text } = require('react-native'); - return ( - - Save - - ); - }; - - render(); - - // Simulate saving - const testSaveButton = screen.getByTestId('test-save-whitespace'); - - await act(async () => { - fireEvent.press(testSaveButton); - }); - - // Verify that assignRoles was called with empty roles array (all filtered out) - expect(mockAssignRoles).toHaveBeenCalledWith({ - UnitId: 'unit1', - Roles: [], - }); - }); - - it('should find role assignments without UnitId filter', () => { - // Test that demonstrates the fix - assignments should be found without the UnitId filter - const testRoleAssignments = [ - { - UnitRoleId: 'role1', - UnitId: '', // UnitId might be empty or different in the API response - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - // The old logic would fail to find this assignment due to UnitId mismatch - const assignmentWithUnitIdFilter = testRoleAssignments.find((a) => a.UnitRoleId === 'role1' && a.UnitId === 'unit1'); - expect(assignmentWithUnitIdFilter).toBeUndefined(); - - // The new logic should find this assignment - const assignmentWithoutUnitIdFilter = testRoleAssignments.find((a) => a.UnitRoleId === 'role1'); - expect(assignmentWithoutUnitIdFilter).toBeDefined(); - expect(assignmentWithoutUnitIdFilter?.UserId).toBe('user1'); - }); - }); -}); \ No newline at end of file diff --git a/src/components/roles/__tests__/roles-modal.test.tsx b/src/components/roles/__tests__/roles-modal.test.tsx deleted file mode 100644 index 7c9a1ba..0000000 --- a/src/components/roles/__tests__/roles-modal.test.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import { render, screen } from '@testing-library/react-native'; -import React from 'react'; - -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; -import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; -import { type ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; - -import { RolesModal } from '../roles-modal'; - -// Mock the stores -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/roles/store'); -jest.mock('@/stores/toast/store'); - -// Mock the Modal components -jest.mock('@/components/ui/modal', () => ({ - Modal: ({ children, isOpen }: any) => { - if (!isOpen) return null; - return
{children}
; - }, - ModalBackdrop: () =>
, - ModalContent: ({ children }: any) =>
{children}
, - ModalHeader: ({ children }: any) =>
{children}
, - ModalBody: ({ children }: any) =>
{children}
, - ModalFooter: ({ children }: any) =>
{children}
, -})); - -// Mock the RoleAssignmentItem component -jest.mock('../role-assignment-item', () => ({ - RoleAssignmentItem: ({ role }: any) => { - const { Text } = require('react-native'); - return ( - Role: {role.Name} - ); - }, -})); - -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValue?: string) => defaultValue || key, - }), -})); - -// Mock logger -jest.mock('@/lib/logging', () => ({ - logger: { - error: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - }, -})); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseRolesStore = useRolesStore as jest.MockedFunction; -const mockUseToastStore = useToastStore as jest.MockedFunction; - -describe('RolesModal', () => { - const mockOnClose = jest.fn(); - const mockFetchRolesForUnit = jest.fn(); - const mockFetchUsers = jest.fn(); - const mockFetchAllForUnit = jest.fn(); - const mockAssignRoles = jest.fn(); - const mockShowToast = jest.fn(); - - const mockActiveUnit: UnitResultData = { - UnitId: 'unit1', - Name: 'Unit 1', - Type: 'Engine', - DepartmentId: 'dept1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '', - GroupName: '', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - }; - - const mockRoles: UnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - Name: 'Captain', - UnitId: 'unit1', - }, - { - UnitRoleId: 'role2', - Name: 'Engineer', - UnitId: 'unit1', - }, - ]; - - const mockUsers: PersonnelInfoResultData[] = [ - { - UserId: 'user1', - FirstName: 'John', - LastName: 'Doe', - EmailAddress: 'john.doe@example.com', - DepartmentId: 'dept1', - IdentificationNumber: '', - MobilePhone: '', - GroupId: '', - GroupName: '', - StatusId: '', - Status: '', - StatusColor: '', - StatusTimestamp: '', - StatusDestinationId: '', - StatusDestinationName: '', - StaffingId: '', - Staffing: '', - StaffingColor: '', - StaffingTimestamp: '', - Roles: [], - }, - ]; - - const mockUnitRoleAssignments: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit }); - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: mockRoles, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: mockRoles, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - showToast: mockShowToast, - } as any) : { - showToast: mockShowToast, - } as any); - - // Mock the getState functions - useRolesStore.getState = jest.fn().mockReturnValue({ - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - }); - - useToastStore.getState = jest.fn().mockReturnValue({ - showToast: mockShowToast, - }); - }); - - it('renders correctly when opened', () => { - render(); - - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - expect(screen.getByText('Close')).toBeTruthy(); - expect(screen.getByText('Save')).toBeTruthy(); - }); - - it('does not render when not opened', () => { - render(); - - expect(screen.queryByText('Unit Role Assignments')).toBeNull(); - }); - - it('fetches roles and users when opened', () => { - render(); - - expect(mockFetchAllForUnit).toHaveBeenCalledWith('unit1'); - }); - - it('renders role assignment items', () => { - render(); - - expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); - expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); - }); - - it('displays error state correctly', () => { - const errorMessage = 'Failed to load roles'; - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: [], - unitRoleAssignments: [], - users: [], - isLoading: false, - error: errorMessage, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: [], - unitRoleAssignments: [], - users: [], - isLoading: false, - error: errorMessage, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - expect(screen.getByText(errorMessage)).toBeTruthy(); - }); - - it('handles missing active unit gracefully', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }); - - render(); - - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - }); - - it('filters roles by active unit', () => { - const rolesWithDifferentUnits = [ - ...mockRoles, - { - UnitRoleId: 'role3', - Name: 'Chief', - UnitId: 'unit2', // Different unit - }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - roles: rolesWithDifferentUnits, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any) : { - roles: rolesWithDifferentUnits, - unitRoleAssignments: mockUnitRoleAssignments, - users: mockUsers, - isLoading: false, - error: null, - fetchRolesForUnit: mockFetchRolesForUnit, - fetchUsers: mockFetchUsers, - fetchAllForUnit: mockFetchAllForUnit, - assignRoles: mockAssignRoles, - } as any); - - render(); - - // Should only show roles for the active unit - expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); - expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); - expect(screen.queryByTestId('role-item-Chief')).toBeNull(); - }); - - describe('Empty RoleId prevention', () => { - it('should filter out roles with empty or whitespace RoleId and UserId', () => { - const { fireEvent } = require('@testing-library/react-native'); - - render(); - - // The component should render without errors - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - expect(screen.getByText('Save')).toBeTruthy(); - }); - - it('should handle save with empty assignments gracefully', async () => { - const { fireEvent } = require('@testing-library/react-native'); - - // Mock the save to resolve successfully even with empty roles - mockAssignRoles.mockResolvedValueOnce({}); - - render(); - - // Try to save - should not throw error even if no pending assignments - const saveButton = screen.getByText('Save'); - fireEvent.press(saveButton); - - // Component should still be functional - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - }); - - it('should allow unassignments by including roles with valid RoleId but empty UserId', () => { - // Test the filter logic that should allow unassignments - const testRoles = [ - { RoleId: 'role-1', UserId: 'user-1', Name: '' }, // Valid assignment - { RoleId: 'role-2', UserId: '', Name: '' }, // Valid unassignment - should pass through - { RoleId: '', UserId: 'user-3', Name: '' }, // Invalid - no RoleId, should be filtered out - { RoleId: ' ', UserId: 'user-4', Name: '' }, // Invalid - whitespace RoleId, should be filtered out - ]; - - const filteredRoles = testRoles.filter((role) => { - // Only filter out entries lacking a RoleId - allow empty UserId for unassignments - return role.RoleId && role.RoleId.trim() !== ''; - }); - - expect(filteredRoles).toHaveLength(2); - expect(filteredRoles[0]).toEqual({ RoleId: 'role-1', UserId: 'user-1', Name: '' }); - expect(filteredRoles[1]).toEqual({ RoleId: 'role-2', UserId: '', Name: '' }); // Unassignment should be included - }); - - it('should track pending removals and assignments properly', () => { - render(); - - // The component should track pending assignments including removals (empty UserId) - // This ensures that unassignments reach the assignRoles API call - expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); - }); - - it('should find role assignments without UnitId filter', () => { - // Test that demonstrates the fix - assignments should be found without the UnitId filter - const testRoleAssignments = [ - { - UnitRoleId: 'role1', - UnitId: '', // UnitId might be empty or different in the API response - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - // The old logic would fail to find this assignment due to UnitId mismatch - const assignmentWithUnitIdFilter = testRoleAssignments.find((a) => a.UnitRoleId === 'role1' && a.UnitId === 'unit1'); - expect(assignmentWithUnitIdFilter).toBeUndefined(); - - // The new logic should find this assignment - const assignmentWithoutUnitIdFilter = testRoleAssignments.find((a) => a.UnitRoleId === 'role1'); - expect(assignmentWithoutUnitIdFilter).toBeDefined(); - expect(assignmentWithoutUnitIdFilter?.UserId).toBe('user1'); - }); - }); -}); \ No newline at end of file diff --git a/src/components/roles/role-assignment-item.tsx b/src/components/roles/role-assignment-item.tsx deleted file mode 100644 index cc2dcef..0000000 --- a/src/components/roles/role-assignment-item.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { ChevronRightIcon, UserIcon, UserXIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; - -import { HStack } from '@/components/ui/hstack'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; -import type { UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; - -import { RoleUserSelectionModal } from './role-user-selection-modal'; - -type RoleAssignmentItemProps = { - role: UnitRoleResultData; - assignedUser?: PersonnelInfoResultData; - availableUsers: PersonnelInfoResultData[]; - onAssignUser: (userId?: string) => void; - currentAssignments: { roleId: string; userId: string; roleName?: string }[]; -}; - -export const RoleAssignmentItem: React.FC = ({ role, assignedUser, availableUsers, onAssignUser, currentAssignments }) => { - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const isDark = colorScheme === 'dark'; - const [isModalOpen, setIsModalOpen] = React.useState(false); - - const handleOpenModal = React.useCallback(() => { - setIsModalOpen(true); - }, []); - - const handleCloseModal = React.useCallback(() => { - setIsModalOpen(false); - }, []); - - const handleSelectUser = React.useCallback( - (userId?: string) => { - onAssignUser(userId); - }, - [onAssignUser] - ); - - return ( - <> - - - {/* Avatar / icon */} - - {assignedUser ? : } - - - {/* Content */} - - {role.Name} - - {assignedUser ? ( - <> - {`${assignedUser.FirstName} ${assignedUser.LastName}`.trim()} - - {assignedUser.GroupName ? {assignedUser.GroupName} : null} - {assignedUser.GroupName && assignedUser.Status ? : null} - {assignedUser.Status ? ( - - {assignedUser.StatusColor ? : null} - {assignedUser.Status} - - ) : null} - {assignedUser.Staffing ? ( - <> - - - {assignedUser.StaffingColor ? : null} - {assignedUser.Staffing} - - - ) : null} - - - ) : ( - {t('roles.unassigned', 'Unassigned')} - )} - - - {/* Chevron */} - - - - - - - ); -}; - -const styles = StyleSheet.create({ - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - marginRight: 4, - }, -}); diff --git a/src/components/roles/role-user-selection-modal.tsx b/src/components/roles/role-user-selection-modal.tsx deleted file mode 100644 index 9b80631..0000000 --- a/src/components/roles/role-user-selection-modal.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { CheckIcon, SearchIcon, UserIcon, UserXIcon, XIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Platform, Pressable, StyleSheet, View } from 'react-native'; - -import { Box } from '@/components/ui/box'; -import { Divider } from '@/components/ui/divider'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; -import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalHeader } from '@/components/ui/modal'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; - -type RoleUserSelectionModalProps = { - isOpen: boolean; - onClose: () => void; - roleName: string; - selectedUserId?: string; - users: PersonnelInfoResultData[]; - onSelectUser: (userId?: string) => void; - currentAssignments?: { roleId: string; userId: string; roleName?: string }[]; - currentRoleId?: string; -}; - -export const RoleUserSelectionModal: React.FC = ({ isOpen, onClose, roleName, selectedUserId, users, onSelectUser, currentAssignments = [], currentRoleId }) => { - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const isDark = colorScheme === 'dark'; - const [searchQuery, setSearchQuery] = React.useState(''); - - React.useEffect(() => { - if (isOpen) { - setSearchQuery(''); - } - }, [isOpen]); - - const filteredUsers = React.useMemo(() => { - if (!searchQuery.trim()) return users; - const query = searchQuery.toLowerCase(); - return users.filter((user) => { - const fullName = `${user.FirstName} ${user.LastName}`.toLowerCase(); - const group = (user.GroupName || '').toLowerCase(); - return fullName.includes(query) || group.includes(query); - }); - }, [users, searchQuery]); - - const handleSelect = React.useCallback( - (userId?: string) => { - onSelectUser(userId); - onClose(); - }, - [onSelectUser, onClose] - ); - - const renderUserItem = React.useCallback( - (item: PersonnelInfoResultData) => { - const isSelected = item.UserId === selectedUserId; - const fullName = `${item.FirstName} ${item.LastName}`.trim(); - const otherAssignment = currentAssignments.find((a) => a.userId === item.UserId && currentRoleId != null && a.roleId !== currentRoleId); - const isAssignedElsewhere = !!otherAssignment; - - return ( - handleSelect(item.UserId)} - className={`px-4 py-3 ${isSelected ? (isDark ? 'bg-primary-900/30' : 'bg-primary-50') : ''}`} - testID={`user-item-${item.UserId}`} - accessibilityRole="button" - accessibilityLabel={t('roles.selectUserLabel', { name: fullName, defaultValue: `Select ${fullName}` })} - > - - - {isSelected ? : } - - - - - {fullName} - {isAssignedElsewhere ? ( - - {otherAssignment?.roleName ? otherAssignment.roleName : t('roles.assignedElsewhere', 'In another role')} - - ) : null} - - - {item.GroupName ? {item.GroupName} : null} - {item.GroupName && item.Status ? : null} - {item.Status ? ( - - {item.StatusColor ? : null} - {item.Status} - - ) : null} - {item.Staffing ? ( - <> - - - {item.StaffingColor ? : null} - {item.Staffing} - - - ) : null} - - {item.Roles && item.Roles.length > 0 ? ( - - {item.Roles.map((role, index) => ( - - {role} - - ))} - - ) : null} - - - {isSelected ? : null} - - - ); - }, - [selectedUserId, isDark, handleSelect, t, currentAssignments, currentRoleId] - ); - - return ( - - - - - - - {t('roles.selectUserForRole', { role: roleName, defaultValue: `Assign: ${roleName}` })} - {t('roles.selectUserDescription', 'Choose a person to fill this role')} - - - - - - - - - {/* Search bar */} - - - - - - - - - - {/* Unassigned option */} - handleSelect(undefined)} - className={`px-4 py-3 ${!selectedUserId ? (isDark ? 'bg-primary-900/30' : 'bg-primary-50') : ''}`} - testID="unassigned-option" - accessibilityRole="button" - accessibilityLabel={t('roles.unassigned', 'Unassigned')} - > - - - {!selectedUserId ? : } - - - {t('roles.unassigned', 'Unassigned')} - {t('roles.clearAssignment', 'Clear the current assignment')} - - {!selectedUserId ? : null} - - - - - - {/* User list - rendered directly inside ModalBody (which is already a ScrollView) */} - - {filteredUsers.length > 0 ? ( - filteredUsers.map((user, index) => ( - - {index > 0 ? : null} - {renderUserItem(user)} - - )) - ) : ( - - {t('roles.noUsersFound', 'No users found')} - - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - marginRight: 4, - }, -}); diff --git a/src/components/roles/roles-bottom-sheet.tsx b/src/components/roles/roles-bottom-sheet.tsx deleted file mode 100644 index b47bf79..0000000 --- a/src/components/roles/roles-bottom-sheet.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useColorScheme } from 'nativewind'; -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; -import { Spinner } from '@/components/ui/spinner'; -import { Text } from '@/components/ui/text'; -import { useAnalytics } from '@/hooks/use-analytics'; -import { logger } from '@/lib/logging'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; - -import { Button, ButtonText } from '../ui/button'; -import { HStack } from '../ui/hstack'; -import { ScrollView } from '../ui/scroll-view'; -import { VStack } from '../ui/vstack'; -import { RoleAssignmentItem } from './role-assignment-item'; - -type RolesBottomSheetProps = { - isOpen: boolean; - onClose: () => void; -}; - -export const RolesBottomSheet: React.FC = ({ isOpen, onClose }) => { - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const { trackEvent } = useAnalytics(); - const activeUnit = useCoreStore((state) => state.activeUnit); - const roles = useRolesStore((state) => state.roles); - const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); - const users = useRolesStore((state) => state.users); - const isLoading = useRolesStore((state) => state.isLoading); - const error = useRolesStore((state) => state.error); - - // Add state to track pending changes - const [pendingAssignments, setPendingAssignments] = React.useState<{ roleId: string; userId?: string }[]>([]); - const [isSaving, setIsSaving] = React.useState(false); - - React.useEffect(() => { - if (isOpen && activeUnit) { - useRolesStore.getState().fetchAllForUnit(activeUnit.UnitId); - // Reset pending assignments when bottom sheet opens - setPendingAssignments([]); - } - }, [isOpen, activeUnit]); - - // Track when roles bottom sheet is opened/rendered - React.useEffect(() => { - if (isOpen) { - trackEvent('roles_bottom_sheet_opened', { - unitId: activeUnit?.UnitId || '', - unitName: activeUnit?.Name || '', - rolesCount: roles.length, - usersCount: users.length, - hasError: !!error, - }); - } - }, [isOpen, trackEvent, activeUnit, roles.length, users.length, error]); - - // Handle user assignment changes - auto-unassign from previous role when swapping - const handleAssignUser = React.useCallback( - (roleId: string, userId?: string) => { - setPendingAssignments((current) => { - let updated = current.filter((a) => a.roleId !== roleId); - // If assigning a user, check if they're currently in another role and unassign them - if (userId && userId.trim() !== '') { - // Check effective assignments (server + pending) for this user in another role - const serverAssignment = unitRoleAssignments.find((a) => a.UserId === userId && a.UnitRoleId !== roleId && a.UserId.trim() !== ''); - const pendingForUser = updated.find((a) => a.userId === userId && a.roleId !== roleId); - // If user is assigned elsewhere on server and not already pending-unassigned, add unassignment - if (serverAssignment && !updated.some((a) => a.roleId === serverAssignment.UnitRoleId)) { - updated = [...updated, { roleId: serverAssignment.UnitRoleId, userId: undefined }]; - } - // If user is pending-assigned to another role, remove that pending assignment - if (pendingForUser) { - updated = updated.filter((a) => !(a.roleId === pendingForUser.roleId && a.userId === userId)); - // Add unassignment for that role if it had a server assignment - const hadServerAssignment = unitRoleAssignments.find((a) => a.UnitRoleId === pendingForUser.roleId && a.UserId && a.UserId.trim() !== ''); - if (hadServerAssignment) { - updated = [...updated.filter((a) => a.roleId !== pendingForUser.roleId), { roleId: pendingForUser.roleId, userId: undefined }]; - } - } - } - return [...updated, { roleId, userId }]; - }); - }, - [unitRoleAssignments] - ); - - const filteredRoles = React.useMemo(() => { - return roles.filter((role) => role.UnitId === activeUnit?.UnitId); - }, [roles, activeUnit]); - - // Handle save button - const handleSave = React.useCallback(async () => { - if (!activeUnit) return; - - setIsSaving(true); - try { - // Get all roles for this unit, allowing empty UserId for unassignments - const allUnitRoles = filteredRoles - .map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const currentAssignment = unitRoleAssignments.find((a) => a.UnitRoleId === role.UnitRoleId); - // If there's a pending assignment for this role, use it (even if empty for unassignment) - const assignedUserId = pendingAssignment ? pendingAssignment.userId || '' : currentAssignment?.UserId || ''; - - return { - RoleId: role.UnitRoleId, - UserId: assignedUserId, - Name: '', - }; - }) - .filter((role) => { - // Only filter out entries lacking a RoleId - allow empty UserId for unassignments - return role.RoleId && role.RoleId.trim() !== ''; - }); - - // Save only valid role assignments - await useRolesStore.getState().assignRoles({ - UnitId: activeUnit.UnitId, - Roles: allUnitRoles, - }); - - // Refresh role assignments after all updates - await useRolesStore.getState().fetchRolesForUnit(activeUnit.UnitId); - useToastStore.getState().showToast('success', t('roles.saved_successfully', 'Role assignments saved successfully')); - onClose(); - } catch (err) { - logger.error({ - message: 'Error saving role assignments', - context: { - error: err, - }, - }); - useToastStore.getState().showToast('error', t('roles.save_error', 'Error saving role assignments')); - } finally { - setIsSaving(false); - } - }, [activeUnit, pendingAssignments, onClose, t, filteredRoles, unitRoleAssignments]); - - const handleClose = React.useCallback(() => { - setPendingAssignments([]); - onClose(); - }, [onClose]); - - // Build effective assignments: pending overrides server, ensuring a person can only be in one role - const effectiveAssignments = React.useMemo(() => { - const merged: { roleId: string; userId: string; roleName?: string }[] = []; - - // Start with server assignments that have valid userIds - for (const a of unitRoleAssignments) { - if (a.UserId && a.UserId.trim() !== '') { - merged.push({ roleId: a.UnitRoleId, userId: a.UserId, roleName: a.Name }); - } - } - - // Override with pending assignments - for (const p of pendingAssignments) { - const idx = merged.findIndex((m) => m.roleId === p.roleId); - const roleDef = filteredRoles.find((r) => r.UnitRoleId === p.roleId); - if (idx >= 0) { - if (p.userId && p.userId.trim() !== '') { - merged[idx] = { roleId: p.roleId, userId: p.userId, roleName: roleDef?.Name }; - } else { - // Unassignment: remove from merged - merged.splice(idx, 1); - } - } else if (p.userId && p.userId.trim() !== '') { - merged.push({ roleId: p.roleId, userId: p.userId, roleName: roleDef?.Name }); - } - } - - return merged; - }, [unitRoleAssignments, pendingAssignments, filteredRoles]); - - const hasChanges = pendingAssignments.length > 0; - - return ( - - - - {t('roles.title', 'Unit Role Assignments')} - {activeUnit && {activeUnit.Name}} - - - {error ? ( - - {error} - - ) : ( - - - {filteredRoles.map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const assignment = unitRoleAssignments.find((a) => a.UnitRoleId === role.UnitRoleId); - // If there's a pending assignment for this role, use it (even if userId is empty for unassignment) - const effectiveUserId = pendingAssignment !== undefined ? pendingAssignment.userId : assignment?.UserId; - const assignedUser = effectiveUserId ? users.find((u) => u.UserId === effectiveUserId) : undefined; - - return ( - handleAssignUser(role.UnitRoleId, userId)} - currentAssignments={effectiveAssignments} - /> - ); - })} - - - )} - - - - - - - - ); -}; diff --git a/src/components/roles/roles-modal.tsx b/src/components/roles/roles-modal.tsx deleted file mode 100644 index d169df7..0000000 --- a/src/components/roles/roles-modal.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Spinner } from '@/components/ui/spinner'; -import { Text } from '@/components/ui/text'; -import { logger } from '@/lib/logging'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; - -import { Button, ButtonText } from '../ui/button'; -import { HStack } from '../ui/hstack'; -import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '../ui/modal'; -import { ScrollView } from '../ui/scroll-view'; -import { VStack } from '../ui/vstack'; -import { RoleAssignmentItem } from './role-assignment-item'; - -type RolesModalProps = { - isOpen: boolean; - onClose: () => void; -}; - -export const RolesModal: React.FC = ({ isOpen, onClose }) => { - const { t } = useTranslation(); - const activeUnit = useCoreStore((state) => state.activeUnit); - const roles = useRolesStore((state) => state.roles); - const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); - const users = useRolesStore((state) => state.users); - const isLoading = useRolesStore((state) => state.isLoading); - const error = useRolesStore((state) => state.error); - - // Add state to track pending changes - const [pendingAssignments, setPendingAssignments] = React.useState<{ roleId: string; userId?: string }[]>([]); - - React.useEffect(() => { - if (isOpen && activeUnit) { - useRolesStore.getState().fetchAllForUnit(activeUnit.UnitId); - // Reset pending assignments when modal opens - setPendingAssignments([]); - } - }, [isOpen, activeUnit]); - - // Replace handleAssignUser to update pending assignments - auto-unassign from previous role when swapping - const handleAssignUser = React.useCallback( - (roleId: string, userId?: string) => { - setPendingAssignments((current) => { - let updated = current.filter((a) => a.roleId !== roleId); - // If assigning a user, check if they're currently in another role and unassign them - if (userId && userId.trim() !== '') { - const serverAssignment = unitRoleAssignments.find((a) => a.UserId === userId && a.UnitRoleId !== roleId && a.UserId.trim() !== ''); - const pendingForUser = updated.find((a) => a.userId === userId && a.roleId !== roleId); - if (serverAssignment && !updated.some((a) => a.roleId === serverAssignment.UnitRoleId)) { - updated = [...updated, { roleId: serverAssignment.UnitRoleId, userId: undefined }]; - } - if (pendingForUser) { - updated = updated.filter((a) => !(a.roleId === pendingForUser.roleId && a.userId === userId)); - const hadServerAssignment = unitRoleAssignments.find((a) => a.UnitRoleId === pendingForUser.roleId && a.UserId && a.UserId.trim() !== ''); - if (hadServerAssignment) { - updated = [...updated.filter((a) => a.roleId !== pendingForUser.roleId), { roleId: pendingForUser.roleId, userId: undefined }]; - } - } - } - return [...updated, { roleId, userId }]; - }); - }, - [unitRoleAssignments] - ); - - const filteredRoles = React.useMemo(() => { - return roles.filter((role) => role.UnitId === activeUnit?.UnitId); - }, [roles, activeUnit]); - - // Build effective assignments: pending overrides server, ensuring a person can only be in one role - const effectiveAssignments = React.useMemo(() => { - const merged: { roleId: string; userId: string; roleName?: string }[] = []; - - // Start with server assignments that have valid userIds - for (const a of unitRoleAssignments) { - if (a.UserId && a.UserId.trim() !== '') { - merged.push({ roleId: a.UnitRoleId, userId: a.UserId, roleName: a.Name }); - } - } - - // Override with pending assignments - for (const p of pendingAssignments) { - const idx = merged.findIndex((m) => m.roleId === p.roleId); - const roleDef = filteredRoles.find((r) => r.UnitRoleId === p.roleId); - if (idx >= 0) { - if (p.userId && p.userId.trim() !== '') { - merged[idx] = { roleId: p.roleId, userId: p.userId, roleName: roleDef?.Name }; - } else { - // Unassignment: remove from merged - merged.splice(idx, 1); - } - } else if (p.userId && p.userId.trim() !== '') { - merged.push({ roleId: p.roleId, userId: p.userId, roleName: roleDef?.Name }); - } - } - - return merged; - }, [unitRoleAssignments, pendingAssignments, filteredRoles]); - - const hasChanges = pendingAssignments.length > 0; - - // Add handler for save button - const handleSave = React.useCallback(async () => { - if (!activeUnit) return; - - try { - // Get all roles for this unit and create assignments/removals - const allUnitRoles = filteredRoles - .map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const currentAssignment = unitRoleAssignments.find((a) => a.UnitRoleId === role.UnitRoleId && a.UnitId === activeUnit.UnitId); - // If there's a pending assignment for this role, use it (even if empty for unassignment) - const assignedUserId = pendingAssignment ? pendingAssignment.userId || '' : currentAssignment?.UserId || ''; - - return { - RoleId: role.UnitRoleId, - UserId: assignedUserId, - Name: '', - }; - }) - .filter((role) => { - // Only filter out entries lacking a RoleId - allow empty UserId for unassignments - return role.RoleId && role.RoleId.trim() !== ''; - }); - - await useRolesStore.getState().assignRoles({ - UnitId: activeUnit.UnitId, - Roles: allUnitRoles, - }); - - // Refresh role assignments after all updates - await useRolesStore.getState().fetchRolesForUnit(activeUnit.UnitId); - onClose(); - } catch (err) { - logger.error({ - message: 'Error saving role assignments', - context: { - error: err, - }, - }); - useToastStore.getState().showToast('error', 'Error saving role assignments'); - } - }, [activeUnit, pendingAssignments, onClose, filteredRoles, unitRoleAssignments]); - - return ( - - - - - {t('roles.modal.title', 'Unit Role Assignments')} - - - {isLoading ? ( - - - {t('common.loading', 'Loading...')} - - ) : error ? ( - {error} - ) : ( - - - {filteredRoles.map((role) => { - const pendingAssignment = pendingAssignments.find((a) => a.roleId === role.UnitRoleId); - const assignment = unitRoleAssignments.find((a) => a.UnitRoleId === role.UnitRoleId); - // If there's a pending assignment for this role, use it (even if userId is empty for unassignment) - const effectiveUserId = pendingAssignment !== undefined ? pendingAssignment.userId : assignment?.UserId; - const assignedUser = effectiveUserId ? users.find((u) => u.UserId === effectiveUserId) : undefined; - - return ( - handleAssignUser(role.UnitRoleId, userId)} - currentAssignments={effectiveAssignments} - /> - ); - })} - - - )} - - - - - - - - - - ); -}; diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx deleted file mode 100644 index 9669cf6..0000000 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx +++ /dev/null @@ -1,536 +0,0 @@ -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: any) => key, - }), -})); - -// Mock logging module to prevent Platform.OS undefined error -jest.mock('@/lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -// Mock Platform first, before any other imports -jest.mock('react-native/Libraries/Utilities/Platform', () => ({ - OS: 'ios', - select: jest.fn().mockImplementation((obj) => obj.ios || obj.default), -})); - -// Mock react-native-svg before anything else -jest.mock('react-native-svg', () => ({ - Svg: 'Svg', - Circle: 'Circle', - Ellipse: 'Ellipse', - G: 'G', - Text: 'Text', - TSpan: 'TSpan', - TextPath: 'TextPath', - Path: 'Path', - Polygon: 'Polygon', - Polyline: 'Polyline', - Line: 'Line', - Rect: 'Rect', - Use: 'Use', - Image: 'Image', - Symbol: 'Symbol', - Defs: 'Defs', - LinearGradient: 'LinearGradient', - RadialGradient: 'RadialGradient', - Stop: 'Stop', - ClipPath: 'ClipPath', - Pattern: 'Pattern', - Mask: 'Mask', - default: 'Svg', -})); - -// Mock @expo/html-elements -jest.mock('@expo/html-elements', () => ({ - H1: 'H1', - H2: 'H2', - H3: 'H3', - H4: 'H4', - H5: 'H5', - H6: 'H6', -})); - -import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react-native'; -import React from 'react'; - -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; -import { useUnitsStore } from '@/stores/units/store'; - -import { UnitSelectionBottomSheet } from '../unit-selection-bottom-sheet'; - -// Mock stores -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn(), -})); - -jest.mock('@/stores/roles/store', () => ({ - useRolesStore: { - getState: jest.fn(() => ({ - fetchRolesForUnit: jest.fn(), - })), - }, -})); - -jest.mock('@/stores/units/store', () => ({ - useUnitsStore: jest.fn(), -})); - -jest.mock('@/stores/toast/store', () => ({ - useToastStore: jest.fn(), -})); - -// Mock lucide icons to avoid SVG issues in tests -jest.mock('lucide-react-native', () => ({ - Check: 'Check', -})); - -// Mock gluestack UI components with simple implementations -jest.mock('@/components/ui/actionsheet', () => ({ - Actionsheet: ({ children, isOpen }: any) => (isOpen ? children : null), - ActionsheetBackdrop: ({ children }: any) => children || null, - ActionsheetContent: ({ children }: any) => children, - ActionsheetDragIndicator: () => null, - ActionsheetDragIndicatorWrapper: ({ children }: any) => children, - ActionsheetItem: ({ children, onPress, disabled, testID }: any) => { - const React = require('react'); - const handlePress = disabled ? undefined : onPress; - return React.createElement( - 'TouchableOpacity', - { onPress: handlePress, testID: testID || 'actionsheet-item', disabled }, - children - ); - }, - ActionsheetItemText: ({ children }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: 'actionsheet-item-text' }, children); - }, -})); - -jest.mock('@/components/ui/box', () => ({ - Box: ({ children }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'box' }, children); - }, -})); - -jest.mock('@/components/ui/vstack', () => ({ - VStack: ({ children }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'vstack' }, children); - }, -})); - -jest.mock('@/components/ui/hstack', () => ({ - HStack: ({ children }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'hstack' }, children); - }, -})); - -jest.mock('@/components/ui/text', () => ({ - Text: ({ children }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: 'text' }, children); - }, -})); - -jest.mock('@/components/ui/heading', () => ({ - Heading: ({ children }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: 'heading' }, children); - }, -})); - -jest.mock('@/components/ui/button', () => ({ - Button: ({ children, onPress, disabled }: any) => { - const React = require('react'); - const handlePress = disabled ? undefined : onPress; - return React.createElement( - 'TouchableOpacity', - { onPress: handlePress, testID: 'button', disabled }, - children - ); - }, - ButtonText: ({ children }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: 'button-text' }, children); - }, -})); - -jest.mock('@/components/ui/center', () => ({ - Center: ({ children }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'center' }, children); - }, -})); - -jest.mock('@/components/ui/spinner', () => ({ - Spinner: () => { - const React = require('react'); - return React.createElement('Text', { testID: 'spinner' }, 'Loading...'); - }, -})); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseUnitsStore = useUnitsStore as jest.MockedFunction; -const mockUseToastStore = useToastStore as jest.MockedFunction; - -describe('UnitSelectionBottomSheet', () => { - const mockProps = { - isOpen: true, - onClose: jest.fn(), - }; - - const mockUnits: UnitResultData[] = [ - { - UnitId: '1', - Name: 'Engine 1', - Type: 'Engine', - DepartmentId: '1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '1', - GroupName: 'Station 1', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - { - UnitId: '2', - Name: 'Ladder 1', - Type: 'Ladder', - DepartmentId: '1', - TypeId: 2, - CustomStatusSetId: '', - GroupId: '1', - GroupName: 'Station 1', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - ]; - - const mockSetActiveUnit = jest.fn().mockResolvedValue(undefined); - const mockFetchUnits = jest.fn().mockResolvedValue(undefined); - const mockFetchRolesForUnit = jest.fn().mockResolvedValue(undefined); - const mockShowToast = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: mockUnits[0], - setActiveUnit: mockSetActiveUnit, - } as any) : { - activeUnit: mockUnits[0], - setActiveUnit: mockSetActiveUnit, - } as any); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: mockUnits, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any) : { - units: mockUnits, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any); - - mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ showToast: mockShowToast }) : { showToast: mockShowToast }); - - // Mock the roles store - (useRolesStore.getState as jest.Mock).mockReturnValue({ - fetchRolesForUnit: mockFetchRolesForUnit, - }); - }); - - it('renders correctly when open', () => { - render(); - - expect(screen.getByText('settings.select_unit')).toBeTruthy(); - expect(screen.getByText('settings.current_unit')).toBeTruthy(); - expect(screen.getAllByText('Engine 1')).toHaveLength(2); // One in current selection, one in list - expect(screen.getByText('Ladder 1')).toBeTruthy(); - }); - - it('does not render when closed', () => { - render(); - - expect(screen.queryByText('settings.select_unit')).toBeNull(); - }); - - it('displays loading state when fetching units', () => { - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: true, - } as any) : { - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: true, - } as any); - - render(); - - expect(screen.getByTestId('spinner')).toBeTruthy(); - expect(screen.getByText('Loading...')).toBeTruthy(); - }); - - it('displays empty state when no units available', () => { - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - } as any) : { - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - } as any); - - render(); - - expect(screen.getByText('settings.no_units_available')).toBeTruthy(); - }); - - it('fetches units when sheet opens and no units are loaded', async () => { - const spyFetchUnits = jest.fn().mockResolvedValue(undefined); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: spyFetchUnits, - isLoading: false, - } as any) : { - units: [], - fetchUnits: spyFetchUnits, - isLoading: false, - } as any); - - render(); - - await waitFor(() => { - expect(spyFetchUnits).toHaveBeenCalled(); - }); - }); - - it('does not fetch units when sheet opens and units are already loaded', () => { - render(); - - expect(mockFetchUnits).not.toHaveBeenCalled(); - }); - - it('handles unit selection successfully', async () => { - mockSetActiveUnit.mockResolvedValue(undefined); - mockFetchRolesForUnit.mockResolvedValue(undefined); - - render(); - - // Find the second unit (Ladder 1) and select it using testID - const ladderUnitItem = screen.getByTestId('unit-item-2'); - - await act(async () => { - fireEvent.press(ladderUnitItem); - }); - - await waitFor(() => { - expect(mockSetActiveUnit).toHaveBeenCalledWith('2'); - }); - - await waitFor(() => { - expect(mockFetchRolesForUnit).toHaveBeenCalledWith('2'); - }); - - await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('success', 'settings.unit_selected_successfully'); - }); - - // After all async operations complete and loading states are reset, onClose should be called - await waitFor(() => { - expect(mockProps.onClose).toHaveBeenCalled(); - }); - }); - - it('handles unit selection failure gracefully', async () => { - const error = new Error('Failed to set active unit'); - mockSetActiveUnit.mockRejectedValue(error); - - render(); - - // Find the second unit (Ladder 1) and select it using testID - const ladderUnitItem = screen.getByTestId('unit-item-2'); - fireEvent.press(ladderUnitItem); - - await waitFor(() => { - expect(mockSetActiveUnit).toHaveBeenCalledWith('2'); - }); - - // Should not call fetchRolesForUnit if setActiveUnit fails - expect(mockFetchRolesForUnit).not.toHaveBeenCalled(); - - // Should show error toast - await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('error', 'settings.unit_selection_failed'); - }); - - // Should not close the modal on error - expect(mockProps.onClose).not.toHaveBeenCalled(); - }); - - it('closes when cancel button is pressed', () => { - render(); - - const cancelButton = screen.getByText('common.cancel'); - fireEvent.press(cancelButton); - - expect(mockProps.onClose).toHaveBeenCalled(); - }); - - it('handles selecting same unit (early return)', async () => { - render(); - - // Find the first unit (Engine 1) which is the current active unit and select it - const engineUnitItem = screen.getByTestId('unit-item-1'); - fireEvent.press(engineUnitItem); - - // Should not call setActiveUnit since it's the same unit - expect(mockSetActiveUnit).not.toHaveBeenCalled(); - expect(mockFetchRolesForUnit).not.toHaveBeenCalled(); - - // Should close the modal immediately - await waitFor(() => { - expect(mockProps.onClose).toHaveBeenCalled(); - }); - }); - - it('shows selected unit with check mark and proper styling', () => { - render(); - - // Engine 1 should be marked as selected since it's the active unit - expect(screen.getAllByText('Engine 1')).toHaveLength(2); - expect(screen.getByText('Ladder 1')).toBeTruthy(); - }); - - it('renders units with correct type information', () => { - render(); - - expect(screen.getByText('Engine')).toBeTruthy(); - expect(screen.getByText('Ladder')).toBeTruthy(); - }); - - it('handles fetch units error gracefully', async () => { - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { }); - const errorFetchUnits = jest.fn().mockRejectedValue(new Error('Network error')); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: errorFetchUnits, - isLoading: false, - } as any) : { - units: [], - fetchUnits: errorFetchUnits, - isLoading: false, - } as any); - - render(); - - await waitFor(() => { - expect(errorFetchUnits).toHaveBeenCalled(); - }); - - // Component should still render normally even if fetch fails - expect(screen.getByText('settings.select_unit')).toBeTruthy(); - - consoleError.mockRestore(); - }); - - describe('Accessibility', () => { - it('provides proper test IDs for testing', () => { - render(); - - expect(screen.getByTestId('scroll-view')).toBeTruthy(); - }); - }); - - describe('Edge Cases', () => { - it('handles missing active unit gracefully', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: null, - setActiveUnit: mockSetActiveUnit, - } as any) : { - activeUnit: null, - setActiveUnit: mockSetActiveUnit, - } as any); - - render(); - - // Should not show current unit section - expect(screen.queryByText('settings.current_unit')).toBeNull(); - // Should still show unit list - expect(screen.getByText('Engine 1')).toBeTruthy(); - }); - - it('handles units with missing names gracefully', () => { - const unitsWithMissingNames = [ - { - UnitId: '1', - Name: '', - Type: 'Engine', - DepartmentId: '1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '1', - GroupName: 'Station 1', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - ]; - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: unitsWithMissingNames, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any) : { - units: unitsWithMissingNames, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any); - - render(); - - // Should still render the unit even with empty name - expect(screen.getByText('Engine')).toBeTruthy(); - }); - }); -}); diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx deleted file mode 100644 index 7bbc126..0000000 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx +++ /dev/null @@ -1,574 +0,0 @@ -// Mock react-i18next first -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: jest.fn((key: string, options?: any) => { - const translations: { [key: string]: string } = { - 'settings.select_unit': 'Select Unit', - 'settings.current_unit': 'Current Unit', - 'settings.no_units_available': 'No units available', - 'common.cancel': 'Cancel', - 'settings.unit_selected_successfully': `${options?.unitName || 'Unit'} selected successfully`, - 'settings.unit_selection_failed': 'Failed to select unit. Please try again.', - }; - return translations[key] || key; - }), - }), -})); - -// Mock stores before any imports -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/roles/store'); -jest.mock('@/stores/units/store'); -jest.mock('@/stores/toast/store'); - -// Mock logger -jest.mock('@/lib/logging', () => ({ - logger: { - error: jest.fn(), - info: jest.fn(), - }, -})); - -// Mock lucide icons to avoid SVG issues in tests -jest.mock('lucide-react-native', () => ({ - Check: ({ size, className, testID, ...props }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: testID || 'check-icon', ...props }, 'Check'); - }, -})); - -// Mock gluestack UI components -jest.mock('@/components/ui/actionsheet', () => ({ - Actionsheet: ({ children, isOpen, ...props }: any) => { - const React = require('react'); - return isOpen ? React.createElement('View', { testID: 'actionsheet', ...props }, children) : null; - }, - ActionsheetBackdrop: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'actionsheet-backdrop', ...props }, children); - }, - ActionsheetContent: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'actionsheet-content', ...props }, children); - }, - ActionsheetDragIndicator: ({ ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'actionsheet-drag-indicator', ...props }); - }, - ActionsheetDragIndicatorWrapper: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: 'actionsheet-drag-indicator-wrapper', ...props }, children); - }, - ActionsheetItem: ({ children, onPress, disabled, testID, ...props }: any) => { - const React = require('react'); - return React.createElement( - 'TouchableOpacity', - { - onPress: disabled ? undefined : onPress, - testID: testID || 'actionsheet-item', - disabled, - ...props, - }, - children - ); - }, - ActionsheetItemText: ({ children, testID, ...props }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: testID || 'actionsheet-item-text', ...props }, children); - }, -})); - -jest.mock('@/components/ui/spinner', () => ({ - Spinner: (props: any) => { - const React = require('react'); - return React.createElement('Text', { testID: 'spinner' }, 'Loading...'); - }, -})); - -jest.mock('@/components/ui/box', () => ({ - Box: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: props.testID || 'box', ...props }, children); - }, -})); - -jest.mock('@/components/ui/vstack', () => ({ - VStack: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: props.testID || 'vstack', ...props }, children); - }, -})); - -jest.mock('@/components/ui/hstack', () => ({ - HStack: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: props.testID || 'hstack', ...props }, children); - }, -})); - -jest.mock('@/components/ui/text', () => ({ - Text: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: props.testID || 'text', ...props }, children); - }, -})); - -jest.mock('@/components/ui/heading', () => ({ - Heading: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: props.testID || 'heading', ...props }, children); - }, -})); - -jest.mock('@/components/ui/button', () => ({ - Button: ({ children, onPress, disabled, ...props }: any) => { - const React = require('react'); - return React.createElement( - 'TouchableOpacity', - { - onPress: disabled ? undefined : onPress, - testID: props.testID || 'button', - disabled, - ...props, - }, - children - ); - }, - ButtonText: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('Text', { testID: props.testID || 'button-text', ...props }, children); - }, -})); - -jest.mock('@/components/ui/center', () => ({ - Center: ({ children, ...props }: any) => { - const React = require('react'); - return React.createElement('View', { testID: props.testID || 'center', ...props }, children); - }, -})); - -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react-native'; -import React from 'react'; - -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useUnitsStore } from '@/stores/units/store'; - -import { UnitSelectionBottomSheet } from '../unit-selection-bottom-sheet'; - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseUnitsStore = useUnitsStore as jest.MockedFunction; -const mockUseToastStore = require('@/stores/toast/store').useToastStore as jest.MockedFunction; - -// Test that imports work first -describe('UnitSelectionBottomSheet Import Test', () => { - it('can import the component without errors', () => { - const { UnitSelectionBottomSheet } = require('../unit-selection-bottom-sheet'); - expect(UnitSelectionBottomSheet).toBeDefined(); - // React.memo returns an object, not a function - expect(typeof UnitSelectionBottomSheet).toBe('object'); - expect(UnitSelectionBottomSheet.displayName).toBe('UnitSelectionBottomSheet'); - }); - - it('can create a simple mock component', () => { - const MockComponent = () => React.createElement('View', { testID: 'mock-component' }, 'Mock'); - const { getByTestId } = render(React.createElement(MockComponent)); - expect(getByTestId('mock-component')).toBeTruthy(); - }); - - it('can render the component with minimal props', () => { - // Mock the necessary functions and store returns before rendering - const mockUseCoreStore = require('@/stores/app/core-store').useCoreStore as jest.MockedFunction; - const mockUseUnitsStore = require('@/stores/units/store').useUnitsStore as jest.MockedFunction; - const mockUseToastStore = require('@/stores/toast/store').useToastStore as jest.MockedFunction; - const mockUseRolesStore = require('@/stores/roles/store').useRolesStore; - - // Minimal mock setup - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: null, - setActiveUnit: jest.fn(), - }) : { - activeUnit: null, - setActiveUnit: jest.fn(), - }); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - }) : { - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - }); - - mockUseRolesStore.getState = jest.fn(() => ({ - fetchRolesForUnit: jest.fn(), - })); - - mockUseToastStore.mockImplementation((selector: any) => { - const state = { - showToast: jest.fn(), - toasts: [], - removeToast: jest.fn(), - }; - return selector(state); - }); - - const { UnitSelectionBottomSheet } = require('../unit-selection-bottom-sheet'); - - const testProps = { isOpen: false, onClose: jest.fn() }; - const renderResult = render(React.createElement(UnitSelectionBottomSheet, testProps)); - - // Component should render without crashing (the actionsheet won't render anything when closed) - expect(renderResult).toBeDefined(); - expect(renderResult.toJSON).toBeDefined(); - }); -}); - -describe('UnitSelectionBottomSheet', () => { - const mockProps = { - isOpen: true, - onClose: jest.fn(), - }; - - const mockUnits: UnitResultData[] = [ - { - UnitId: '1', - Name: 'Engine 1', - Type: 'Engine', - DepartmentId: '1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '1', - GroupName: 'Station 1', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - { - UnitId: '2', - Name: 'Ladder 1', - Type: 'Ladder', - DepartmentId: '1', - TypeId: 2, - CustomStatusSetId: '', - GroupId: '1', - GroupName: 'Station 1', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - { - UnitId: '3', - Name: 'Rescue 1', - Type: 'Rescue', - DepartmentId: '1', - TypeId: 3, - CustomStatusSetId: '', - GroupId: '2', - GroupName: 'Station 2', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - } as UnitResultData, - ]; - - const mockSetActiveUnit = jest.fn().mockResolvedValue(undefined); - const mockFetchUnits = jest.fn().mockResolvedValue(undefined); - const mockFetchRolesForUnit = jest.fn().mockResolvedValue(undefined); - const mockShowToast = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: mockUnits[0], - setActiveUnit: mockSetActiveUnit, - } as any) : { - activeUnit: mockUnits[0], - setActiveUnit: mockSetActiveUnit, - } as any); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: mockUnits, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any) : { - units: mockUnits, - fetchUnits: mockFetchUnits, - isLoading: false, - } as any); - - // Mock the roles store - (useRolesStore.getState as jest.Mock).mockReturnValue({ - fetchRolesForUnit: mockFetchRolesForUnit, - }); - - // Mock the toast store - mockUseToastStore.mockImplementation((selector: any) => { - const state = { - showToast: mockShowToast, - toasts: [], - removeToast: jest.fn(), - }; - return selector(state); - }); - }); - - it('renders correctly when open', () => { - render(); - - expect(screen.getByText('Select Unit')).toBeTruthy(); - expect(screen.getByText('Current Unit')).toBeTruthy(); - // Engine 1 appears twice: once in current selection and once in the list - expect(screen.getAllByText('Engine 1')).toHaveLength(2); - expect(screen.getByText('Ladder 1')).toBeTruthy(); - expect(screen.getByText('Rescue 1')).toBeTruthy(); - }); - - it('does not render when closed', () => { - render(); - - expect(screen.queryByText('Select Unit')).toBeNull(); - }); - - it('displays current unit selection', () => { - render(); - - expect(screen.getByText('Current Unit')).toBeTruthy(); - // The current unit (Engine 1) should be displayed - it appears twice: once in current section, once in list - expect(screen.getAllByText('Engine 1')).toHaveLength(2); - }); - - it('displays loading state when fetching units', () => { - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: true, - } as any) : { - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: true, - } as any); - - render(); - - expect(screen.getByTestId('spinner')).toBeTruthy(); - expect(screen.getByText('Loading...')).toBeTruthy(); - }); - - it('displays empty state when no units available', () => { - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - } as any) : { - units: [], - fetchUnits: jest.fn().mockResolvedValue(undefined), - isLoading: false, - } as any); - - render(); - - expect(screen.getByText('No units available')).toBeTruthy(); - }); - - it('fetches units when sheet opens and no units are loaded', async () => { - const spyFetchUnits = jest.fn().mockResolvedValue(undefined); - - mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - units: [], - fetchUnits: spyFetchUnits, - isLoading: false, - } as any) : { - units: [], - fetchUnits: spyFetchUnits, - isLoading: false, - } as any); - - render(); - - await waitFor(() => { - expect(spyFetchUnits).toHaveBeenCalled(); - }); - }); - - it('does not fetch units when sheet opens and units are already loaded', () => { - render(); - - expect(mockFetchUnits).not.toHaveBeenCalled(); - }); - - it('closes when cancel button is pressed', () => { - render(); - - const cancelButton = screen.getByText('Cancel'); - fireEvent.press(cancelButton); - - expect(mockProps.onClose).toHaveBeenCalled(); - }); - - it('handles unit selection with success', async () => { - render(); - - const unitToSelect = screen.getByTestId('unit-item-2'); - - await act(async () => { - fireEvent.press(unitToSelect); - }); - - await waitFor(() => { - expect(mockSetActiveUnit).toHaveBeenCalledWith('2'); - }); - - expect(mockFetchRolesForUnit).toHaveBeenCalledWith('2'); - expect(mockShowToast).toHaveBeenCalledWith('success', 'Ladder 1 selected successfully'); - }); - - it('handles selecting the same unit that is already active', async () => { - render(); - - const sameUnitButton = screen.getByTestId('unit-item-1'); - - await act(async () => { - fireEvent.press(sameUnitButton); - }); - - await waitFor(() => { - expect(mockProps.onClose).toHaveBeenCalled(); - }); - - // Should not call setActiveUnit for the same unit - expect(mockSetActiveUnit).not.toHaveBeenCalled(); - }); - - it('handles unit selection failure', async () => { - mockSetActiveUnit.mockRejectedValueOnce(new Error('Network error')); - - render(); - - const unitToSelect = screen.getByTestId('unit-item-2'); - - await act(async () => { - fireEvent.press(unitToSelect); - }); - - await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('error', 'Failed to select unit. Please try again.'); - }); - - // Should not close on error - expect(mockProps.onClose).not.toHaveBeenCalled(); - }); - - it('handles roles fetch failure gracefully', async () => { - mockFetchRolesForUnit.mockRejectedValueOnce(new Error('Roles fetch failed')); - - render(); - - const unitToSelect = screen.getByTestId('unit-item-2'); - - await act(async () => { - fireEvent.press(unitToSelect); - }); - - await waitFor(() => { - expect(mockSetActiveUnit).toHaveBeenCalledWith('2'); - }); - - expect(mockFetchRolesForUnit).toHaveBeenCalledWith('2'); - expect(mockShowToast).toHaveBeenCalledWith('error', 'Failed to select unit. Please try again.'); - }); - - it('prevents multiple concurrent unit selections', async () => { - render(); - - const unitToSelect = screen.getByTestId('unit-item-2'); - - await act(async () => { - // Trigger multiple rapid selections - fireEvent.press(unitToSelect); - fireEvent.press(unitToSelect); - fireEvent.press(unitToSelect); - }); - - await waitFor(() => { - expect(mockSetActiveUnit).toHaveBeenCalledTimes(1); - }); - }); - - it('does not close when loading', async () => { - // Make setActiveUnit slow to simulate loading state - mockSetActiveUnit.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); - - render(); - - const unitToSelect = screen.getByTestId('unit-item-2'); - - await act(async () => { - fireEvent.press(unitToSelect); - }); - - // During loading state, pressing cancel should not close - const cancelButton = screen.getByText('Cancel'); - fireEvent.press(cancelButton); - - // onClose should not be called while loading - await new Promise((resolve) => setTimeout(resolve, 50)); // Wait a bit but not long enough for the async operation - expect(mockProps.onClose).not.toHaveBeenCalled(); - }); - - it('renders with no active unit', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: null, - setActiveUnit: mockSetActiveUnit, - } as any) : { - activeUnit: null, - setActiveUnit: mockSetActiveUnit, - } as any); - - render(); - - // Should not display current unit section - expect(screen.queryByText('Current Unit')).toBeNull(); - expect(screen.getByText('Select Unit')).toBeTruthy(); - expect(screen.getByText('Engine 1')).toBeTruthy(); - }); - - it('groups units correctly in the list', () => { - render(); - - // All units should be displayed (Engine 1 appears twice: in current selection and in list) - expect(screen.getAllByText('Engine 1')).toHaveLength(2); - expect(screen.getByText('Ladder 1')).toBeTruthy(); - expect(screen.getByText('Rescue 1')).toBeTruthy(); - - // Check that unit types are displayed - expect(screen.getAllByText('Engine')).toBeTruthy(); - expect(screen.getAllByText('Ladder')).toBeTruthy(); - expect(screen.getAllByText('Rescue')).toBeTruthy(); - }); -}); diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx deleted file mode 100644 index 7b237a3..0000000 --- a/src/components/settings/unit-selection-bottom-sheet.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { Check } from 'lucide-react-native'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { logger } from '@/lib/logging'; -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; -import { useUnitsStore } from '@/stores/units/store'; - -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetItem, ActionsheetItemText } from '../ui/actionsheet'; -import { Box } from '../ui/box'; -import { Button, ButtonText } from '../ui/button'; -import { Center } from '../ui/center'; -import { Heading } from '../ui/heading'; -import { HStack } from '../ui/hstack'; -import { ScrollView } from '../ui/scroll-view'; -import { Spinner } from '../ui/spinner'; -import { Text } from '../ui/text'; -import { VStack } from '../ui/vstack'; - -interface UnitItemProps { - unit: UnitResultData; - isSelected: boolean; - isLoading: boolean; - onSelect: (unit: UnitResultData) => void; -} - -const UnitItem = React.memo(({ unit, isSelected, isLoading, onSelect }) => { - const handlePress = React.useCallback(() => { - onSelect(unit); - }, [onSelect, unit]); - - return ( - - - - {unit.Name} - - - {unit.Type} - - - {isSelected ? : null} - - ); -}); - -UnitItem.displayName = 'UnitItem'; - -interface UnitSelectionBottomSheetProps { - isOpen: boolean; - onClose: () => void; -} - -export const UnitSelectionBottomSheet = React.memo(({ isOpen, onClose }) => { - const { t } = useTranslation(); - const [isLoading, setIsLoading] = React.useState(false); - const units = useUnitsStore((state) => state.units); - const fetchUnits = useUnitsStore((state) => state.fetchUnits); - const isLoadingUnits = useUnitsStore((state) => state.isLoading); - const activeUnit = useCoreStore((state) => state.activeUnit); - const setActiveUnit = useCoreStore((state) => state.setActiveUnit); - const showToast = useToastStore((state) => state.showToast); - const isProcessingRef = React.useRef(false); - - // Fetch units when sheet opens - React.useEffect(() => { - if (isOpen && units.length === 0) { - fetchUnits().catch((error) => { - logger.error({ - message: 'Failed to fetch units', - context: { error }, - }); - }); - } - }, [isOpen, units.length, fetchUnits]); - - const handleClose = React.useCallback(() => { - if (isLoading || isProcessingRef.current) { - return; - } - onClose(); - }, [onClose, isLoading]); - - const handleUnitSelection = React.useCallback( - async (unit: UnitResultData) => { - // Prevent multiple concurrent selections using ref guard - if (isLoading || isProcessingRef.current) { - return; - } - - // Additional check for same unit selection - if (activeUnit?.UnitId === unit.UnitId) { - logger.info({ - message: 'Same unit already selected, closing modal', - context: { unitId: unit.UnitId }, - }); - handleClose(); - return; - } - - try { - isProcessingRef.current = true; - setIsLoading(true); - let hasError = false; - - try { - await setActiveUnit(unit.UnitId); - await useRolesStore.getState().fetchRolesForUnit(unit.UnitId); - - logger.info({ - message: 'Active unit updated successfully', - context: { unitId: unit.UnitId, unitName: unit.Name }, - }); - - showToast('success', t('settings.unit_selected_successfully', { unitName: unit.Name })); - } catch (error) { - hasError = true; - logger.error({ - message: 'Failed to update active unit', - context: { error, unitId: unit.UnitId, unitName: unit.Name }, - }); - - showToast('error', t('settings.unit_selection_failed')); - } finally { - setIsLoading(false); - isProcessingRef.current = false; - - // Call handleClose after resetting loading states so it can actually close - if (!hasError) { - handleClose(); - } - } - } catch (outerError) { - // This should not happen, but just in case - setIsLoading(false); - isProcessingRef.current = false; - } - }, - [setActiveUnit, handleClose, isLoading, activeUnit, showToast, t] - ); - - return ( - - - - - - - - - - {t('settings.select_unit')} - - - {/* Current Selection */} - {activeUnit && ( - - - - {t('settings.current_unit')} - - - {activeUnit.Name} - - - - )} - - {/* Units List */} - - {isLoading ? ( -
- - - {t('settings.activating_unit')} - -
- ) : ( - - {isLoadingUnits ? ( -
- -
- ) : units.length > 0 ? ( - - {units.map((unit) => ( - - ))} - - ) : ( -
- {t('settings.no_units_available')} -
- )} -
- )} -
- - {/* Cancel Button - Fixed to bottom */} - - - -
-
-
- ); -}); - -UnitSelectionBottomSheet.displayName = 'UnitSelectionBottomSheet'; diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx deleted file mode 100644 index 5af66c6..0000000 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ /dev/null @@ -1,559 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react-native'; -import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; -import { useColorScheme } from 'nativewind'; -import { router } from 'expo-router'; -import { Alert } from 'react-native'; - -import { SidebarCallCard } from '../call-sidebar'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useCallsStore } from '@/stores/calls/store'; -import { type CallResultData } from '@/models/v4/calls/callResultData'; -import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; -import { openMapsWithDirections, openMapsWithAddress } from '@/lib/navigation'; - -// Mock dependencies -jest.mock('react-i18next'); -jest.mock('@tanstack/react-query'); -jest.mock('nativewind'); -jest.mock('expo-router'); -jest.mock('react-native/Libraries/Alert/Alert'); -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/calls/store'); -jest.mock('@/lib/navigation'); - -// Mock UI components -jest.mock('@/components/ui/bottom-sheet', () => ({ - CustomBottomSheet: ({ children, isOpen, onClose, isLoading, testID }: any) => { - const { View, Text } = require('react-native'); - return isOpen ? ( - - {isLoading ? Loading... : children} - - ) : null; - }, -})); - -jest.mock('@/components/calls/call-card', () => ({ - CallCard: ({ call }: any) => { - const { View, Text } = require('react-native'); - return ( - - {call.Name} - - ); - }, -})); - -jest.mock('@/components/ui/card', () => ({ - Card: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/text', () => ({ - Text: ({ children, testID }: any) => { - const { Text } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/vstack', () => ({ - VStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/hstack', () => ({ - HStack: ({ children }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -jest.mock('@/components/ui/button', () => ({ - Button: ({ children, onPress, testID }: any) => { - const { TouchableOpacity } = require('react-native'); - return ( - - {children} - - ); - }, - ButtonIcon: ({ as: Icon, testID }: any) => { - const { View, Text } = require('react-native'); - // Create a testID based on the icon type - let iconTestId = testID; - if (Icon) { - if (Icon === require('lucide-react-native').Eye) { - iconTestId = 'eye-icon'; - } else if (Icon === require('lucide-react-native').MapPin) { - iconTestId = 'map-pin-icon'; - } else if (Icon === require('lucide-react-native').CircleX) { - iconTestId = 'circle-x-icon'; - } else if (Icon === require('lucide-react-native').Check) { - iconTestId = 'check-icon'; - } - } - return ( - - Icon - - ); - }, -})); - -jest.mock('lucide-react-native', () => ({ - Check: (props: any) => { - const { View, Text } = require('react-native'); - return Check; - }, - CircleX: (props: any) => { - const { View, Text } = require('react-native'); - return CircleX; - }, - Eye: (props: any) => { - const { View, Text } = require('react-native'); - return Eye; - }, - MapPin: (props: any) => { - const { View, Text } = require('react-native'); - return MapPin; - }, -})); - -const mockCall: CallResultData = { - CallId: '123', - Priority: 1, - Name: 'Test Emergency Call', - Nature: 'Emergency', - Note: 'Test note', - Address: '123 Test Street', - Geolocation: '40.7128,-74.0060', - LoggedOn: '2023-01-01T10:00:00Z', - State: '1', - Number: 'C001', - NotesCount: 0, - AudioCount: 0, - ImgagesCount: 0, - FileCount: 0, - What3Words: '', - ContactName: '', - ContactInfo: '', - ReferenceId: '', - ExternalId: '', - IncidentId: '', - AudioFileId: '', - Type: 'Emergency', - LoggedOnUtc: '2023-01-01T10:00:00Z', - DispatchedOn: '2023-01-01T10:05:00Z', - DispatchedOnUtc: '2023-01-01T10:05:00Z', - Latitude: '40.7128', - Longitude: '-74.0060', - CheckInTimersEnabled: false, -}; - -const mockPriority: CallPriorityResultData = { - Id: 1, - DepartmentId: 1, - Name: 'High Priority', - Color: '#FF0000', - Sort: 1, - IsDeleted: false, - IsDefault: false, - Tone: 0, -}; - -const mockCalls: CallResultData[] = [ - mockCall, - { - ...mockCall, - CallId: '456', - Number: 'C002', - Name: 'Test Fire Call', - Type: 'Fire', - Address: '456 Fire Lane', - }, -]; - -// Mock function implementations -const mockUseTranslation = useTranslation as jest.MockedFunction; -const mockUseQuery = useQuery as jest.MockedFunction; -const mockUseColorScheme = useColorScheme as jest.MockedFunction; -const mockRouter = router as jest.Mocked; -const mockAlert = Alert as jest.Mocked; -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseCallsStore = useCallsStore as jest.MockedFunction; -const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction; -const mockOpenMapsWithAddress = openMapsWithAddress as jest.MockedFunction; - -describe('SidebarCallCard', () => { - const mockSetActiveCall = jest.fn(); - const mockFetchCalls = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseTranslation.mockReturnValue({ - t: (key: string) => key, - i18n: {} as any, - ready: true, - } as any); - - mockUseColorScheme.mockReturnValue({ - colorScheme: 'light', - setColorScheme: jest.fn(), - toggleColorScheme: jest.fn(), - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: null, - activePriority: null, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: null, - activePriority: null, - setActiveCall: mockSetActiveCall, - }); - - mockUseCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - calls: [], - fetchCalls: mockFetchCalls, - }) : { - calls: [], - fetchCalls: mockFetchCalls, - }); - - mockUseQuery.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: jest.fn(), - } as any); - - mockAlert.alert = jest.fn(); - mockRouter.push = jest.fn(); - mockOpenMapsWithDirections.mockResolvedValue(true); - mockOpenMapsWithAddress.mockResolvedValue(true); - }); - - describe('Basic Rendering', () => { - it('should render with no active call', () => { - render(); - - expect(screen.getByTestId('call-selection-trigger')).toBeTruthy(); - expect(screen.getByTestId('card')).toBeTruthy(); - expect(screen.getByText('calls.no_call_selected')).toBeTruthy(); - expect(screen.getByText('calls.no_call_selected_info')).toBeTruthy(); - }); - - it('should render with active call', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - expect(screen.getByTestId('call-selection-trigger')).toBeTruthy(); - expect(screen.getByTestId('call-card')).toBeTruthy(); - expect(screen.getByTestId('call-name')).toBeTruthy(); - expect(screen.getByText('Test Emergency Call')).toBeTruthy(); - }); - - it('should show action buttons when active call exists with coordinates', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - expect(screen.getByTestId('eye-icon')).toBeTruthy(); - expect(screen.getByTestId('map-pin-icon')).toBeTruthy(); - expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); - }); - - it('should show map button when active call has address only', () => { - const callWithAddressOnly = { - ...mockCall, - Latitude: '', - Longitude: '', - Address: '123 Test Street', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - expect(screen.getByTestId('eye-icon')).toBeTruthy(); - expect(screen.getByTestId('map-pin-icon')).toBeTruthy(); - expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); - }); - - it('should not show map button when active call has no location data', () => { - const callWithoutLocation = { - ...mockCall, - Latitude: '', - Longitude: '', - Address: '', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithoutLocation, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithoutLocation, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - expect(screen.getByTestId('eye-icon')).toBeTruthy(); - expect(() => screen.getByTestId('map-pin-icon')).toThrow(); - expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); - }); - - it('should not show map button when active call has empty address', () => { - const callWithEmptyAddress = { - ...mockCall, - Latitude: '', - Longitude: '', - Address: ' ', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithEmptyAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithEmptyAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - expect(screen.getByTestId('eye-icon')).toBeTruthy(); - expect(() => screen.getByTestId('map-pin-icon')).toThrow(); - expect(screen.getByTestId('circle-x-icon')).toBeTruthy(); - }); - }); - - describe('Bottom Sheet Behavior', () => { - it('should open bottom sheet when trigger is pressed', () => { - render(); - - fireEvent.press(screen.getByTestId('call-selection-trigger')); - - expect(screen.getByTestId('call-selection-bottom-sheet')).toBeTruthy(); - }); - - it('should show loading state in bottom sheet', () => { - mockUseQuery.mockReturnValue({ - data: [], - isLoading: true, - error: null, - refetch: jest.fn(), - } as any); - - render(); - - fireEvent.press(screen.getByTestId('call-selection-trigger')); - - expect(screen.getByTestId('loading-spinner')).toBeTruthy(); - }); - - it('should display calls list in bottom sheet', () => { - mockUseQuery.mockReturnValue({ - data: mockCalls, - isLoading: false, - error: null, - refetch: jest.fn(), - } as any); - - render(); - - fireEvent.press(screen.getByTestId('call-selection-trigger')); - - expect(screen.getByText('calls.select_active_call')).toBeTruthy(); - expect(screen.getByTestId('call-item-123')).toBeTruthy(); - expect(screen.getByTestId('call-item-456')).toBeTruthy(); - }); - - it('should show no calls message when list is empty', () => { - mockUseQuery.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: jest.fn(), - } as any); - - render(); - - fireEvent.press(screen.getByTestId('call-selection-trigger')); - - expect(screen.getByTestId('no-calls-message')).toBeTruthy(); - expect(screen.getByText('calls.no_open_calls')).toBeTruthy(); - }); - }); - - describe('Action Buttons', () => { - beforeEach(() => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - }); - - it('should navigate to call detail when eye button is pressed', () => { - render(); - - fireEvent.press(screen.getByTestId('eye-icon')); - - expect(mockRouter.push).toHaveBeenCalledWith('/call/123'); - }); - - it('should show deselect confirmation when deselect button is pressed', () => { - render(); - - fireEvent.press(screen.getByTestId('circle-x-icon')); - - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.confirm_deselect_title', - 'calls.confirm_deselect_message', - expect.arrayContaining([ - expect.objectContaining({ text: 'common.cancel' }), - expect.objectContaining({ text: 'common.confirm' }), - ]), - { cancelable: true } - ); - }); - - it('should open maps with coordinates when map pin button is pressed with valid coordinates', async () => { - render(); - - fireEvent.press(screen.getByTestId('map-pin-icon')); - - expect(mockOpenMapsWithDirections).toHaveBeenCalledWith( - mockCall.Latitude, - mockCall.Longitude, - mockCall.Address - ); - expect(mockOpenMapsWithAddress).not.toHaveBeenCalled(); - }); - - it('should open maps with address when map pin button is pressed with address only', async () => { - const callWithAddressOnly = { - ...mockCall, - Latitude: '', - Longitude: '', - Address: '123 Test Street', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - render(); - - fireEvent.press(screen.getByTestId('map-pin-icon')); - - expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('123 Test Street'); - expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); - }); - - it('should show error alert when openMapsWithDirections fails', async () => { - mockOpenMapsWithDirections.mockRejectedValue(new Error('Navigation failed')); - - render(); - - fireEvent.press(screen.getByTestId('map-pin-icon')); - - // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); - }); - - it('should show error alert when openMapsWithAddress fails', async () => { - const callWithAddressOnly = { - ...mockCall, - Latitude: '', - Longitude: '', - Address: '123 Test Street', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); - - mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed')); - - render(); - - fireEvent.press(screen.getByTestId('map-pin-icon')); - - // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); - }); - }); - - describe('Accessibility', () => { - it('should have proper testIDs for automation', () => { - render(); - - expect(screen.getByTestId('call-selection-trigger')).toBeTruthy(); - }); - }); -}); \ No newline at end of file diff --git a/src/components/sidebar/__tests__/roles-sidebar.test.tsx b/src/components/sidebar/__tests__/roles-sidebar.test.tsx deleted file mode 100644 index e040ee5..0000000 --- a/src/components/sidebar/__tests__/roles-sidebar.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { render, screen } from '@testing-library/react-native'; -import React from 'react'; - -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { type ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; -import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; - -import { SidebarRolesCard } from '../roles-sidebar'; - -// Mock the stores -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/roles/store'); - -// Mock the RolesBottomSheet component -jest.mock('../../roles/roles-bottom-sheet', () => ({ - RolesBottomSheet: ({ isOpen }: any) => { - if (!isOpen) return null; - return
Roles Bottom Sheet
; - }, -})); - -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: any) => { - if (key === 'roles.status') { - return `${options.active}/${options.total} Active`; - } - return key; - }, - }), -})); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseRolesStore = useRolesStore as jest.MockedFunction; - -describe('SidebarRolesCard', () => { - const mockActiveUnit: UnitResultData = { - UnitId: 'unit1', - Name: 'Unit 1', - Type: 'Engine', - DepartmentId: 'dept1', - TypeId: 1, - CustomStatusSetId: '', - GroupId: '', - GroupName: '', - Vin: '', - PlateNumber: '', - FourWheelDrive: false, - SpecialPermit: false, - CurrentDestinationId: '', - CurrentStatusId: '', - CurrentStatusTimestamp: '', - Latitude: '', - Longitude: '', - Note: '', - }; - - const mockRoles: UnitRoleResultData[] = [ - { UnitRoleId: 'role1', UnitId: 'unit1', Name: 'Captain' }, - { UnitRoleId: 'role2', UnitId: 'unit1', Name: 'Engineer' }, - { UnitRoleId: 'role3', UnitId: 'unit1', Name: 'Firefighter' }, - ]; - - const mockUnitRoleAssignments: ActiveUnitRoleResultData[] = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role2', - UnitId: 'unit1', - Name: 'Engineer', - UserId: 'user2', - FullName: 'Jane Smith', - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role3', - UnitId: 'unit1', - Name: 'Firefighter', - UserId: '', - FullName: '', // Unassigned role - UpdatedOn: new Date().toISOString(), - }, - ]; - - const mockStoreState = { roles: mockRoles, unitRoleAssignments: mockUnitRoleAssignments }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit }); - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStoreState) : mockStoreState); - }); - - it('renders correctly with active unit and role assignments', () => { - render(); - - expect(screen.getByText('2/3 Active')).toBeTruthy(); - }); - - it('displays zero counts when no active unit', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }); - - render(); - - expect(screen.getByText('0/0 Active')).toBeTruthy(); - }); - - it('displays zero counts when no role assignments and no roles', () => { - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: [], roles: [] }) : { unitRoleAssignments: [], roles: [] }); - - render(); - - expect(screen.getByText('0/0 Active')).toBeTruthy(); - }); - - it('filters role assignments by active unit', () => { - const roleAssignmentsWithDifferentUnits = [ - ...mockUnitRoleAssignments, - { - UnitRoleId: 'role4', - UnitId: 'unit2', // Different unit - Name: 'Chief', - UserId: 'user3', - FullName: 'Bob Johnson', - UpdatedOn: new Date().toISOString(), - }, - ]; - - const rolesWithDifferentUnits = [ - ...mockRoles, - { UnitRoleId: 'role4', UnitId: 'unit2', Name: 'Chief' }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: roleAssignmentsWithDifferentUnits, roles: rolesWithDifferentUnits }) : { unitRoleAssignments: roleAssignmentsWithDifferentUnits, roles: rolesWithDifferentUnits }); - - render(); - - // Should only count assignments for unit1 - expect(screen.getByText('2/3 Active')).toBeTruthy(); - }); - - it('counts active assignments correctly', () => { - const testAssignments = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role2', - UnitId: 'unit1', - Name: 'Engineer', - UserId: 'user2', - FullName: '', // Empty name should not count as active - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role3', - UnitId: 'unit1', - Name: 'Firefighter', - UserId: '', - FullName: null as any, // Null name should not count as active - UpdatedOn: new Date().toISOString(), - }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: testAssignments, roles: mockRoles }) : { unitRoleAssignments: testAssignments, roles: mockRoles }); - - render(); - - // Should count only the one with a non-empty FullName - expect(screen.getByText('1/3 Active')).toBeTruthy(); - }); - - it('handles empty unit role assignments gracefully', () => { - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: [], roles: mockRoles }) : { unitRoleAssignments: [], roles: mockRoles }); - - render(); - - expect(screen.getByText('0/3 Active')).toBeTruthy(); - }); - - it('handles assignments with undefined FullName', () => { - const assignmentsWithUndefinedFullName = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: undefined as any, - UpdatedOn: new Date().toISOString(), - }, - { - UnitRoleId: 'role2', - UnitId: 'unit1', - Name: 'Engineer', - UserId: 'user2', - FullName: 'Jane Smith', - UpdatedOn: new Date().toISOString(), - }, - ]; - - const twoRoles: UnitRoleResultData[] = [ - { UnitRoleId: 'role1', UnitId: 'unit1', Name: 'Captain' }, - { UnitRoleId: 'role2', UnitId: 'unit1', Name: 'Engineer' }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: assignmentsWithUndefinedFullName, roles: twoRoles }) : { unitRoleAssignments: assignmentsWithUndefinedFullName, roles: twoRoles }); - - render(); - - expect(screen.getByText('1/2 Active')).toBeTruthy(); - }); - - it('memoizes counts correctly when props change', () => { - const { rerender } = render(); - - expect(screen.getByText('2/3 Active')).toBeTruthy(); - - // Change the role assignments - const newAssignments = [ - { - UnitRoleId: 'role1', - UnitId: 'unit1', - Name: 'Captain', - UserId: 'user1', - FullName: 'John Doe', - UpdatedOn: new Date().toISOString(), - }, - ]; - - const oneRole: UnitRoleResultData[] = [ - { UnitRoleId: 'role1', UnitId: 'unit1', Name: 'Captain' }, - ]; - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: newAssignments, roles: oneRole }) : { unitRoleAssignments: newAssignments, roles: oneRole }); - - rerender(); - - expect(screen.getByText('1/1 Active')).toBeTruthy(); - }); - - it('handles unit change correctly', () => { - const { rerender } = render(); - - expect(screen.getByText('2/3 Active')).toBeTruthy(); - - // Change the active unit - const newUnit = { - ...mockActiveUnit, - UnitId: 'unit2', - Name: 'Unit 2', - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: newUnit }) : { activeUnit: newUnit }); - - rerender(); - - // Should show 0/0 since no roles for unit2 - expect(screen.getByText('0/0 Active')).toBeTruthy(); - }); - - it('renders the card component', () => { - render(); - - expect(screen.getByTestId('roles-sidebar-card')).toBeTruthy(); - expect(screen.getByTestId('roles-status-text')).toBeTruthy(); - }); -}); \ No newline at end of file diff --git a/src/components/sidebar/__tests__/sidebar-content.test.tsx b/src/components/sidebar/__tests__/sidebar-content.test.tsx new file mode 100644 index 0000000..23334b1 --- /dev/null +++ b/src/components/sidebar/__tests__/sidebar-content.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +const mockPush = jest.fn(); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` }); + return { + ChevronRight: icon('chevron-right'), + ClipboardList: icon('clipboard-list'), + CloudAlert: icon('cloud-alert'), + Map: icon('map'), + Megaphone: icon('megaphone'), + Settings: icon('settings'), + }; +}); + +import Sidebar from '../sidebar-content'; + +describe('Sidebar menu', () => { + beforeEach(() => { + mockPush.mockClear(); + }); + + it('renders a link for every page', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('sidebar-link-map')).toBeTruthy(); + expect(getByTestId('sidebar-link-calls')).toBeTruthy(); + expect(getByTestId('sidebar-link-command')).toBeTruthy(); + expect(getByTestId('sidebar-link-weather-alerts')).toBeTruthy(); + expect(getByTestId('sidebar-link-settings')).toBeTruthy(); + + unmount(); + }); + + it('renders translated labels', () => { + const { getByText, unmount } = render(); + + expect(getByText('sidebar.menu')).toBeTruthy(); + expect(getByText('tabs.map')).toBeTruthy(); + expect(getByText('tabs.calls')).toBeTruthy(); + expect(getByText('tabs.command_board')).toBeTruthy(); + expect(getByText('tabs.weather_alerts')).toBeTruthy(); + expect(getByText('tabs.settings')).toBeTruthy(); + + unmount(); + }); + + it('navigates and closes the drawer when a link is pressed', () => { + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('sidebar-link-command')); + + expect(onClose).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/command'); + + unmount(); + }); + + it('navigates to the map root from the map link', () => { + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('sidebar-link-map')); + + expect(mockPush).toHaveBeenCalledWith('/'); + + unmount(); + }); +}); diff --git a/src/components/sidebar/__tests__/status-sidebar.test.tsx b/src/components/sidebar/__tests__/status-sidebar.test.tsx deleted file mode 100644 index f88ab14..0000000 --- a/src/components/sidebar/__tests__/status-sidebar.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react-native'; - -import { useCoreStore } from '@/stores/app/core-store'; -import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; - -import { SidebarStatusCard } from '../status-sidebar'; - -// Mock the store -jest.mock('@/stores/app/core-store'); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; - -// Helper function to create mock status objects -const createMockStatus = (overrides: Partial = {}): UnitStatusResultData => ({ - UnitId: '123', - State: 'Available', - StateStyle: 'label-success', - Timestamp: '2023-01-01T12:00:00Z', - Note: 'Test note', - Name: 'Unit 1', - Type: 'Engine', - StateCss: '', - DestinationId: '', - Latitude: '', - Longitude: '', - GroupName: '', - GroupId: '', - Eta: '', - ...overrides, -}); - -describe('SidebarStatusCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should render with unknown status when activeUnitStatus is null', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null }); - - render(); - - expect(screen.getByText('Unknown')).toBeTruthy(); - }); - - it('should render with unknown status when activeUnitStatus is undefined', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: undefined }) : { activeUnitStatus: undefined }); - - render(); - - expect(screen.getByText('Unknown')).toBeTruthy(); - }); - - it('should render the correct status text', () => { - const mockStatus = createMockStatus(); - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - render(); - - expect(screen.getByText('Available')).toBeTruthy(); - }); - - it('should render status with empty string when State is missing', () => { - const mockStatus = createMockStatus({ State: '' }); - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - render(); - - expect(screen.getByText('Unknown')).toBeTruthy(); - }); - - describe('Color mapping', () => { - const colorTestCases = [ - { styleClass: 'label-danger', expectedColor: '#ED5565', label: 'danger' }, - { styleClass: 'label-info', expectedColor: '#23c6c8', label: 'info' }, - { styleClass: 'label-warning', expectedColor: '#f8ac59', label: 'warning' }, - { styleClass: 'label-success', expectedColor: '#449d44', label: 'success' }, - { styleClass: 'label-onscene', expectedColor: '#449d44', label: 'onscene' }, - { styleClass: 'label-primary', expectedColor: '#228BCB', label: 'primary' }, - { styleClass: 'label-returning', expectedColor: '', label: 'returning' }, - { styleClass: 'label-default', expectedColor: '#262626', label: 'default' }, - { styleClass: 'label-enroute', expectedColor: '#449d44', label: 'enroute' }, - ]; - - colorTestCases.forEach(({ styleClass, expectedColor, label }) => { - it(`should apply correct color for ${label} status`, () => { - const mockStatus = createMockStatus({ - State: 'Test Status', - StateStyle: styleClass, - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - const { getByTestId } = render(); - - // We need to find the Card component by its style - const cardElement = getByTestId('status-card'); - expect(cardElement.props.style).toEqual( - expect.objectContaining({ - backgroundColor: expectedColor, - }) - ); - }); - }); - - it('should handle unknown status styles by keeping original value', () => { - const mockStatus = createMockStatus({ - State: 'Test Status', - StateStyle: 'unknown-style', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - const { getByTestId } = render(); - - const cardElement = getByTestId('status-card'); - expect(cardElement.props.style).toEqual( - expect.objectContaining({ - backgroundColor: 'unknown-style', - }) - ); - }); - - it('should handle empty StateStyle by using empty string', () => { - const mockStatus = createMockStatus({ - State: 'Test Status', - StateStyle: '', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - const { getByTestId } = render(); - - const cardElement = getByTestId('status-card'); - expect(cardElement.props.style).toEqual( - expect.objectContaining({ - backgroundColor: '', - }) - ); - }); - }); - - describe('Status updates', () => { - it('should re-render when activeUnitStatus changes', () => { - const initialStatus = createMockStatus(); - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: initialStatus }) : { activeUnitStatus: initialStatus }); - - const { rerender } = render(); - - expect(screen.getByText('Available')).toBeTruthy(); - - // Update the status - const updatedStatus = createMockStatus({ - State: 'Busy', - StateStyle: 'label-danger', - Timestamp: '2023-01-01T12:30:00Z', - Note: 'Updated note', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: updatedStatus }) : { activeUnitStatus: updatedStatus }); - - rerender(); - - expect(screen.getByText('Busy')).toBeTruthy(); - expect(screen.queryByText('Available')).toBeNull(); - }); - - it('should handle transition from null to valid status', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null }); - - const { rerender } = render(); - - expect(screen.getByText('Unknown')).toBeTruthy(); - - // Update to valid status - const status = createMockStatus({ - State: 'En Route', - StateStyle: 'label-enroute', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: status }) : { activeUnitStatus: status }); - - rerender(); - - expect(screen.getByText('En Route')).toBeTruthy(); - expect(screen.queryByText('Unknown')).toBeNull(); - }); - - it('should handle transition from valid status to null', () => { - const status = createMockStatus({ - State: 'On Scene', - StateStyle: 'label-onscene', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: status }) : { activeUnitStatus: status }); - - const { rerender } = render(); - - expect(screen.getByText('On Scene')).toBeTruthy(); - - // Update to null - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null }); - - rerender(); - - expect(screen.getByText('Unknown')).toBeTruthy(); - expect(screen.queryByText('On Scene')).toBeNull(); - }); - }); - - describe('Edge cases', () => { - it('should handle status with special characters', () => { - const mockStatus = createMockStatus({ - State: 'Status with "quotes" & symbols', - StateStyle: 'label-info', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - render(); - - expect(screen.getByText('Status with "quotes" & symbols')).toBeTruthy(); - }); - - it('should handle very long status text', () => { - const longStatusText = 'This is a very long status text that might overflow the container and cause layout issues'; - const mockStatus = createMockStatus({ - State: longStatusText, - StateStyle: 'label-info', - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - render(); - - expect(screen.getByText(longStatusText)).toBeTruthy(); - }); - - it('should handle null StateStyle gracefully', () => { - const mockStatus = createMockStatus({ - State: 'Test Status', - StateStyle: null as any, - }); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus }); - - const { getByTestId } = render(); - - const cardElement = getByTestId('status-card'); - expect(cardElement.props.style).toEqual( - expect.objectContaining({ - backgroundColor: '', - }) - ); - }); - }); -}); \ No newline at end of file diff --git a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx deleted file mode 100644 index 305425a..0000000 --- a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { render } from '@testing-library/react-native'; -import React from 'react'; - -// Mock Platform first before any other imports -const mockPlatform = { - OS: 'ios' as const, - select: jest.fn().mockImplementation((obj: any) => obj.ios || obj.default), - Version: 17, - constants: {}, - isTesting: true, -}; - -// Mock react-native Platform -jest.mock('react-native/Libraries/Utilities/Platform', () => mockPlatform); - -// Mock react-native-svg to avoid Platform.OS issues -jest.mock('react-native-svg', () => { - const React = require('react'); - const Svg = React.forwardRef((props: any, ref: any) => React.createElement('View', { ...props, ref, testID: 'mock-svg' })); - const Circle = (props: any) => React.createElement('View', { ...props, testID: 'mock-circle' }); - const Path = (props: any) => React.createElement('View', { ...props, testID: 'mock-path' }); - const G = (props: any) => React.createElement('View', { ...props, testID: 'mock-g' }); - const Line = (props: any) => React.createElement('View', { ...props, testID: 'mock-line' }); - const Polyline = (props: any) => React.createElement('View', { ...props, testID: 'mock-polyline' }); - const Polygon = (props: any) => React.createElement('View', { ...props, testID: 'mock-polygon' }); - const Rect = (props: any) => React.createElement('View', { ...props, testID: 'mock-rect' }); - - return { - __esModule: true, - default: Svg, - Svg, - Circle, - Path, - G, - Line, - Polyline, - Polygon, - Rect, - }; -}); - -// Mock lucide-react-native icons -jest.mock('lucide-react-native', () => { - const React = require('react'); - return { - Lock: (props: any) => React.createElement('View', { ...props, testID: 'mock-lock-icon' }), - Mic: (props: any) => React.createElement('View', { ...props, testID: 'mock-mic-icon' }), - Phone: (props: any) => React.createElement('View', { ...props, testID: 'mock-phone-icon' }), - Radio: (props: any) => React.createElement('View', { ...props, testID: 'mock-radio-icon' }), - Unlock: (props: any) => React.createElement('View', { ...props, testID: 'mock-unlock-icon' }), - }; -}); - -// Mock all stores inline -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }), -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() }), -})); - -jest.mock('@/stores/app/livekit-store', () => ({ - useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }), -})); - -jest.mock('@/stores/app/audio-stream-store', () => ({ - useAudioStreamStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }), -})); - -jest.mock('@/components/audio-stream/audio-stream-bottom-sheet', () => ({ - AudioStreamBottomSheet: () => null, -})); - -// Test if we can import the component -describe('SidebarUnitCard - Import Test', () => { - it('should be able to import the component', () => { - const SidebarUnitCard = require('../unit-sidebar').SidebarUnitCard; - expect(SidebarUnitCard).toBeDefined(); - }); - - it('should render the component with default props', () => { - const { SidebarUnitCard } = require('../unit-sidebar'); - const { getByText } = render( - - ); - - expect(getByText('Test Unit')).toBeTruthy(); - expect(getByText('Engine')).toBeTruthy(); - expect(getByText('Station 1')).toBeTruthy(); - }); - - it('should render buttons with proper test IDs', () => { - const { SidebarUnitCard } = require('../unit-sidebar'); - const { getByTestId } = render( - - ); - - expect(getByTestId('map-lock-button')).toBeTruthy(); - expect(getByTestId('audio-stream-button')).toBeTruthy(); - expect(getByTestId('call-button')).toBeTruthy(); - }); -}); diff --git a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx deleted file mode 100644 index 60f7a85..0000000 --- a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react-native'; -import React from 'react'; - -// Mock the store hooks directly without importing the actual stores -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }), -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ - isMapLocked: false, - setMapLocked: jest.fn() - }) : { - isMapLocked: false, - setMapLocked: jest.fn() - }), -})); - -jest.mock('@/stores/app/livekit-store', () => ({ - useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }), -})); - -jest.mock('@/stores/app/audio-stream-store', () => ({ - useAudioStreamStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }), -})); - -// Mock the AudioStreamBottomSheet component -jest.mock('@/components/audio-stream/audio-stream-bottom-sheet', () => ({ - AudioStreamBottomSheet: () => null, -})); - -// Now import the component and store functions -import { SidebarUnitCard } from '../unit-sidebar'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useLiveKitStore } from '@/stores/app/livekit-store'; -import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseLiveKitStore = useLiveKitStore as jest.MockedFunction; -const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction; - -describe('SidebarUnitCard', () => { - const mockSetMapLocked = jest.fn(); - const mockSetIsBottomSheetVisible = jest.fn(); - const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true); - const mockSetAudioStreamBottomSheetVisible = jest.fn(); - - const defaultProps = { - unitName: 'Test Unit', - unitType: 'Ambulance', - unitGroup: 'Test Group', - bgColor: 'bg-blue-500', - }; - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: null, - }) : { - activeUnit: null, - }); - - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - isMapLocked: false, - setMapLocked: mockSetMapLocked, - }) : { - isMapLocked: false, - setMapLocked: mockSetMapLocked, - }); - - mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: mockSetIsBottomSheetVisible, - ensureMicrophonePermission: mockEnsureMicrophonePermission, - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }) : { - setIsBottomSheetVisible: mockSetIsBottomSheetVisible, - ensureMicrophonePermission: mockEnsureMicrophonePermission, - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }); - - mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible, - currentStream: null, - isPlaying: false, - }) : { - setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible, - currentStream: null, - isPlaying: false, - }); - }); - - it('renders unit information correctly', () => { - render(); - - expect(screen.getByText('Test Unit')).toBeTruthy(); - expect(screen.getByText('Ambulance')).toBeTruthy(); - expect(screen.getByText('Test Group')).toBeTruthy(); - }); - - it('renders with default props when no active unit', () => { - render(); - - // Should render the default props since no active unit is set - expect(screen.getByText('Test Unit')).toBeTruthy(); - expect(screen.getByText('Ambulance')).toBeTruthy(); - expect(screen.getByText('Test Group')).toBeTruthy(); - }); - - describe('Map Lock Button', () => { - it('renders map lock button with unlock icon when map is not locked', () => { - render(); - - const mapLockButton = screen.getByTestId('map-lock-button'); - expect(mapLockButton).toBeTruthy(); - - // Check that the button has the correct styling for unlocked state - expect(mapLockButton).toHaveStyle({ - backgroundColor: 'transparent', - borderColor: '#007AFF', - }); - }); - - it('toggles map lock state when pressed', () => { - render(); - - const mapLockButton = screen.getByTestId('map-lock-button'); - fireEvent.press(mapLockButton); - - expect(mockSetMapLocked).toHaveBeenCalledWith(true); - }); - }); - - describe('Call Button', () => { - it('renders call button with correct styling when not connected', () => { - render(); - - const callButton = screen.getByTestId('call-button'); - expect(callButton).toBeTruthy(); - - // Check that the button has the correct styling for disconnected state - expect(callButton).toHaveStyle({ - backgroundColor: 'transparent', - borderColor: '#007AFF', - }); - }); - - it('opens LiveKit when call button is pressed', async () => { - render(); - - const callButton = screen.getByTestId('call-button'); - await fireEvent.press(callButton); - - expect(mockEnsureMicrophonePermission).toHaveBeenCalled(); - expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true); - }); - }); - - describe('Audio Stream Button', () => { - it('renders audio stream button with correct styling when not playing', () => { - render(); - - const audioStreamButton = screen.getByTestId('audio-stream-button'); - expect(audioStreamButton).toBeTruthy(); - - // Check that the button has the correct styling for inactive state - expect(audioStreamButton).toHaveStyle({ - backgroundColor: 'transparent', - borderColor: '#007AFF', - }); - }); - - it('opens audio stream when button is pressed', () => { - render(); - - const audioStreamButton = screen.getByTestId('audio-stream-button'); - fireEvent.press(audioStreamButton); - - expect(mockSetAudioStreamBottomSheetVisible).toHaveBeenCalledWith(true); - }); - }); - - describe('Button Container Layout', () => { - it('renders all buttons in the correct order', () => { - render(); - - const mapLockButton = screen.getByTestId('map-lock-button'); - const audioStreamButton = screen.getByTestId('audio-stream-button'); - const callButton = screen.getByTestId('call-button'); - - expect(mapLockButton).toBeTruthy(); - expect(audioStreamButton).toBeTruthy(); - expect(callButton).toBeTruthy(); - }); - }); -}); diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx deleted file mode 100644 index 9eda8b9..0000000 --- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react-native'; -import React from 'react'; - -// Mock @livekit/react-native-webrtc before any imports that use it -jest.mock('@livekit/react-native-webrtc', () => ({ - RTCAudioSession: { - configure: jest.fn().mockResolvedValue(undefined), - setCategory: jest.fn().mockResolvedValue(undefined), - setMode: jest.fn().mockResolvedValue(undefined), - getActiveAudioSession: jest.fn().mockReturnValue(null), - setActive: jest.fn().mockResolvedValue(undefined), - }, -})); - -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useLiveKitStore } from '@/stores/app/livekit-store'; -import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; - -// Mock the stores -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/app/location-store'); -jest.mock('@/stores/app/livekit-store'); -jest.mock('@/stores/app/audio-stream-store'); - -jest.mock('@/components/audio-stream/audio-stream-bottom-sheet', () => ({ - AudioStreamBottomSheet: () => null, -})); - -// Mock lucide-react-native icons -jest.mock('lucide-react-native', () => ({ - Lock: () => null, - Unlock: () => null, - Phone: () => null, - Radio: () => null, - Mic: () => null, -})); - -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseLiveKitStore = useLiveKitStore as jest.MockedFunction; -const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction; - -// Import component after mocks -import { SidebarUnitCard } from '../unit-sidebar'; - -describe('SidebarUnitCard', () => { - const defaultProps = { - unitName: 'Test Unit', - unitType: 'Ambulance', - unitGroup: 'Test Group', - bgColor: 'bg-blue-500', - }; - - beforeEach(() => { - jest.clearAllMocks(); - - // Reset to default mocks - mockUseCoreStore.mockImplementation((selector) => selector({ activeUnit: null } as any)); - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() }); - mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }); - mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - currentStream: null, - isPlaying: false, - }); - }); - - it('renders unit information correctly with default props', () => { - render(); - - expect(screen.getByText('Test Unit')).toBeTruthy(); - expect(screen.getByText('Ambulance')).toBeTruthy(); - expect(screen.getByText('Test Group')).toBeTruthy(); - }); - - it('renders active unit information when available', () => { - const mockActiveUnit = { - Name: 'Active Unit Name', - Type: 'Fire Truck', - GroupName: 'Fire Department', - } as any; - - mockUseCoreStore.mockImplementation((selector) => selector({ activeUnit: mockActiveUnit } as any)); - - render(); - - expect(screen.getByText('Active Unit Name')).toBeTruthy(); - expect(screen.getByText('Fire Truck')).toBeTruthy(); - expect(screen.getByText('Fire Department')).toBeTruthy(); - }); - - it('renders action buttons', () => { - render(); - - expect(screen.getByTestId('map-lock-button')).toBeTruthy(); - expect(screen.getByTestId('audio-stream-button')).toBeTruthy(); - expect(screen.getByTestId('call-button')).toBeTruthy(); - }); - - it('handles map lock button press', () => { - const mockSetMapLocked = jest.fn(); - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - isMapLocked: false, - setMapLocked: mockSetMapLocked - }) : { - isMapLocked: false, - setMapLocked: mockSetMapLocked - }); - - render(); - - const mapLockButton = screen.getByTestId('map-lock-button'); - fireEvent.press(mapLockButton); - - expect(mockSetMapLocked).toHaveBeenCalledWith(true); - }); - - it('handles audio stream button press', () => { - const mockSetAudioStreamBottomSheetVisible = jest.fn(); - mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible, - currentStream: null, - isPlaying: false, - }) : { - setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible, - currentStream: null, - isPlaying: false, - }); - - render(); - - const audioStreamButton = screen.getByTestId('audio-stream-button'); - fireEvent.press(audioStreamButton); - - expect(mockSetAudioStreamBottomSheetVisible).toHaveBeenCalledWith(true); - }); - - it('handles call button press', async () => { - const mockSetIsBottomSheetVisible = jest.fn(); - const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true); - mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: mockSetIsBottomSheetVisible, - ensureMicrophonePermission: mockEnsureMicrophonePermission, - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }) : { - setIsBottomSheetVisible: mockSetIsBottomSheetVisible, - ensureMicrophonePermission: mockEnsureMicrophonePermission, - currentRoomInfo: null, - isConnected: false, - isTalking: false, - }); - - render(); - - const callButton = screen.getByTestId('call-button'); - await fireEvent.press(callButton); - - expect(mockEnsureMicrophonePermission).toHaveBeenCalled(); - expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true); - }); - - it('shows room status when connected', () => { - const mockRoomInfo = { Name: 'Emergency Call Room' }; - mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: mockRoomInfo as any, - isConnected: true, - isTalking: false, - }) : { - setIsBottomSheetVisible: jest.fn(), - ensureMicrophonePermission: jest.fn().mockResolvedValue(true), - currentRoomInfo: mockRoomInfo as any, - isConnected: true, - isTalking: false, - }); - - render(); - - expect(screen.getByText('Emergency Call Room')).toBeTruthy(); - }); - - it('toggles map lock correctly when currently locked', () => { - const mockSetMapLocked = jest.fn(); - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - isMapLocked: true, - setMapLocked: mockSetMapLocked - }) : { - isMapLocked: true, - setMapLocked: mockSetMapLocked - }); - - render(); - - const mapLockButton = screen.getByTestId('map-lock-button'); - fireEvent.press(mapLockButton); - - expect(mockSetMapLocked).toHaveBeenCalledWith(false); - }); -}); \ No newline at end of file diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx deleted file mode 100644 index 15fc286..0000000 --- a/src/components/sidebar/call-sidebar.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { router } from 'expo-router'; -import { Check, CircleX, Eye, MapPin } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Alert, Platform, Pressable, ScrollView } from 'react-native'; - -import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { openMapsWithAddress, openMapsWithDirections } from '@/lib/navigation'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useCallsStore } from '@/stores/calls/store'; - -import { CallCard } from '../calls/call-card'; -import { Button, ButtonIcon } from '../ui/button'; -import { Card } from '../ui/card'; -import { HStack } from '../ui/hstack'; - -export const SidebarCallCard = () => { - const { colorScheme } = useColorScheme(); - const activeCall = useCoreStore((state) => state.activeCall); - const activePriority = useCoreStore((state) => state.activePriority); - const setActiveCall = useCoreStore((state) => state.setActiveCall); - - const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false); - const { t } = useTranslation(); - - // Fetch calls data when bottom sheet opens - const { data: openCallsData, isLoading } = useQuery({ - queryKey: ['calls', 'open'], - queryFn: async () => { - // Only fetch when bottom sheet is open - if (!isBottomSheetOpen) return []; - await useCallsStore.getState().fetchCalls(); - return useCallsStore.getState().calls; - }, - enabled: isBottomSheetOpen, // Only run query when bottom sheet is open - }); - - const handleDeselect = () => { - if (Platform.OS === 'web') { - const confirmed = window.confirm(`${t('calls.confirm_deselect_title')}\n${t('calls.confirm_deselect_message')}`); - if (confirmed) { - setActiveCall(null); - } - return; - } - - Alert.alert( - t('calls.confirm_deselect_title'), - t('calls.confirm_deselect_message'), - [ - { - text: t('common.cancel'), - style: 'cancel', - }, - { - text: t('common.confirm'), - onPress: () => setActiveCall(null), - style: 'destructive', - }, - ], - { cancelable: true } - ); - }; - - // Check if location data exists (either coordinates or address) - const hasLocationData = (call: typeof activeCall) => { - if (!call) return false; - const hasCoordinates = call.Latitude && call.Longitude; - const hasAddress = call.Address && call.Address.trim() !== ''; - return hasCoordinates || hasAddress; - }; - - const showLocationAlert = () => { - if (Platform.OS === 'web') { - window.alert(`${t('calls.no_location_title')}\n${t('calls.no_location_message')}`); - } else { - Alert.alert(t('calls.no_location_title'), t('calls.no_location_message'), [{ text: t('common.ok') }]); - } - }; - - const handleDirections = async () => { - if (!activeCall) return; - - const latitude = activeCall.Latitude; - const longitude = activeCall.Longitude; - const address = activeCall.Address; - - // Check if we have coordinates - if (latitude && longitude) { - try { - await openMapsWithDirections(latitude, longitude, address); - } catch { - showLocationAlert(); - } - } else if (address && address.trim() !== '') { - // Fall back to address if no coordinates - try { - await openMapsWithAddress(address); - } catch { - showLocationAlert(); - } - } else { - // No location data available - showLocationAlert(); - } - }; - - return ( - <> - setIsBottomSheetOpen(true)} className="w-full" testID="call-selection-trigger"> - {activeCall && activePriority ? ( - - ) : ( - - {t('calls.no_call_selected')} - {t('calls.no_call_selected_info')} - - )} - - - {activeCall && ( - - - - {hasLocationData(activeCall) && ( - - )} - - - - )} - - setIsBottomSheetOpen(false)} isLoading={isLoading} loadingText={t('common.loading')} snapPoints={[60]} testID="call-selection-bottom-sheet"> - - {t('calls.select_active_call')} - - - {openCallsData?.map((call) => ( - { - const handleCallSelect = async () => { - try { - await setActiveCall(call.CallId); - setIsBottomSheetOpen(false); - } catch (error) { - console.error('Failed to set active call:', error); - } - }; - handleCallSelect().catch((error) => { - console.error('Failed to handle call selection:', error); - }); - }} - className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${ - activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : '' - }`} - testID={`call-item-${call.CallId}`} - > - - - {call.Name} - - {call.Type} - - - {activeCall?.CallId === call.CallId && } - - - ))} - {!isLoading && openCallsData?.length === 0 && ( - - {t('calls.no_open_calls')} - - )} - - - - - - ); -}; diff --git a/src/components/sidebar/check-in-sidebar-widget.tsx b/src/components/sidebar/check-in-sidebar-widget.tsx deleted file mode 100644 index 63645c0..0000000 --- a/src/components/sidebar/check-in-sidebar-widget.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useRouter } from 'expo-router'; -import { Timer } from 'lucide-react-native'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable } from 'react-native'; - -import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; -import { HStack } from '@/components/ui/hstack'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { useQuickCheckIn } from '@/hooks/use-quick-check-in'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; - -const STATUS_COLORS: Record = { - Ok: '#22C55E', - Warning: '#F59E0B', - Overdue: '#EF4444', -}; - -export const CheckInSidebarWidget: React.FC = () => { - const { t } = useTranslation(); - const router = useRouter(); - const activeCall = useCoreStore((state) => state.activeCall); - const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); - - const callId = activeCall ? parseInt(activeCall.CallId, 10) : 0; - const { quickCheckIn, isCheckingIn } = useQuickCheckIn(callId); - - // Only render when there's an active call with timers - if (!activeCall?.CheckInTimersEnabled || timerStatuses.length === 0) { - return null; - } - - // Get most urgent timer - const urgentTimer = timerStatuses[0]; - const statusColor = STATUS_COLORS[urgentTimer.Status] ?? '#808080'; - - const handleNavigateToCheckIn = () => { - router.push(`/call/${activeCall.CallId}`); - }; - - return ( - - - - - - - - {urgentTimer.TargetName} - - - - - {Math.floor(urgentTimer.ElapsedMinutes)}/{urgentTimer.DurationMinutes} {t('check_in.duration')} - - - - - - - - - ); -}; diff --git a/src/components/sidebar/roles-sidebar.tsx b/src/components/sidebar/roles-sidebar.tsx deleted file mode 100644 index 9222fdd..0000000 --- a/src/components/sidebar/roles-sidebar.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable } from 'react-native'; - -import { Text } from '@/components/ui/text'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; - -import { RolesBottomSheet } from '../roles/roles-bottom-sheet'; -import { Card } from '../ui/card'; - -export const SidebarRolesCard = () => { - const { t } = useTranslation(); - const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false); - const activeUnit = useCoreStore((state) => state.activeUnit); - const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); - const roles = useRolesStore((state) => state.roles); - - const handlePress = React.useCallback(() => { - setIsBottomSheetOpen(true); - }, []); - - const handleClose = React.useCallback(() => { - setIsBottomSheetOpen(false); - }, []); - - const activeCount = React.useMemo(() => { - if (!activeUnit || unitRoleAssignments.length === 0) return 0; - return unitRoleAssignments.filter((assignment) => assignment.FullName && assignment.FullName !== '' && assignment.UnitId === activeUnit.UnitId).length; - }, [unitRoleAssignments, activeUnit]); - - const totalCount = React.useMemo(() => { - if (!activeUnit) return 0; - return roles.filter((role) => role.UnitId === activeUnit.UnitId).length; - }, [roles, activeUnit]); - - const displayStatus = t('roles.status', { - active: activeCount, - total: totalCount, - }); - - return ( - <> - - - - {displayStatus} - - - - - - - ); -}; diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index a0464ec..b378d07 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -1,103 +1,56 @@ import { useRouter } from 'expo-router'; -import { Settings } from 'lucide-react-native'; +import type { LucideIcon } from 'lucide-react-native'; +import { ChevronRight, ClipboardList, CloudAlert, Layers, Map, Megaphone, Settings } from 'lucide-react-native'; 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 { Icon } from '@/components/ui/icon'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { invertColor } from '@/lib/utils'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useStatusBottomSheetStore } from '@/stores/status/store'; - -import ZeroState from '../common/zero-state'; -import { StatusBottomSheet } from '../status/status-bottom-sheet'; -import { SidebarCallCard } from './call-sidebar'; -import { CheckInSidebarWidget } from './check-in-sidebar-widget'; -import { SidebarRolesCard } from './roles-sidebar'; -import { SidebarStatusCard } from './status-sidebar'; -import { SidebarUnitCard } from './unit-sidebar'; interface SidebarProps { onClose?: () => void; } +interface MenuItem { + key: string; + labelKey: string; + icon: LucideIcon; + href: string; +} + +const MENU_ITEMS: MenuItem[] = [ + { key: 'map', labelKey: 'tabs.map', icon: Map, href: '/' }, + { key: 'calls', labelKey: 'tabs.calls', icon: Megaphone, href: '/calls' }, + { key: 'command', labelKey: 'tabs.command_board', icon: ClipboardList, href: '/command' }, + { key: 'maps', labelKey: 'maps.title', icon: Layers, href: '/maps' }, + { key: 'weather-alerts', labelKey: 'tabs.weather_alerts', icon: CloudAlert, href: '/weather-alerts' }, + { key: 'settings', labelKey: 'tabs.settings', icon: Settings, href: '/settings' }, +]; + const Sidebar = ({ onClose }: SidebarProps) => { - const activeStatuses = useCoreStore((state) => state.activeStatuses); - const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen); const { t } = useTranslation(); const router = useRouter(); - const isActiveStatusesEmpty = !activeStatuses?.Statuses || activeStatuses.Statuses.length === 0; - - const handleNavigateToSettings = () => { + const handleNavigate = (href: string) => { onClose?.(); - router.push('/settings'); + router.push(href as never); }; return ( - - {/* First row - Two cards side by side */} - - - - - - - - - {/* Second row - Single card */} - - - {/* Check-in timer widget */} - - - {/* Third row - Status buttons or empty state */} - {isActiveStatusesEmpty ? ( - - - - ) : ( - - {activeStatuses?.Statuses.map((status) => ( - - ))} - - )} - + + {t('sidebar.menu')} + + {MENU_ITEMS.map((item) => ( + handleNavigate(item.href)}> + + {t(item.labelKey)} + + + ))} ); diff --git a/src/components/sidebar/sidebar.web.tsx b/src/components/sidebar/sidebar.web.tsx index d5de00e..c2f13c1 100644 --- a/src/components/sidebar/sidebar.web.tsx +++ b/src/components/sidebar/sidebar.web.tsx @@ -6,11 +6,15 @@ import { Box } from '@/components/ui/box'; // On web, './sidebar' resolves to sidebar.web.tsx (this file), not sidebar.tsx. import Sidebar from './sidebar-content'; -const WebSidebar = () => { +interface WebSidebarProps { + onClose?: () => void; +} + +const WebSidebar = ({ onClose }: WebSidebarProps) => { return ( {/* common sidebar contents for web and mobile */} - + ); }; diff --git a/src/components/sidebar/status-sidebar.tsx b/src/components/sidebar/status-sidebar.tsx deleted file mode 100644 index 755834f..0000000 --- a/src/components/sidebar/status-sidebar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; - -import { Text } from '@/components/ui/text'; -import { useCoreStore } from '@/stores/app/core-store'; - -import { Card } from '../ui/card'; - -export const SidebarStatusCard = () => { - const activeUnitStatus = useCoreStore((state) => state.activeUnitStatus); - - // Derive the display values from activeUnit when available, otherwise use defaults - const displayStatus = activeUnitStatus?.State || 'Unknown'; - let displayColor = activeUnitStatus?.StateStyle ?? ''; - - // Fix up the color values to match the design system - if (displayColor === 'label-danger') { - displayColor = '#ED5565'; - } else if (displayColor === 'label-info') { - displayColor = '#23c6c8'; - } else if (displayColor === 'label-warning') { - displayColor = '#f8ac59'; - } else if (displayColor === 'label-success') { - displayColor = '#449d44'; - } else if (displayColor === 'label-onscene') { - displayColor = '#449d44'; - } else if (displayColor === 'label-primary') { - displayColor = '#228BCB'; - } else if (displayColor === 'label-returning') { - displayColor = ''; - } else if (displayColor === 'label-default') { - displayColor = '#262626'; - } else if (displayColor === 'label-enroute') { - displayColor = '#449d44'; - } - - return ( - - {displayStatus} - - ); -}; diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx deleted file mode 100644 index 5457bd7..0000000 --- a/src/components/sidebar/unit-sidebar.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { Lock, Mic, Phone, Radio, Unlock } from 'lucide-react-native'; -import * as React from 'react'; -import { StyleSheet, TouchableOpacity, View } from 'react-native'; - -import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; -import { Text } from '@/components/ui/text'; -import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLiveKitStore } from '@/stores/app/livekit-store'; -import { useLocationStore } from '@/stores/app/location-store'; - -import { Card } from '../ui/card'; - -type ItemProps = { - unitName: string; - unitType: string; - unitGroup: string; - bgColor: string; -}; - -export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => { - const activeUnit = useCoreStore((state) => state.activeUnit); - const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible); - const ensureMicrophonePermission = useLiveKitStore((state) => state.ensureMicrophonePermission); - const currentRoomInfo = useLiveKitStore((state) => state.currentRoomInfo); - const isConnected = useLiveKitStore((state) => state.isConnected); - const isTalking = useLiveKitStore((state) => state.isTalking); - const isMapLocked = useLocationStore((state) => state.isMapLocked); - const setMapLocked = useLocationStore((state) => state.setMapLocked); - const setAudioStreamBottomSheetVisible = useAudioStreamStore((state) => state.setIsBottomSheetVisible); - const currentStream = useAudioStreamStore((state) => state.currentStream); - const isPlaying = useAudioStreamStore((state) => state.isPlaying); - - // Derive the display values from activeUnit when available, otherwise use defaults - const displayName = activeUnit?.Name ?? defaultUnitName; - const displayType = activeUnit?.Type ?? defaultUnitType; - const displayGroup = activeUnit?.GroupName ?? defaultUnitGroup; - - const handleOpenLiveKit = async () => { - // Request microphone permission before the Actionsheet (Modal) opens. - // On Android, system permission dialogs are hidden behind React Native - // Modals, so we must request while no Modal is on screen. - await ensureMicrophonePermission(); - setIsBottomSheetVisible(true); - }; - - const handleToggleMapLock = () => { - setMapLocked(!isMapLocked); - }; - - const handleOpenAudioStream = () => { - setAudioStreamBottomSheetVisible(true); - }; - - return ( - - {displayType} - {displayName} - {displayGroup} - - {/* Call button and status */} - - {isConnected && currentRoomInfo ? ( - - - {currentRoomInfo.Name} - - {isTalking ? : null} - - ) : null} - - - - {isMapLocked ? : } - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - callContainer: { - marginTop: 8, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - buttonContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - callButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: '#007AFF', - borderRadius: 20, - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - }, - activeCall: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - mapLockButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: '#007AFF', - borderRadius: 20, - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - }, - mapLockButtonActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - audioStreamButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: '#007AFF', - borderRadius: 20, - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - }, - audioStreamButtonActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - roomStatus: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - marginRight: 8, - }, - roomName: { - fontSize: 12, - color: '#6b7280', - marginRight: 4, - flex: 1, - }, -}); diff --git a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx b/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx deleted file mode 100644 index 0320dec..0000000 --- a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** - * GPS Coordinate Duplication Fix Validation Tests - * Tests to ensure the coordinate duplication issue is fixed - */ - -import { act, renderHook } from '@testing-library/react-native'; - -import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useStatusesStore } from '@/stores/status/store'; - -// Mock the dependencies -jest.mock('@/api/units/unitStatuses', () => ({ - saveUnitStatus: jest.fn(), -})); - -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - queueUnitStatusEvent: jest.fn(), - }, -})); - -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn(), -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn(), -})); - -jest.mock('@/lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -// Get the mocked functions -const { saveUnitStatus: mockSaveUnitStatus } = jest.requireMock('@/api/units/unitStatuses'); -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseCoreStore = jest.requireMock('@/stores/app/core-store').useCoreStore; - -describe('GPS Coordinate Duplication Fix', () => { - let mockLocationStore: any; - let mockCoreStore: any; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock location store - mockLocationStore = { - latitude: null, - longitude: null, - accuracy: null, - altitude: null, - speed: null, - heading: null, - timestamp: null, - }; - - mockUseLocationStore.mockImplementation(() => mockLocationStore); - (mockUseLocationStore as any).getState = jest.fn().mockReturnValue(mockLocationStore); - - // Mock core store - mockCoreStore = { - activeUnit: { - UnitId: 'unit-test', - }, - setActiveUnitWithFetch: jest.fn().mockResolvedValue({}), - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore); - (mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore); - }); - - it('should not duplicate coordinates when input already has coordinates', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location store with coordinates - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Pre-populate with existing coordinates - input.Latitude = '41.8781'; - input.Longitude = '-87.6298'; - input.Accuracy = '5'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - // Should use input coordinates, not location store coordinates - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '41.8781', - Longitude: '-87.6298', - Accuracy: '5', - }) - ); - }); - - it('should populate coordinates only when both latitude and longitude are missing', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location store with coordinates - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Only set latitude, leave longitude empty - input.Latitude = '41.8781'; - input.Longitude = ''; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - // Should keep existing latitude and not populate from location store - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '41.8781', - Longitude: '', - }) - ); - }); - - it('should include AltitudeAccuracy field in coordinate population', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location store with coordinates - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - mockLocationStore.altitude = 50; - mockLocationStore.speed = 5; - mockLocationStore.heading = 180; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Leave coordinates empty to trigger population - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '10', - Altitude: '50', - AltitudeAccuracy: '', // Should be empty string since location store doesn't provide it - Speed: '5', - Heading: '180', - }) - ); - }); - - it('should populate empty strings for all GPS fields when no location data available', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Location store has no coordinates - mockLocationStore.latitude = null; - mockLocationStore.longitude = null; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '', - Longitude: '', - Accuracy: '', - Altitude: '', - AltitudeAccuracy: '', - Speed: '', - Heading: '', - }) - ); - }); - - it('should handle setActiveUnitWithFetch returning undefined without error', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Mock setActiveUnitWithFetch to return undefined (simulating the bug) - mockCoreStore.setActiveUnitWithFetch = jest.fn().mockReturnValue(undefined); - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - // This should not throw an error - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalled(); - }); -}); \ No newline at end of file diff --git a/src/components/status/__tests__/location-update-validation.test.tsx b/src/components/status/__tests__/location-update-validation.test.tsx deleted file mode 100644 index 1973ccd..0000000 --- a/src/components/status/__tests__/location-update-validation.test.tsx +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Location Update Validation Tests - * Ensures that GPS coordinate duplication fix does not interfere with location updates - */ - -import { act, renderHook } from '@testing-library/react-native'; - -import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useStatusesStore } from '@/stores/status/store'; - -// Mock the dependencies -jest.mock('@/api/units/unitStatuses', () => ({ - saveUnitStatus: jest.fn(), -})); - -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - queueUnitStatusEvent: jest.fn(), - }, -})); - -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn(), -})); - -// Get the mocked functions -const { saveUnitStatus: mockSaveUnitStatus } = jest.requireMock('@/api/units/unitStatuses'); -const mockUseCoreStore = jest.requireMock('@/stores/app/core-store').useCoreStore; - -describe('Location Update Validation', () => { - let mockCoreStore: any; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock core store - mockCoreStore = { - activeUnit: { - UnitId: 'unit-test', - }, - setActiveUnitWithFetch: jest.fn().mockResolvedValue({}), - }; - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore); - (mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore); - }); - - it('should allow location store to be updated with new coordinates', () => { - const { result } = renderHook(() => useLocationStore()); - - // Initially no location - expect(result.current.latitude).toBeNull(); - expect(result.current.longitude).toBeNull(); - - // Simulate location service updating the store with new coordinates - const newLocation1 = { - coords: { - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 5, - heading: 180, - altitudeAccuracy: 2, - }, - timestamp: 1640995200000, - }; - - act(() => { - result.current.setLocation(newLocation1); - }); - - // Location should be updated - expect(result.current.latitude).toBe(40.7128); - expect(result.current.longitude).toBe(-74.0060); - expect(result.current.accuracy).toBe(10); - expect(result.current.altitude).toBe(50); - expect(result.current.speed).toBe(5); - expect(result.current.heading).toBe(180); - - // Simulate another location update (movement) - const newLocation2 = { - coords: { - latitude: 41.8781, - longitude: -87.6298, - accuracy: 8, - altitude: 60, - speed: 15, - heading: 90, - altitudeAccuracy: 3, - }, - timestamp: 1640995260000, // 1 minute later - }; - - act(() => { - result.current.setLocation(newLocation2); - }); - - // Location should be updated again - expect(result.current.latitude).toBe(41.8781); - expect(result.current.longitude).toBe(-87.6298); - expect(result.current.accuracy).toBe(8); - expect(result.current.altitude).toBe(60); - expect(result.current.speed).toBe(15); - expect(result.current.heading).toBe(90); - expect(result.current.timestamp).toBe(1640995260000); - }); - - it('should read updated location data when saving status without pre-populated coordinates', async () => { - const { result: locationResult } = renderHook(() => useLocationStore()); - const { result: statusResult } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockResolvedValue({}); - - // Set initial location - const initialLocation = { - coords: { - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 5, - heading: 180, - altitudeAccuracy: 2, - }, - timestamp: 1640995200000, - }; - - act(() => { - locationResult.current.setLocation(initialLocation); - }); - - // Save status without coordinates - should use location store - const input1 = new SaveUnitStatusInput(); - input1.Id = 'unit1'; - input1.Type = '1'; - - await act(async () => { - await statusResult.current.saveUnitStatus(input1); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '10', - Altitude: '50', - Speed: '5', - Heading: '180', - }) - ); - - // Now update location (simulating user movement) - const updatedLocation = { - coords: { - latitude: 41.8781, - longitude: -87.6298, - accuracy: 8, - altitude: 60, - speed: 15, - heading: 90, - altitudeAccuracy: 3, - }, - timestamp: 1640995260000, - }; - - act(() => { - locationResult.current.setLocation(updatedLocation); - }); - - // Save another status - should use the NEW location data - const input2 = new SaveUnitStatusInput(); - input2.Id = 'unit1'; - input2.Type = '2'; - - await act(async () => { - await statusResult.current.saveUnitStatus(input2); - }); - - expect(mockSaveUnitStatus).toHaveBeenLastCalledWith( - expect.objectContaining({ - Latitude: '41.8781', - Longitude: '-87.6298', - Accuracy: '8', - Altitude: '60', - Speed: '15', - Heading: '90', - }) - ); - }); - - it('should continue to respect pre-populated coordinates even after location updates', async () => { - const { result: locationResult } = renderHook(() => useLocationStore()); - const { result: statusResult } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockResolvedValue({}); - - // Set location store data - const locationData = { - coords: { - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 5, - heading: 180, - altitudeAccuracy: 2, - }, - timestamp: 1640995200000, - }; - - act(() => { - locationResult.current.setLocation(locationData); - }); - - // Save status WITH pre-populated coordinates - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Latitude = '35.6762'; // Different from location store - input.Longitude = '139.6503'; // Different from location store - input.Accuracy = '5'; - - await act(async () => { - await statusResult.current.saveUnitStatus(input); - }); - - // Should use the pre-populated coordinates, not location store - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '35.6762', - Longitude: '139.6503', - Accuracy: '5', - }) - ); - }); - - it('should demonstrate the fix prevents coordinate duplication but allows location updates', async () => { - const { result: locationResult } = renderHook(() => useLocationStore()); - const { result: statusResult } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockResolvedValue({}); - - // Set location store data - const locationData = { - coords: { - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 5, - heading: 180, - altitudeAccuracy: 2, - }, - timestamp: 1640995200000, - }; - - act(() => { - locationResult.current.setLocation(locationData); - }); - - // Test case that would have caused duplication before the fix: - // Input has latitude but missing longitude - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Latitude = '35.6762'; // Has latitude - input.Longitude = ''; // Missing longitude - - await act(async () => { - await statusResult.current.saveUnitStatus(input); - }); - - // With our fix: Should NOT populate from location store - // because only one coordinate is missing (old behavior would have caused duplication) - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '35.6762', // Keeps original - Longitude: '', // Stays empty - }) - ); - - // But location updates should still work fine - const newLocationData = { - coords: { - latitude: 41.8781, - longitude: -87.6298, - accuracy: 8, - altitude: 60, - speed: 15, - heading: 90, - altitudeAccuracy: 3, - }, - timestamp: 1640995260000, - }; - - act(() => { - locationResult.current.setLocation(newLocationData); - }); - - // Verify location was updated - expect(locationResult.current.latitude).toBe(41.8781); - expect(locationResult.current.longitude).toBe(-87.6298); - }); -}); \ No newline at end of file diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx deleted file mode 100644 index 93c2257..0000000 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ /dev/null @@ -1,3704 +0,0 @@ -// Mock dependencies early -jest.mock('react-i18next', () => ({ - useTranslation: jest.fn(), -})); - -// Mock all UI components based on actual imports -jest.mock('@/components/ui/actionsheet', () => { - const mockReact = require('react'); - const { View } = require('react-native'); - - return { - Actionsheet: ({ children, isOpen }: any) => - isOpen ? mockReact.createElement(View, { testID: 'actionsheet' }, children) : null, - ActionsheetBackdrop: ({ children }: any) => - mockReact.createElement(View, { testID: 'actionsheet-backdrop' }, children), - ActionsheetContent: ({ children }: any) => - mockReact.createElement(View, { testID: 'actionsheet-content' }, children), - ActionsheetDragIndicator: () => - mockReact.createElement(View, { testID: 'actionsheet-drag-indicator' }), - ActionsheetDragIndicatorWrapper: ({ children }: any) => - mockReact.createElement(View, { testID: 'actionsheet-drag-indicator-wrapper' }, children), - }; -}); - -jest.mock('../../ui/button', () => { - const mockReact = require('react'); - const { TouchableOpacity, Text } = require('react-native'); - - return { - Button: ({ children, onPress, isDisabled, className, ...props }: any) => - mockReact.createElement(TouchableOpacity, { - onPress: isDisabled ? undefined : onPress, - testID: 'button', - accessibilityState: { disabled: isDisabled }, - ...props - }, children), - ButtonText: ({ children, className, ...props }: any) => - mockReact.createElement(Text, props, children), - }; -}); - -jest.mock('../../ui/heading', () => { - const mockReact = require('react'); - const { Text } = require('react-native'); - - return { - Heading: ({ children, ...props }: any) => mockReact.createElement(Text, props, children), - }; -}); - -jest.mock('../../ui/hstack', () => { - const mockReact = require('react'); - const { View } = require('react-native'); - - return { - HStack: ({ children, ...props }: any) => mockReact.createElement(View, props, children), - }; -}); - -jest.mock('../../ui/vstack', () => { - const mockReact = require('react'); - const { View } = require('react-native'); - - return { - VStack: ({ children, ...props }: any) => mockReact.createElement(View, props, children), - }; -}); - -jest.mock('../../ui/text', () => { - const mockReact = require('react'); - const { Text } = require('react-native'); - - return { - Text: ({ children, ...props }: any) => mockReact.createElement(Text, props, children), - }; -}); - -jest.mock('../../ui/spinner', () => { - const mockReact = require('react'); - const { View } = require('react-native'); - - return { - Spinner: (props: any) => mockReact.createElement(View, { testID: 'spinner', ...props }), - }; -}); - -jest.mock('../../ui/textarea', () => { - const mockReact = require('react'); - const { TextInput, View } = require('react-native'); - - return { - Textarea: ({ children, ...props }: any) => mockReact.createElement(View, props, children), - TextareaInput: ({ value, onChangeText, placeholder, ...props }: any) => - mockReact.createElement(TextInput, { value, onChangeText, placeholder, testID: 'textarea-input', ...props }), - }; -}); - -jest.mock('@expo/html-elements', () => { - const mockReact = require('react'); - const mockComponent = (props: any) => mockReact.createElement('Text', props); - - return { - H1: mockComponent, - H2: mockComponent, - H3: mockComponent, - H4: mockComponent, - H5: mockComponent, - H6: mockComponent, - }; -}); - -jest.mock('nativewind', () => ({ - useColorScheme: jest.fn(() => ({ colorScheme: 'light' })), - cssInterop: jest.fn((component: any) => component), -})); - -jest.mock('@/lib/utils', () => ({ - IS_ANDROID: false, - IS_IOS: true, - invertColor: jest.fn(() => '#000000'), - createSelectors: jest.fn(), - openLinkInBrowser: jest.fn(), - DEFAULT_CENTER_COORDINATE: [-77.036086, 38.910233], - SF_OFFICE_COORDINATE: [-122.400021, 37.789085], - onSortOptions: jest.fn(), -})); - -jest.mock('@/lib/storage/index', () => ({ - storage: { - set: jest.fn(), - getString: jest.fn(), - delete: jest.fn(), - contains: jest.fn(), - getAllKeys: jest.fn(), - clearAll: jest.fn(), - }, -})); - -jest.mock('react-native-mmkv', () => ({ - MMKV: jest.fn().mockImplementation(() => ({ - set: jest.fn(), - getString: jest.fn(), - delete: jest.fn(), - contains: jest.fn(), - getAllKeys: jest.fn(), - clearAll: jest.fn(), - })), -})); - -jest.mock('react-native-svg', () => { - const mockReact = require('react'); - const mockComponent = (props: any) => mockReact.createElement('View', props); - - return { - Svg: mockComponent, - Circle: mockComponent, - Path: mockComponent, - G: mockComponent, - Defs: mockComponent, - LinearGradient: mockComponent, - Stop: mockComponent, - Rect: mockComponent, - Line: mockComponent, - Polygon: mockComponent, - Polyline: mockComponent, - Text: (props: any) => mockReact.createElement('Text', props), - }; -}); - -jest.mock('lucide-react-native', () => { - const mockReact = require('react'); - const mockIcon = (props: any) => mockReact.createElement('View', { ...props, testID: 'mock-icon' }); - - return new Proxy({}, { - get: () => mockIcon, - }); -}); - -jest.mock('@/stores/status/store', () => ({ - useStatusBottomSheetStore: jest.fn(), - useStatusesStore: jest.fn(), -})); - -// Mock additional stores and services -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - initialize: jest.fn(), - addEvent: jest.fn(), - processEvents: jest.fn(), - clearEvents: jest.fn(), - }, -})); - -jest.mock('@/stores/app/core-store', () => { - const mockStore = jest.fn(); - (mockStore as any).getState = jest.fn(); - return { useCoreStore: mockStore }; -}); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn(), -})); - -jest.mock('@/stores/roles/store', () => ({ - useRolesStore: jest.fn(), -})); - -jest.mock('@/stores/toast/store', () => ({ - useToastStore: jest.fn(), -})); - -import React from 'react'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react-native'; -import { useTranslation } from 'react-i18next'; - -import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useToastStore } from '@/stores/toast/store'; -import { useLocationStore } from '@/stores/app/location-store'; - -import { StatusBottomSheet } from '../status-bottom-sheet'; - - -const mockSetActiveCall = jest.fn(); - -const mockTranslation = { - t: (key: string, options?: any) => { - const translations: Record = { - 'common.step': 'Step', - 'common.of': 'of', - 'common.next': 'Next', - 'common.previous': 'Previous', - 'common.submit': 'Submit', - 'common.submitting': 'Submitting', - 'common.optional': 'Optional', - 'status.select_status': 'Select Status', - 'status.select_status_type': 'What status would you like to set?', - 'status.select_destination': 'Select Destination for {{status}}', - 'status.add_note': 'Add Note', - 'status.set_status': 'Set Status', - 'status.select_destination_type': 'Select destination type', - 'status.no_destination': 'No Destination', - 'status.general_status': 'General Status', - 'status.calls_tab': 'Calls', - 'status.calls_and_pois_destinations_enabled': 'Can respond to calls or POIs', - 'status.calls_stations_pois_destinations_enabled': 'Can respond to calls, stations, or POIs', - 'status.stations_tab': 'Stations', - 'status.pois_tab': 'POIs', - 'status.selected_destination': 'Selected Destination', - 'status.selected_status': 'Selected Status', - 'status.note': 'Note', - 'status.note_required': 'Note required', - 'status.note_optional': 'Note optional', - 'status.loading_pois': 'Loading POIs...', - 'status.loading_stations': 'Loading stations...', - 'status.station_destination_enabled': 'Can respond to stations', - 'status.call_destination_enabled': 'Can respond to calls', - 'status.both_destinations_enabled': 'Can respond to calls or stations', - 'status.poi_destination_enabled': 'Can respond to POIs', - 'status.stations_and_pois_destinations_enabled': 'Can respond to stations or POIs', - 'status.no_statuses_available': 'No statuses available', - 'status.status_saved_successfully': 'Status saved successfully', - 'status.failed_to_save_status': 'Failed to save status', - 'calls.loading_calls': 'Loading calls...', - 'calls.no_calls_available': 'No calls available', - 'status.no_stations_available': 'No stations available', - 'status.no_pois_available': 'No POIs available', - }; - - let translation = translations[key] || key; - if (options && typeof options === 'object') { - Object.keys(options).forEach(optionKey => { - translation = translation.replace(`{{${optionKey}}}`, options[optionKey]); - }); - } - return translation; - }, -}; - -const mockUseTranslation = useTranslation as jest.MockedFunction; -const mockUseStatusBottomSheetStore = useStatusBottomSheetStore as jest.MockedFunction; -const mockUseStatusesStore = useStatusesStore as jest.MockedFunction; -const mockUseCoreStore = useCoreStore as unknown as jest.MockedFunction; -const mockGetState = (mockUseCoreStore as any).getState; -const mockUseRolesStore = useRolesStore as jest.MockedFunction; -const mockUseToastStore = useToastStore as jest.MockedFunction; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; - -describe('StatusBottomSheet', () => { - const mockReset = jest.fn(); - const mockSetCurrentStep = jest.fn(); - const mockSetSelectedCall = jest.fn(); - const mockSetSelectedStation = jest.fn(); - const mockSetSelectedPoi = jest.fn(); - const mockSetSelectedDestinationType = jest.fn(); - const mockSetNote = jest.fn(); - const mockFetchDestinationData = jest.fn(); - const mockSaveUnitStatus = jest.fn(); - const mockShowToast = jest.fn(); - - const defaultBottomSheetStore = { - isOpen: false, - currentStep: 'select-destination' as const, - selectedCall: null, - selectedStation: null, - selectedPoi: null, - selectedDestinationType: 'none' as const, - selectedStatus: null, - cameFromStatusSelection: false, - note: '', - availableCalls: [], - availableStations: [], - availablePois: [], - availablePoiTypes: [], - isLoading: false, - setIsOpen: jest.fn(), - setCurrentStep: mockSetCurrentStep, - setSelectedCall: mockSetSelectedCall, - setSelectedStation: mockSetSelectedStation, - setSelectedPoi: mockSetSelectedPoi, - setSelectedDestinationType: mockSetSelectedDestinationType, - setSelectedStatus: jest.fn(), - setNote: mockSetNote, - fetchDestinationData: mockFetchDestinationData, - reset: mockReset, - }; - - const defaultStatusesStore = { - isLoading: false, - error: null, - saveUnitStatus: mockSaveUnitStatus, - }; - - const defaultCoreStore = { - activeUnitId: 'unit-1', - activeUnit: { - UnitId: 'unit-1', - Name: 'Unit 1', - }, - activeUnitStatus: null, - activeUnitStatusType: null, - activeStatuses: { - UnitType: '0', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#28a745', Color: '#fff', Gps: false, Note: 0, Detail: 1 }, - { Id: 2, Type: 2, StateId: 2, Text: 'Responding', BColor: '#ffc107', Color: '#000', Gps: true, Note: 1, Detail: 2 }, - { Id: 3, Type: 3, StateId: 3, Text: 'On Scene', BColor: '#dc3545', Color: '#fff', Gps: true, Note: 2, Detail: 3 }, - ], - }, - activeCallId: null, - activeCall: null, - activePriority: null, - config: null, - isLoading: false, - isInitialized: true, - isInitializing: false, - error: null, - init: jest.fn(), - setActiveUnit: jest.fn(), - setActiveUnitWithFetch: jest.fn(), - setActiveCall: mockSetActiveCall, - fetchConfig: jest.fn(), - }; - - const defaultRolesStore = { - unitRoleAssignments: [], - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseTranslation.mockReturnValue(mockTranslation as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(defaultBottomSheetStore); - } - return defaultBottomSheetStore; - }); - - mockUseStatusesStore.mockImplementation((selector: any) => { - if (selector) { - return selector(defaultStatusesStore); - } - return defaultStatusesStore; - }); - - mockUseToastStore.mockImplementation((selector: any) => { - const store = { showToast: mockShowToast }; - if (selector) { - return selector(store); - } - return store; - }); - - // Set up the core store mock with getState that returns the store state - mockGetState.mockReturnValue(defaultCoreStore as any); - // Also mock the hook usage in the component - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(defaultCoreStore); - } - return defaultCoreStore; - }); - - mockUseRolesStore.mockImplementation((selector: any) => { - if (selector) { - return selector(defaultRolesStore); - } - return defaultRolesStore; - }); - - mockUseLocationStore.mockImplementation((selector: any) => { - const store = { - latitude: 37.7749, - longitude: -122.4194, - heading: 0, - accuracy: 10, - speed: 0, - altitude: 0, - timestamp: Date.now(), - }; - if (selector) { - return selector(store); - } - return store; - }); - }); - - it('should be importable without error', () => { - expect(StatusBottomSheet).toBeDefined(); - expect(typeof StatusBottomSheet).toBe('function'); - }); - - it('should not render when isOpen is false', () => { - render(); - expect(screen.queryByText('Select Destination for')).toBeNull(); - }); - - it('should render when isOpen is true with destination step', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, // Show destination step - Note: 1, // Note optional - this gives us 2 steps - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Step 1 of 2')).toBeTruthy(); - expect(screen.getByText('Select Destination for Available')).toBeTruthy(); - expect(screen.getByText('No Destination')).toBeTruthy(); - expect(screen.getByText('Next')).toBeTruthy(); - }); - - it('should handle no destination selection', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const noDestinationOption = screen.getByText('No Destination'); - fireEvent.press(noDestinationOption); - - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('none'); - expect(mockSetSelectedCall).toHaveBeenCalledWith(null); - expect(mockSetSelectedStation).toHaveBeenCalledWith(null); - }); - - it('should handle call selection and unselect no destination', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetSelectedStation).toHaveBeenCalledWith(null); - }); - - it('should handle station selection and unselect no destination', () => { - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'At Station', - Detail: 1, // Show stations - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableStations: [mockStation], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const stationOption = screen.getByText('Fire Station 1'); - fireEvent.press(stationOption); - - expect(mockSetSelectedStation).toHaveBeenCalledWith(mockStation); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('station'); - expect(mockSetSelectedCall).toHaveBeenCalledWith(null); - }); - - it('should not set active call when selecting a call', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with no active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: null, - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: null, - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - // Active call should NOT be set until submission - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - - it('should not set active call when selecting a different call', () => { - const mockCall = { - CallId: 'call-2', - Number: 'C002', - Name: 'Fire Emergency', - Address: '456 Oak St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with different active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'call-1', - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const callOption = screen.getByText('C002 - Fire Emergency'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - // Active call should NOT be set until submission - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - - it('should not set active call when selecting any call during selection', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with same active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'call-1', - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - - it('should show tabs when detailLevel is 3', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 3, // Show both calls and stations - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [{ CallId: 'call-1', Number: 'C001', Name: 'Call', Address: '' }], - availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Calls')).toBeTruthy(); - expect(screen.getByText('Stations')).toBeTruthy(); - }); - - it('should proceed to note step when next is pressed', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 1, // Note optional - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('add-note'); - }); - - it('should show note step correctly', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 1, // Note optional - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'add-note', - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Step 2 of 2')).toBeTruthy(); - expect(screen.getByText('Add Note')).toBeTruthy(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('No Destination')).toBeTruthy(); - expect(screen.getByText('Previous')).toBeTruthy(); - expect(screen.getByText('Submit')).toBeTruthy(); - }); - - it('should handle previous button on note step', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 1, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'add-note', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const previousButton = screen.getByText('Previous'); - fireEvent.press(previousButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('select-destination'); - }); - - it('should handle note input', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 2, // Note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'add-note', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const noteInput = screen.getByPlaceholderText('Note required'); - fireEvent.changeText(noteInput, 'Test note'); - - expect(mockSetNote).toHaveBeenCalledWith('Test note'); - }); - - it('should disable submit when note is required but empty', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 2, // Note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'add-note', - note: '', // Empty note - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Find all button elements and check the submit button's disabled state - const buttons = screen.getAllByTestId('button'); - const submitButton = buttons.find(button => { - try { - const textElements = button.findAllByType('Text'); - return textElements.some((text: any) => text.props.children === 'Submit'); - } catch (e) { - return false; - } - }); - expect(submitButton?.props.accessibilityState?.disabled).toBe(true); - }); - - it('should enable submit when note is required and provided', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 2, // Note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'add-note', - note: 'Test note', // Note provided - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Find all button elements and check the submit button's disabled state - const buttons = screen.getAllByTestId('button'); - const submitButton = buttons.find(button => { - try { - const textElements = button.findAllByType('Text'); - return textElements.some((text: any) => text.props.children === 'Submit'); - } catch (e) { - return false; - } - }); - expect(submitButton?.props.accessibilityState?.disabled).toBe(false); - }); - - it('should submit status directly when no destination step needed and no note required', async () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step - Note: 0, // No note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - }); - }); - - it('should show loading states correctly', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Need at least one call in availableCalls for the parent VStack to render - const mockAvailableCalls = [ - { CallId: 'call-1', Name: 'Test Call', Number: '123', Address: 'Test Address' }, - ]; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'select-destination', - isLoading: true, // This should show loading instead of the call list - availableCalls: mockAvailableCalls, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Loading calls...')).toBeTruthy(); - }); - - it('should set active call on submission when call is selected and different from current', async () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required - }; - - // Mock core store with no active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: null, - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: null, - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).toHaveBeenCalledWith('call-1'); - }); - }); - - it('should set active call on submission when selected call is different from current active call', async () => { - const mockCall = { - CallId: 'call-2', - Number: 'C002', - Name: 'Fire Emergency', - Address: '456 Oak St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required - }; - - // Mock core store with different active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'call-1', - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).toHaveBeenCalledWith('call-2'); - }); - }); - - it('should not set active call on submission when selected call is same as current active call', async () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required - }; - - // Mock core store with same active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'call-1', - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; return typeof selector === 'function' ? selector(store) : store; }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - }); - - it('should not set active call on submission when no call is selected', async () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step - Note: 0, // No note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - }); - - it('should not set active call on submission when station is selected', async () => { - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step needed - Note: 0, // No note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedStation: mockStation, - selectedDestinationType: 'station', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).not.toHaveBeenCalled(); - }); - }); - - it('should set active call only at end of flow, not during call selection', async () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, // No note required - }; - - // Mock core store with no active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: null, - } as any); - (useCoreStore as any).mockImplementation((selector: any) => { const store = { - ...defaultCoreStore, - activeCallId: null, - }; return typeof selector === 'function' ? selector(store) : store; }); - - const mockStore = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(mockStore); - } - return mockStore; - }); - - render(); - - // Step 1: Select a call - should NOT set active call - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetActiveCall).not.toHaveBeenCalled(); - - // Update mock store to reflect call selection - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...mockStore, - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - // Step 2: Navigate to next step - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - // setActiveCall should still NOT have been called - expect(mockSetActiveCall).not.toHaveBeenCalled(); - - // Re-render to show the final step (submit) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...mockStore, - selectedCall: mockCall, - selectedDestinationType: 'call', - currentStep: 'select-destination', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Step 3: Submit - NOW setActiveCall should be called - const submitButtonAfterFlow = screen.getByText('Next'); // This should trigger submit for no note status - fireEvent.press(submitButtonAfterFlow); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveCall).toHaveBeenCalledWith('call-1'); - }); - }); - - it('should fetch destination data when opened', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(mockFetchDestinationData).toHaveBeenCalledWith('unit-1'); - }); - - it('should show custom checkbox for no destination when selected', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // The No Destination option should be visually selected - const noDestinationContainer = screen.getByText('No Destination').parent?.parent; - expect(noDestinationContainer).toBeTruthy(); - }); - - it('should show custom checkbox for selected call', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const callContainer = screen.getByText('C001 - Emergency Call').parent?.parent; - expect(callContainer).toBeTruthy(); - }); - - it('should show custom checkbox for selected station', () => { - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'At Station', - Detail: 1, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableStations: [mockStation], - selectedStation: mockStation, - selectedDestinationType: 'station', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const stationContainer = screen.getByText('Fire Station 1').parent?.parent; - expect(stationContainer).toBeTruthy(); - }); - - it('should clear call selection when no destination is selected', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Select no destination - should clear call selection - const noDestinationOption = screen.getByText('No Destination'); - fireEvent.press(noDestinationOption); - - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('none'); - expect(mockSetSelectedCall).toHaveBeenCalledWith(null); - expect(mockSetSelectedStation).toHaveBeenCalledWith(null); - }); - - it('should clear station selection when call is selected', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Calls only - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - availableStations: [mockStation], - selectedStation: mockStation, - selectedDestinationType: 'station', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Select call - should clear station selection - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetSelectedStation).toHaveBeenCalledWith(null); - }); - - it('should clear call selection when station is selected', () => { - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 1, // Stations only - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [mockCall], - availableStations: [mockStation], - selectedCall: mockCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Select station - should clear call selection - const stationOption = screen.getByText('Fire Station 1'); - fireEvent.press(stationOption); - - expect(mockSetSelectedStation).toHaveBeenCalledWith(mockStation); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('station'); - expect(mockSetSelectedCall).toHaveBeenCalledWith(null); - }); - - it('should select a POI destination and clear call and station selections', () => { - const mockPoi = { - PoiId: 42, - PoiTypeId: 7, - PoiTypeName: 'Hospital', - Name: 'Mercy Hospital', - Address: '789 Care Way', - Note: '', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Transporting', - Detail: 4, // POIs only - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availablePois: [mockPoi], - availablePoiTypes: [{ PoiTypeId: 7, Name: 'Hospital', IsDestination: true }], - selectedCall: { CallId: 'call-1', Number: 'C001', Name: 'Emergency Call', Address: '123 Main St' }, - selectedStation: { GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const poiOption = screen.getByText('Mercy Hospital - 789 Care Way'); - fireEvent.press(poiOption); - - expect(mockSetSelectedPoi).toHaveBeenCalledWith(mockPoi); - expect(mockSetSelectedCall).toHaveBeenCalledWith(null); - expect(mockSetSelectedStation).toHaveBeenCalledWith(null); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('poi'); - }); - - it('should render many items without height constraints for proper scrolling', () => { - // Create many mock calls to test scrolling - const manyCalls = Array.from({ length: 10 }, (_, index) => ({ - CallId: `call-${index + 1}`, - Number: `C${String(index + 1).padStart(3, '0')}`, - Name: `Emergency Call ${index + 1}`, - Address: `${100 + index} Main Street`, - })); - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: manyCalls, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // All calls should be rendered (not limited by height constraints) - expect(screen.getByText('C001 - Emergency Call 1')).toBeTruthy(); - expect(screen.getByText('C005 - Emergency Call 5')).toBeTruthy(); - expect(screen.getByText('C010 - Emergency Call 10')).toBeTruthy(); - - // Select a call in the middle to ensure it's interactive - const fifthCall = screen.getByText('C005 - Emergency Call 5'); - fireEvent.press(fifthCall); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(manyCalls[4]); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - }); - - it('should render many stations without height constraints for proper scrolling', () => { - // Create many mock stations to test scrolling - const manyStations = Array.from({ length: 8 }, (_, index) => ({ - GroupId: `station-${index + 1}`, - Name: `Fire Station ${index + 1}`, - Address: `${200 + index} Oak Avenue`, - GroupType: 'Station', - })); - - const selectedStatus = { - Id: 'status-1', - Text: 'At Station', - Detail: 1, // Show stations - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableStations: manyStations, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // All stations should be rendered (not limited by height constraints) - expect(screen.getByText('Fire Station 1')).toBeTruthy(); - expect(screen.getByText('Fire Station 4')).toBeTruthy(); - expect(screen.getByText('Fire Station 8')).toBeTruthy(); - - // Select a station in the middle to ensure it's interactive - const fourthStation = screen.getByText('Fire Station 4'); - fireEvent.press(fourthStation); - - expect(mockSetSelectedStation).toHaveBeenCalledWith(manyStations[3]); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('station'); - }); - - it('should pre-select active call when status bottom sheet opens with calls enabled', async () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const otherCall = { - CallId: 'other-call-456', - Number: 'C456', - Name: 'Other Emergency Call', - Address: '456 Other St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'active-call-123', - }; - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [otherCall, activeCall], // Active call is in the list - isLoading: false, - selectedCall: null, // No call initially selected - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should pre-select the active call - await waitFor(() => { - expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - }); - }); - - it('should pre-select active call when status has detailLevel 3 (both calls and stations)', async () => { - const activeCall = { - CallId: 'active-call-789', - Number: 'C789', - Name: 'Active Fire Call', - Address: '789 Fire St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 3, // Show both calls and stations - Note: 0, - }; - - // Mock core store with active call - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'active-call-789', - }; - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], - availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }], - isLoading: false, - selectedCall: null, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should pre-select the active call - await waitFor(() => { - expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - }); - }); - - it('should not pre-select active call when calls are not enabled (detailLevel 1)', () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'At Station', - Detail: 1, // Show only stations, not calls - Note: 0, - }; - - // Mock core store with active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'active-call-123', - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], // Active call is in the list but not relevant for this status - isLoading: false, - selectedCall: null, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should NOT pre-select the active call since this status doesn't support calls - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalledWith('call'); - }); - - it('should not pre-select active call when it is not in the available calls list', () => { - const availableCall = { - CallId: 'available-call-456', - Number: 'C456', - Name: 'Available Call', - Address: '456 Available St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call that's NOT in the available calls list - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'different-active-call-999', - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [availableCall], // Active call is NOT in this list - isLoading: false, - selectedCall: null, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should NOT pre-select any call since the active call is not available - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalledWith('call'); - }); - - it('should not pre-select active call when there is no active call', () => { - const availableCall = { - CallId: 'available-call-456', - Number: 'C456', - Name: 'Available Call', - Address: '456 Available St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with NO active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: null, - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [availableCall], - isLoading: false, - selectedCall: null, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should NOT pre-select any call since there's no active call - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalledWith('call'); - }); - - it('should not pre-select active call when a call is already selected', () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const alreadySelectedCall = { - CallId: 'selected-call-456', - Number: 'C456', - Name: 'Already Selected Call', - Address: '456 Selected St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'active-call-123', - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall, alreadySelectedCall], - isLoading: false, - selectedCall: alreadySelectedCall, // Already has a selected call - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should NOT change the selection since a call is already selected - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalled(); - }); - - it('should not pre-select active call when destination type is not none', () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'active-call-123', - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], - isLoading: false, - selectedCall: null, - selectedDestinationType: 'station', // Not 'none', so should not change selection - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Invalid destination types should be cleared before any pre-selection happens - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('none'); - }); - - it('should not pre-select active call when still loading', () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - mockGetState.mockReturnValue({ - ...defaultCoreStore, - activeCallId: 'active-call-123', - } as any); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], - isLoading: true, // Still loading - selectedCall: null, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should NOT pre-select the active call since it's still loading - expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalled(); - }); - - it('should only select active call and not show no destination as selected when pre-selecting', async () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'active-call-123', - }; - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - // First render with initial state - let currentStore = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], - isLoading: false, - selectedCall: null, - selectedDestinationType: 'none', - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(currentStore); - } - return currentStore; - }); - - const { rerender } = render(); - - // Should pre-select the active call - await waitFor(() => { - expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall); - expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - }); - - // Simulate state update after pre-selection - const updatedStore = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [activeCall], - isLoading: false, - selectedCall: activeCall, - selectedDestinationType: 'call' as const, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(updatedStore); - } - return updatedStore; - }); - - rerender(); - - // Verify that the active call is visually selected - const callContainer = screen.getByText('C123 - Active Emergency Call').parent?.parent; - expect(callContainer).toBeTruthy(); - - // Verify that "No Destination" is NOT visually selected by checking the computed styling - const noDestinationContainer = screen.getByText('No Destination').parent?.parent?.parent; - expect(noDestinationContainer).toBeTruthy(); - - // The container should NOT have the selected styling (blue border/background) - expect(noDestinationContainer?.props.className).not.toContain('border-blue-500'); - expect(noDestinationContainer?.props.className).not.toContain('bg-blue-50'); - }); - - it('should not show no destination as selected during loading when there is an active call to pre-select', async () => { - const activeCall = { - CallId: 'active-call-123', - Number: 'C123', - Name: 'Active Emergency Call', - Address: '123 Active St', - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 2, // Show calls - Note: 0, - }; - - // Mock core store with active call - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'active-call-123', - }; - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - // Render with loading state (no calls available yet) - const loadingStore = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - availableCalls: [], // No calls loaded yet - isLoading: true, // Still loading - selectedCall: null, - selectedDestinationType: 'none', - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - if (selector) { - return selector(loadingStore); - } - return loadingStore; - }); - - const { rerender } = render(); - - // Verify that "No Destination" is NOT visually selected even during loading - // when there's an active call that should be pre-selected - const noDestinationContainer = screen.getByText('No Destination').parent?.parent?.parent; - expect(noDestinationContainer).toBeTruthy(); - - // The container should NOT have the selected styling during loading - expect(noDestinationContainer?.props.className).not.toContain('border-blue-500'); - expect(noDestinationContainer?.props.className).not.toContain('bg-blue-50'); - }); - - // New tests for status selection step - it('should render status selection step when no status is pre-selected', () => { - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, // No status pre-selected - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Step 1 of 3')).toBeTruthy(); - expect(screen.getByText('Select Status')).toBeTruthy(); - expect(screen.getByText('What status would you like to set?')).toBeTruthy(); - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('Responding')).toBeTruthy(); - expect(screen.getByText('On Scene')).toBeTruthy(); - }); - - it('should display checkmarks instead of radio buttons for status selection', () => { - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Check that we have TouchableOpacity components for each status instead of radio buttons - const statusOptions = screen.getAllByText(/Available|Responding|On Scene/); - expect(statusOptions).toHaveLength(3); - - // Verify the status text is present (indicating TouchableOpacity structure worked) - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('Responding')).toBeTruthy(); - expect(screen.getByText('On Scene')).toBeTruthy(); - }); - - it('should display checkmarks instead of radio buttons for destination selection', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 1, // Show destination step - Note: 1, // Note optional - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // The No Destination option should use TouchableOpacity with checkmark instead of radio - expect(screen.getByText('No Destination')).toBeTruthy(); - expect(screen.getByText('General Status')).toBeTruthy(); - }); - - it('should handle status selection', () => { - const mockSetSelectedStatus = jest.fn(); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - setSelectedStatus: mockSetSelectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const respondingStatus = screen.getByText('Responding'); - fireEvent.press(respondingStatus); - - expect(mockSetSelectedStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 2, - Text: 'Responding', - Detail: 2, - Note: 1, - }) - ); - }); - - it('should show status details in status selection', () => { - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Check for destination type descriptions - expect(screen.getByText('Can respond to stations')).toBeTruthy(); // Detail: 1 - expect(screen.getByText('Can respond to calls')).toBeTruthy(); // Detail: 2 - expect(screen.getByText('Can respond to calls or stations')).toBeTruthy(); // Detail: 3 - - // Check for note requirements - expect(screen.getByText('Note optional')).toBeTruthy(); // Note: 1 (Responding) - expect(screen.getByText('Note required')).toBeTruthy(); // Note: 2 (On Scene) - }); - - it('should disable next button on status selection when no status is selected', () => { - const mockSetCurrentStep = jest.fn(); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - cameFromStatusSelection: true, - setCurrentStep: mockSetCurrentStep, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - - // Try to press the button - it should not navigate when disabled - fireEvent.press(nextButton); - expect(mockSetCurrentStep).not.toHaveBeenCalled(); - }); - - it('should enable next button on status selection when status is selected', () => { - const mockSetCurrentStep = jest.fn(); - - const selectedStatus = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', - Color: '#fff', - Gps: false, - Note: 0, - Detail: 1, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus, - cameFromStatusSelection: true, - setCurrentStep: mockSetCurrentStep, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - - // Button should be enabled and allow navigation when status is selected - fireEvent.press(nextButton); - expect(mockSetCurrentStep).toHaveBeenCalledWith('select-destination'); - }); - - it('should proceed to destination step from status selection when destination is needed', () => { - const selectedStatus = { - Id: 2, - Type: 2, - StateId: 2, - Text: 'Responding', - BColor: '#ffc107', - Color: '#000', - Gps: true, - Note: 1, - Detail: 2, // Has destination step - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('select-destination'); - }); - - it('should proceed to note step from status selection when no destination is needed but note is required', () => { - const selectedStatus = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', - Color: '#fff', - Gps: false, - Note: 2, // Note required - Detail: 0, // No destination step - }; - - const mockHandleSubmit = jest.fn(); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('add-note'); - }); - - it('should submit directly from status selection when no destination or note is needed', () => { - const selectedStatus = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', - Color: '#fff', - Gps: false, - Note: 0, // No note required - Detail: 0, // No destination step - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - // Should call submit directly - expect(mockSaveUnitStatus).toHaveBeenCalled(); - }); - - it('should handle previous button from destination step to status selection', () => { - const selectedStatus = { - Id: 2, - Type: 2, - StateId: 2, - Text: 'Responding', - BColor: '#ffc107', - Color: '#000', - Gps: true, - Note: 1, - Detail: 2, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-destination', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const nextButton = screen.getByText('Next'); - fireEvent.press(nextButton); - - // Now in note step - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const previousButton = screen.getByText('Previous'); - fireEvent.press(previousButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('select-destination'); - }); - - it('should handle previous button from note step to status selection when no destination step', () => { - const selectedStatus = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', - Color: '#fff', - Gps: false, - Note: 1, // Note optional - Detail: 0, // No destination step - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const previousButton = screen.getByText('Previous'); - fireEvent.press(previousButton); - - expect(mockSetCurrentStep).toHaveBeenCalledWith('select-status'); - }); - - it('should calculate correct step numbers with status selection', () => { - // Test step 1 of 3 (status, destination, note) - const selectedStatusWithAll = { - Id: 3, - Type: 3, - StateId: 3, - Text: 'On Scene', - BColor: '#dc3545', - Color: '#fff', - Gps: true, - Note: 2, // Note optional - Detail: 3, // Both destinations - }; - - // Step 1: Status selection (no status selected yet) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, // No status selected yet, so we see status selection - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - const { rerender } = render(); - expect(screen.getByText('Step 1 of 3')).toBeTruthy(); - - // Step 2: After selecting status, now on destination step (from new flow) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-destination', - selectedStatus: selectedStatusWithAll, - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - rerender(); - expect(screen.getByText('Step 2 of 3')).toBeTruthy(); - - // Step 3: Note step (from new flow) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus: selectedStatusWithAll, - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - rerender(); - expect(screen.getByText('Step 3 of 3')).toBeTruthy(); - }); - - it('should calculate correct step numbers without destination step', () => { - // Test step 1 of 2 (status, note) - no destination - const selectedStatusNoDestination = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', - Color: '#fff', - Gps: false, - Note: 1, // Note required - Detail: 0, // No destination - }; - - // Create a mock core store with only statuses that don't have destinations - const coreStoreNoDestinations = { - ...defaultCoreStore, - activeStatuses: { - UnitType: '0', - Statuses: [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#28a745', Color: '#fff', Gps: false, Note: 1, Detail: 0 }, - { Id: 4, Type: 4, StateId: 4, Text: 'Busy', BColor: '#dc3545', Color: '#fff', Gps: false, Note: 0, Detail: 0 }, - ], - }, - }; - - mockGetState.mockReturnValue(coreStoreNoDestinations as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreNoDestinations); - } - return coreStoreNoDestinations; - }); - - // Status selection step - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - const { rerender } = render(); - expect(screen.getByText('Step 1 of 2')).toBeTruthy(); - - // Note step (skipping destination) - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus: selectedStatusNoDestination, - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - rerender(); - expect(screen.getByText('Step 2 of 2')).toBeTruthy(); - }); - - it('should show no statuses available message when no statuses are present', () => { - // Mock core store with no statuses - const coreStoreNoStatuses = { - ...defaultCoreStore, - activeStatuses: { - UnitType: '0', - Statuses: [], - }, - }; - - mockGetState.mockReturnValue(coreStoreNoStatuses as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreNoStatuses); - } - return coreStoreNoStatuses; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('No statuses available')).toBeTruthy(); - }); - - it('should show no statuses available message when activeStatuses is null', () => { - // Mock core store with null activeStatuses - const coreStoreNullStatuses = { - ...defaultCoreStore, - activeStatuses: null, - }; - - mockGetState.mockReturnValue(coreStoreNullStatuses as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreNullStatuses); - } - return coreStoreNullStatuses; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('No statuses available')).toBeTruthy(); - }); - - // NEW TESTS FOR LAYOUT IMPROVEMENTS - - it('should show "Committed" status with both calls and stations without pushing Next button off screen', () => { - const committedStatus = { - Id: 'committed-status', - Text: 'Committed', - Detail: 3, // Both calls and stations enabled - Note: 1, // Note optional - }; - - const mockCalls = Array.from({ length: 5 }, (_, index) => ({ - CallId: `call-${index + 1}`, - Number: `C${String(index + 1).padStart(3, '0')}`, - Name: `Emergency Call ${index + 1}`, - Address: `${100 + index} Main Street`, - })); - - const mockStations = Array.from({ length: 3 }, (_, index) => ({ - GroupId: `station-${index + 1}`, - Name: `Fire Station ${index + 1}`, - Address: `${200 + index} Oak Avenue`, - GroupType: 'Station', - })); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus: committedStatus, - currentStep: 'select-destination', - availableCalls: mockCalls, - availableStations: mockStations, - isLoading: false, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should show tabs for both calls and stations - expect(screen.getByText('Calls')).toBeTruthy(); - expect(screen.getByText('Stations')).toBeTruthy(); - - // Should show No Destination option - expect(screen.getByText('No Destination')).toBeTruthy(); - - // Next button should be visible and accessible - expect(screen.getByText('Next')).toBeTruthy(); - - // Should show some calls on the Calls tab (default) - expect(screen.getByText('C001 - Emergency Call 1')).toBeTruthy(); - - // Next button should still be accessible - expect(screen.getByText('Next')).toBeTruthy(); - }); - - it('should maintain Next button visibility with many calls and stations in Committed status', () => { - const committedStatus = { - Id: 'committed-status', - Text: 'Committed', - Detail: 3, // Both calls and stations enabled - Note: 0, // No note required - }; - - // Create many calls and stations to test scrolling - const manyCalls = Array.from({ length: 15 }, (_, index) => ({ - CallId: `call-${index + 1}`, - Number: `C${String(index + 1).padStart(3, '0')}`, - Name: `Emergency Call ${index + 1}`, - Address: `${100 + index} Main Street`, - })); - - const manyStations = Array.from({ length: 10 }, (_, index) => ({ - GroupId: `station-${index + 1}`, - Name: `Fire Station ${index + 1}`, - Address: `${200 + index} Oak Avenue`, - GroupType: 'Station', - })); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus: committedStatus, - currentStep: 'select-destination', - availableCalls: manyCalls, - availableStations: manyStations, - isLoading: false, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should show first few calls - expect(screen.getByText('C001 - Emergency Call 1')).toBeTruthy(); - expect(screen.getByText('C005 - Emergency Call 5')).toBeTruthy(); - - // Next button should still be visible and functional - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeTruthy(); - - // Should be able to click Next button without scrolling - fireEvent.press(nextButton); - - // Since no note is required, this should trigger submit - expect(mockSaveUnitStatus).toHaveBeenCalled(); - }); - - it('should handle status selection with many statuses without pushing Next button off screen', () => { - const manyStatuses = [ - { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#28a745', Color: '#fff', Gps: false, Note: 0, Detail: 1 }, - { Id: 2, Type: 2, StateId: 2, Text: 'Responding', BColor: '#ffc107', Color: '#000', Gps: true, Note: 1, Detail: 2 }, - { Id: 3, Type: 3, StateId: 3, Text: 'On Scene', BColor: '#dc3545', Color: '#fff', Gps: true, Note: 2, Detail: 3 }, - { Id: 4, Type: 4, StateId: 4, Text: 'Committed', BColor: '#17a2b8', Color: '#fff', Gps: true, Note: 1, Detail: 3 }, - { Id: 5, Type: 5, StateId: 5, Text: 'Transporting', BColor: '#6f42c1', Color: '#fff', Gps: true, Note: 1, Detail: 2 }, - { Id: 6, Type: 6, StateId: 6, Text: 'At Hospital', BColor: '#e83e8c', Color: '#fff', Gps: false, Note: 2, Detail: 1 }, - { Id: 7, Type: 7, StateId: 7, Text: 'Clearing', BColor: '#fd7e14', Color: '#000', Gps: false, Note: 0, Detail: 0 }, - { Id: 8, Type: 8, StateId: 8, Text: 'Out of Service', BColor: '#6c757d', Color: '#fff', Gps: false, Note: 2, Detail: 0 }, - ]; - - // Update core store with many statuses - const coreStoreWithManyStatuses = { - ...defaultCoreStore, - activeStatuses: { - UnitType: '0', - Statuses: manyStatuses, - }, - }; - - mockGetState.mockReturnValue(coreStoreWithManyStatuses as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithManyStatuses); - } - return coreStoreWithManyStatuses; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - cameFromStatusSelection: true, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should show all statuses - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('Committed')).toBeTruthy(); - expect(screen.getByText('Out of Service')).toBeTruthy(); - - // Next button should be visible but disabled since no status is selected - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeTruthy(); - - // Select a status - const committedStatus = screen.getByText('Committed'); - fireEvent.press(committedStatus); - - // Next button should still be accessible after selection - expect(screen.getByText('Next')).toBeTruthy(); - }); - - it('should show proper layout spacing in destination step with reduced margins', () => { - const selectedStatus = { - Id: 'status-1', - Text: 'Responding', - Detail: 3, // Both calls and stations - Note: 1, - }; - - const mockCall = { - CallId: 'call-1', - Number: 'C001', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - const mockStation = { - GroupId: 'station-1', - Name: 'Fire Station 1', - Address: '456 Oak Ave', - GroupType: 'Station', - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - currentStep: 'select-destination', - availableCalls: [mockCall], - availableStations: [mockStation], - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Check that the layout components are rendered - expect(screen.getByText('No Destination')).toBeTruthy(); - expect(screen.getByText('Calls')).toBeTruthy(); - expect(screen.getByText('Stations')).toBeTruthy(); - expect(screen.getByText('C001 - Emergency Call')).toBeTruthy(); - expect(screen.getByText('Next')).toBeTruthy(); - - // Verify we can select a call and the Next button remains accessible - const callOption = screen.getByText('C001 - Emergency Call'); - fireEvent.press(callOption); - - expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); - expect(screen.getByText('Next')).toBeTruthy(); - }); - - it('should show selected status and destination on note step', () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 2, - Note: 1, - }; - - const selectedCall = { - CallId: 'call-1', - Number: 'C123', - Name: 'Emergency Call', - Address: '123 Main St', - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedCall, - selectedDestinationType: 'call', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Selected Status:')).toBeTruthy(); - expect(screen.getByText('Available')).toBeTruthy(); - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); - }); - - it('should disable submit button and show spinner when submitting', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 0, - Note: 0, - }; - - // Mock a slow save operation - const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseStatusesStore.mockImplementation((selector: any) => { const store = { - ...defaultStatusesStore, - saveUnitStatus: slowSaveUnitStatus, - }; return typeof selector === 'function' ? selector(store) : store; }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - // Check that the button is disabled and shows submitting text - await waitFor(() => { - expect(screen.getByText('Submitting')).toBeTruthy(); - }); - - // Wait for the operation to complete - await waitFor(() => slowSaveUnitStatus); - }); - - it('should show success toast when status is saved successfully', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 0, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('success', 'Status saved successfully'); - expect(mockReset).toHaveBeenCalled(); - }); - }); - - it('should show error toast when status save fails', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 0, - Note: 0, - }; - - const errorSaveUnitStatus = jest.fn().mockRejectedValue(new Error('Network error')); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseStatusesStore.mockImplementation((selector: any) => { const store = { - ...defaultStatusesStore, - saveUnitStatus: errorSaveUnitStatus, - }; return typeof selector === 'function' ? selector(store) : store; }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - expect(errorSaveUnitStatus).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('error', 'Failed to save status'); - }); - - // Button should stop spinning even on error - await waitFor(() => { - expect(screen.getByText('Submit')).toBeTruthy(); // Should be back to normal state - }); - }); - - it('should prevent double submission when submit is pressed multiple times', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 0, - Note: 0, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - const submitButton = screen.getByText('Submit'); - - // Press submit multiple times rapidly - fireEvent.press(submitButton); - fireEvent.press(submitButton); - fireEvent.press(submitButton); - - await waitFor(() => { - // Should only call save once despite multiple presses - expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); - }); - }); - - it('should stop spinning immediately after status save completes', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 0, - Note: 0, - }; - - // Create a mock that resolves after a short delay to simulate API call - const fastSaveUnitStatus = jest.fn().mockImplementation(() => Promise.resolve()); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseStatusesStore.mockImplementation((selector: any) => { const store = { - ...defaultStatusesStore, - saveUnitStatus: fastSaveUnitStatus, - }; return typeof selector === 'function' ? selector(store) : store; }); - - render(); - - const submitButton = screen.getByText('Submit'); - - // Initially should show "Submit" - expect(screen.getByText('Submit')).toBeTruthy(); - - fireEvent.press(submitButton); - - // Should immediately show "Submitting" - await waitFor(() => { - expect(screen.getByText('Submitting')).toBeTruthy(); - }); - - // After the save completes, should go back to "Submit" and modal should close - await waitFor(() => { - expect(fastSaveUnitStatus).toHaveBeenCalled(); - expect(mockReset).toHaveBeenCalled(); // Modal should close immediately after save - }); - }); - - it('should disable previous button when submitting on note step', async () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 2, - Note: 1, - }; - - const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - note: 'Test note', - }; - if (selector) { - return selector(store); - } - return store; - }); - - mockUseStatusesStore.mockImplementation((selector: any) => { const store = { - ...defaultStatusesStore, - saveUnitStatus: slowSaveUnitStatus, - }; return typeof selector === 'function' ? selector(store) : store; }); - - render(); - - const submitButton = screen.getByText('Submit'); - fireEvent.press(submitButton); - - await waitFor(() => { - // Check that the submitting state is active - expect(screen.getByText('Submitting')).toBeTruthy(); - }); - - // Wait for the operation to complete - await waitFor(() => expect(slowSaveUnitStatus).toHaveBeenCalled()); - }); - - it('should show "No Destination" correctly when no call or station is selected', () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 2, - Note: 1, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('No Destination')).toBeTruthy(); - }); - - it('should show loading state when call is being selected but selectedCall is null', () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 2, - Note: 1, - }; - - const availableCalls = [ - { - CallId: 'call-1', - Number: 'C123', - Name: 'Emergency Call', - Address: '123 Main St', - }, - ]; - - // Mock core store with active call ID - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; - - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'call', // Set to call but no selectedCall yet - selectedCall: null, // This is the issue scenario - availableCalls, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); // Should find the call from availableCalls - }); - - it('should show loading text when call type is selected but no calls are available yet', () => { - const selectedStatus = { - Id: 1, - Text: 'Available', - Color: '#00FF00', - Detail: 2, - Note: 1, - }; - - // Mock core store with active call ID - const coreStoreWithActiveCall = { - ...defaultCoreStore, - activeCallId: 'call-1', - }; - - mockGetState.mockReturnValue(coreStoreWithActiveCall as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithActiveCall); - } - return coreStoreWithActiveCall; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus, - selectedDestinationType: 'call', // Set to call but no selectedCall yet - selectedCall: null, // This is the issue scenario - availableCalls: [], // No calls available yet - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Selected Destination:')).toBeTruthy(); - expect(screen.getByText('Loading calls...')).toBeTruthy(); // Should show loading text - }); - - // New tests for color scheme functionality - it('should use BColor for background and invertColor for text color in status selection', () => { - const statusWithBColor = { - Id: 1, - Type: 1, - StateId: 1, - Text: 'Available', - BColor: '#28a745', // Green background - Color: '#fff', // Original text color (should be ignored) - Gps: false, - Note: 0, - Detail: 1, - }; - - // Mock core store with status that has BColor - const coreStoreWithBColor = { - ...defaultCoreStore, - activeStatuses: { - UnitType: '0', - Statuses: [statusWithBColor], - }, - }; - - mockGetState.mockReturnValue(coreStoreWithBColor as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreWithBColor); - } - return coreStoreWithBColor; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Check that the status text is present - expect(screen.getByText('Available')).toBeTruthy(); - - // The styling should use BColor for background and calculated text color for contrast - // We can't easily test the actual computed styles in this test environment, - // but we've verified that the component renders without errors - }); - - it('should use BColor for background in selected status display on note step', () => { - const statusWithBColor = { - Id: 1, - Text: 'Responding', - BColor: '#ffc107', // Yellow background - Color: '#000', // Original text color (should be ignored) - Detail: 1, - Note: 1, - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'add-note', - selectedStatus: statusWithBColor, - selectedDestinationType: 'none', - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - expect(screen.getByText('Selected Status:')).toBeTruthy(); - expect(screen.getByText('Responding')).toBeTruthy(); - - // The status display should use BColor for background and calculated text color - // We can't easily test the actual computed styles, but we verify rendering works - }); - - it('should handle status without BColor gracefully', () => { - const statusWithoutBColor = { - Id: 1, - Text: 'Emergency', - BColor: '', // No background color - Color: '#ff0000', // Red text color - Detail: 0, - Note: 0, - }; - - // Mock core store with status that has no BColor - const coreStoreNoBColor = { - ...defaultCoreStore, - activeStatuses: { - UnitType: '0', - Statuses: [statusWithoutBColor], - }, - }; - - mockGetState.mockReturnValue(coreStoreNoBColor as any); - mockUseCoreStore.mockImplementation((selector: any) => { - if (selector) { - return selector(coreStoreNoBColor); - } - return coreStoreNoBColor; - }); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-status', - selectedStatus: null, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should still render the status text - expect(screen.getByText('Emergency')).toBeTruthy(); - - // Component should handle missing BColor gracefully with fallback - }); - - it('should show Next button when tabs are visible with reduced ScrollView height', () => { - const committedStatus = { - Id: 4, - Text: 'Committed', - Color: '#FF6600', - Detail: 3, // Both calls and stations - Note: 0, - }; - - // Mock lots of calls and stations to ensure content would overflow - const manyCalls = Array.from({ length: 20 }, (_, i) => ({ - CallId: `call-${i}`, - Number: `C-${i.toString().padStart(3, '0')}`, - Name: `Emergency Call ${i}`, - Address: `${100 + i} Test Street`, - })); - - const manyStations = Array.from({ length: 15 }, (_, i) => ({ - GroupId: `station-${i}`, - Name: `Station ${i}`, - Address: `${200 + i} Station Road`, - GroupType: 'Fire Station', - })); - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { - const store = { - ...defaultBottomSheetStore, - isOpen: true, - currentStep: 'select-destination', - selectedStatus: committedStatus, - availableCalls: manyCalls, - availableStations: manyStations, - isLoading: false, - }; - if (selector) { - return selector(store); - } - return store; - }); - - render(); - - // Should show tab headers for calls and stations - expect(screen.getByText('Calls')).toBeTruthy(); - expect(screen.getByText('Stations')).toBeTruthy(); - - // Should show some calls (even with many items) - expect(screen.getByText('C-000 - Emergency Call 0')).toBeTruthy(); - - // Next button should still be visible and accessible - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeTruthy(); - - // Button should be enabled (can proceed) - fireEvent.press(nextButton); - // Should not throw or fail to find the button - }); -}); diff --git a/src/components/status/__tests__/status-gps-debug.test.tsx b/src/components/status/__tests__/status-gps-debug.test.tsx deleted file mode 100644 index b0835c1..0000000 --- a/src/components/status/__tests__/status-gps-debug.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react-native'; -import { StatusBottomSheet } from '../status-bottom-sheet'; -import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { saveUnitStatus } from '@/api/units/unitStatuses'; - -// Mock all the stores with the exact same approach as the working test -jest.mock('@/stores/status/store'); -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/app/location-store'); -jest.mock('@/stores/roles/store'); -jest.mock('@/api/units/unitStatuses'); -jest.mock('@/services/offline-event-manager.service'); - -// Mock translations -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'common.submit': 'Submit', - 'common.next': 'Next', - 'common.previous': 'Previous', - 'common.cancel': 'Cancel', - }; - return translations[key] || key; - } - }), -})); - -// Mock the Actionsheet components to render properly -jest.mock('@/components/ui/actionsheet', () => ({ - Actionsheet: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => { - const { View } = require('react-native'); - return isOpen ? {children} : null; - }, - ActionsheetBackdrop: ({ children }: { children: React.ReactNode }) => { - const { View } = require('react-native'); - return {children}; - }, - ActionsheetContent: ({ children }: { children: React.ReactNode }) => { - const { View } = require('react-native'); - return {children}; - }, - ActionsheetDragIndicator: () => { - const { View } = require('react-native'); - return ; - }, - ActionsheetDragIndicatorWrapper: ({ children }: { children: React.ReactNode }) => { - const { View } = require('react-native'); - return {children}; - }, -})); - -const mockUseStatusBottomSheetStore = useStatusBottomSheetStore as jest.MockedFunction; -const mockUseStatusesStore = useStatusesStore as jest.MockedFunction; -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseRolesStore = useRolesStore as jest.MockedFunction; -const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; - -describe('Status GPS Debug Test', () => { - it('should render Submit button with minimal setup', () => { - // Mock all required stores - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeUnit: { - UnitId: 'unit1', - Name: 'Unit 1', - Type: 'Engine', - }, - }) : { - activeUnit: { - UnitId: 'unit1', - Name: 'Unit 1', - Type: 'Engine', - }, - }); - - mockUseStatusesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - saveUnitStatus: mockSaveUnitStatus, - }) : { - saveUnitStatus: mockSaveUnitStatus, - }); - - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 0, - heading: 180, - timestamp: '2025-08-06T17:30:00.000Z', - }) : { - latitude: 40.7128, - longitude: -74.0060, - accuracy: 10, - altitude: 50, - speed: 0, - heading: 180, - timestamp: '2025-08-06T17:30:00.000Z', - }); - - mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - unitRoleAssignments: [], - }) : { - unitRoleAssignments: [], - }); - - // Copy exact pattern from working status-bottom-sheet test - const defaultBottomSheetStore = { - isOpen: false, - currentStep: 'select-destination' as const, - selectedCall: null, - selectedStation: null, - selectedPoi: null, - selectedDestinationType: 'none' as const, - selectedStatus: null, - cameFromStatusSelection: false, - note: '', - availableCalls: [], - availableStations: [], - availablePois: [], - availablePoiTypes: [], - isLoading: false, - setIsOpen: jest.fn(), - setCurrentStep: jest.fn(), - setSelectedCall: jest.fn(), - setSelectedStation: jest.fn(), - setSelectedPoi: jest.fn(), - setSelectedDestinationType: jest.fn(), - setSelectedStatus: jest.fn(), - setNote: jest.fn(), - fetchDestinationData: jest.fn(), - reset: jest.fn(), - }; - - const selectedStatus = { - Id: 'status-1', - Text: 'Available', - Detail: 0, // No destination step - Note: 0, // No note required - }; - - mockUseStatusBottomSheetStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }) : { - ...defaultBottomSheetStore, - isOpen: true, - selectedStatus, - }); - - // Verify the mock is being called - console.log('Mock implementation:', mockUseStatusBottomSheetStore.getMockName()); - console.log('Mock return value:', mockUseStatusBottomSheetStore()); - - render(); - - console.log('Component rendered. Looking for Submit button...'); - - // Check if the basic ActionSheet is rendered - const actionsheet = screen.queryByTestId('actionsheet'); - console.log('Actionsheet found:', !!actionsheet); - - // Look for any text content - const allText = screen.queryAllByText(/.*/, { exact: false }); - console.log('All text elements found:', allText.length); - allText.forEach((element, index) => { - console.log(`Text ${index}:`, element.props.children); - }); - - // Look for any button elements - const allButtons = screen.queryAllByRole('button'); - console.log('All button elements found:', allButtons.length); - - // Always show debug to see what's rendered - screen.debug(); - - const submitButton = screen.queryByText('Submit'); - console.log('Submit button found:', !!submitButton); - - if (submitButton) { - console.log('SUCCESS: Submit button is visible!'); - } else { - console.log('FAILURE: Submit button not found'); - } - - expect(submitButton).toBeTruthy(); - }); -}); diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx deleted file mode 100644 index 6eae2a5..0000000 --- a/src/components/status/__tests__/status-gps-integration-working.test.tsx +++ /dev/null @@ -1,553 +0,0 @@ -/** - * GPS Integration Tests for Status Bottom Sheet - * Tests GPS coordinate handling in statu expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objec expect(mockSaveUni expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.obje expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '2', - 'Offline GPS status', - '', - [], - { - latitude: '40.7128', - longitude: '-74.006', - altitude: '100', - heading: '90', - speed: '25', - accuracy: '15', - altitudeAccuracy: '', - }, - ); - }); Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Head expect(mockOfflineEventManager.queueUnitStatusEvent) expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '5', - 'Complex status with GPS', - 'call123', - [{ userId: 'user1', roleId: 'role1' }], - { - latitude: '51.5074', - longitude: '-0.1278', - accuracy: '8', - altitude: '', - speed: '30', - heading: '', - altitudeAccuracy: '', - }, - );edWith( - 'unit1', - '4', - 'Partial GPS', - '', - [], - { - latitude: '35.6762', - longitude: '139.6503', - accuracy: '', - altitude: '', - speed: '', - heading: '', - altitudeAccuracy: '', - }, - ); AltitudeAccuracy: '', - }), - );oHaveBeenC expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '2', - 'Offline GPS status', - '', - [], - { - latitude: '40.7128', - longitude: '-74.006', - accuracy: '15', - altitude: '100', - speed: '25', - heading: '90', - altitudeAccuracy: '', - }, - ); expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - AltitudeAccuracy: '', - }), - );g({ - Id: 'unit1', - Type: '1', - Note: 'GPS enabled status', - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '10', - Altitude: '50', - Speed: '0', - Heading: '180', - AltitudeAccuracy: '', - }), - ); - */ - -import { act, renderHook } from '@testing-library/react-native'; - -import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { offlineEventManager } from '@/services/offline-event-manager.service'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useStatusesStore } from '@/stores/status/store'; - -// Mock the dependencies -jest.mock('@/api/units/unitStatuses', () => ({ - saveUnitStatus: jest.fn(), -})); - -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - queueUnitStatusEvent: jest.fn(), - }, -})); - -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn(), -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn(), -})); - -jest.mock('@/lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -const mockOfflineEventManager = offlineEventManager as jest.Mocked; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseCoreStore = require('@/stores/app/core-store').useCoreStore as jest.MockedFunction; -const mockSaveUnitStatus = require('@/api/units/unitStatuses').saveUnitStatus as jest.MockedFunction; - -describe('Status GPS Integration', () => { - let mockLocationStore: any; - let mockCoreStore: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockLocationStore = { - latitude: null, - longitude: null, - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - }; - - mockCoreStore = { - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: jest.fn(), - }; - - mockUseLocationStore.mockImplementation(() => { - console.log('useLocationStore called, returning:', mockLocationStore); - return mockLocationStore; - }); - // Also mock getState for the location store logic - (mockUseLocationStore as any).getState = jest.fn().mockReturnValue(mockLocationStore); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore); - // Also mock getState for the status store logic - (mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore); - mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id'); - }); - - describe('GPS Coordinate Integration', () => { - it('should include GPS coordinates when available during successful submission', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - mockLocationStore.altitude = 50; - mockLocationStore.speed = 0; - mockLocationStore.heading = 180; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'GPS enabled status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 'unit1', - Type: '1', - Note: 'GPS enabled status', - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '10', - Altitude: '50', - AltitudeAccuracy: '', - Speed: '0', - Heading: '180', - }) - ); - }); - - it('should not include GPS coordinates when location data is not available', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'No GPS status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 'unit1', - Type: '1', - Note: 'No GPS status', - Latitude: '', - Longitude: '', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - }) - ); - }); - - it('should handle partial GPS data (only lat/lon available)', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up minimal location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - // Other fields remain null - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - AltitudeAccuracy: '', - }) - ); - }); - - it('should include GPS coordinates in offline queue when submission fails', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 15; - mockLocationStore.altitude = 100; - mockLocationStore.speed = 25; - mockLocationStore.heading = 90; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '2'; - input.Note = 'Offline GPS status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '2', - 'Offline GPS status', - '', - null, - [], - { - latitude: '40.7128', - longitude: '-74.006', - accuracy: '15', - altitude: '100', - altitudeAccuracy: '', - speed: '25', - heading: '90', - } - ); - }); - - it('should not include GPS data in offline queue when location is unavailable', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '3'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '3', - '', - '', - null, - [], - undefined - ); - }); - - it('should handle high precision GPS coordinates', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up high precision location data - mockLocationStore.latitude = 40.712821; - mockLocationStore.longitude = -74.006015; - mockLocationStore.accuracy = 3; - mockLocationStore.altitude = 10.5; - mockLocationStore.speed = 5.2; - mockLocationStore.heading = 245.8; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.712821', - Longitude: '-74.006015', - Accuracy: '3', - Altitude: '10.5', - Speed: '5.2', - Heading: '245.8', - }) - ); - }); - - it('should handle edge case GPS values (zeros and negatives)', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up edge case location data - mockLocationStore.latitude = 0; - mockLocationStore.longitude = 0; - mockLocationStore.accuracy = 0; - mockLocationStore.altitude = -50; // Below sea level - mockLocationStore.speed = 0; - mockLocationStore.heading = 0; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '0', - Longitude: '0', - Accuracy: '0', - Altitude: '-50', - Speed: '0', - Heading: '0', - }) - ); - }); - - it('should prioritize input GPS coordinates over location store when both exist', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location store data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Pre-populate input with different GPS coordinates - input.Latitude = '41.8781'; - input.Longitude = '-87.6298'; - input.Accuracy = '5'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - // Should use the input coordinates, not location store - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '41.8781', - Longitude: '-87.6298', - Accuracy: '5', - }) - ); - }); - - it('should handle null/undefined GPS values gracefully', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up mixed null/undefined location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = null; - mockLocationStore.altitude = undefined; - mockLocationStore.speed = null; - mockLocationStore.heading = undefined; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - AltitudeAccuracy: '', - }) - ); - }); - }); - - describe('Offline GPS Integration', () => { - it('should queue GPS data with partial location information', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Only latitude and longitude available - mockLocationStore.latitude = 35.6762; - mockLocationStore.longitude = 139.6503; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '4'; - input.Note = 'Partial GPS'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '4', - 'Partial GPS', - '', - null, - [], - { - latitude: '35.6762', - longitude: '139.6503', - accuracy: '', - altitude: '', - speed: '', - heading: '', - altitudeAccuracy: '', - } - ); - }); - - it('should handle GPS data with roles and complex status data', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockLocationStore.latitude = 51.5074; - mockLocationStore.longitude = -0.1278; - mockLocationStore.accuracy = 8; - mockLocationStore.speed = 30; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '5'; - input.Note = 'Complex status with GPS'; - input.RespondingTo = 'call123'; - input.Roles = [{ - Id: '1', - EventId: '', - UserId: 'user1', - RoleId: 'role1', - Name: 'Driver', - }]; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '5', - 'Complex status with GPS', - 'call123', - null, - [{ roleId: 'role1', userId: 'user1' }], - { - latitude: '51.5074', - longitude: '-0.1278', - accuracy: '8', - altitude: '', - speed: '30', - heading: '', - altitudeAccuracy: '', - } - ); - }); - }); -}); diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx deleted file mode 100644 index 5c27e75..0000000 --- a/src/components/status/__tests__/status-gps-integration.test.tsx +++ /dev/null @@ -1,460 +0,0 @@ -/** - * GPS Integration Tests for Status Bottom Sheet - * Tests GPS coordinate handling in status submissions - */ - -import { act, renderHook } from '@testing-library/react-native'; - -import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { offlineEventManager } from '@/services/offline-event-manager.service'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useStatusesStore } from '@/stores/status/store'; - -// Mock the dependencies -jest.mock('@/api/units/unitStatuses', () => ({ - saveUnitStatus: jest.fn(), -})); - -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - queueUnitStatusEvent: jest.fn(), - }, -})); - -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn(), -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn(), -})); - -jest.mock('@/lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -const mockOfflineEventManager = offlineEventManager as jest.Mocked; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseCoreStore = require('@/stores/app/core-store').useCoreStore as jest.MockedFunction; -const mockSaveUnitStatus = require('@/api/units/unitStatuses').saveUnitStatus as jest.MockedFunction; - -describe('Status GPS Integration', () => { - let mockLocationStore: any; - let mockCoreStore: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockLocationStore = { - latitude: null, - longitude: null, - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - }; - - mockCoreStore = { - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: jest.fn(), - }; - - mockUseLocationStore.mockImplementation(() => { - console.log('useLocationStore called, returning:', mockLocationStore); - return mockLocationStore; - }); - // Also mock getState method - (mockUseLocationStore as any).getState = jest.fn().mockReturnValue(mockLocationStore); - - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore); - // Also mock getState for the status store logic - (mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore); - mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id'); - }); - - describe('GPS Coordinate Integration', () => { - it('should include GPS coordinates when available during successful submission', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - mockLocationStore.altitude = 50; - mockLocationStore.speed = 0; - mockLocationStore.heading = 180; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'GPS enabled status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 'unit1', - Type: '1', - Note: 'GPS enabled status', - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '10', - Altitude: '50', - AltitudeAccuracy: '', - Speed: '0', - Heading: '180', - }) - ); - }); - - it('should not include GPS coordinates when location data is not available', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'No GPS status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 'unit1', - Type: '1', - Note: 'No GPS status', - Latitude: '', - Longitude: '', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - }) - ); - }); - - it('should handle partial GPS data (only lat/lon available)', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up minimal location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - // Other fields remain null - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - }) - ); - }); - - it('should include GPS coordinates in offline queue when submission fails', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 15; - mockLocationStore.altitude = 100; - mockLocationStore.speed = 25; - mockLocationStore.heading = 90; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '2'; - input.Note = 'Offline GPS status'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '2', - 'Offline GPS status', - '', - null, - [], - { - latitude: '40.7128', - longitude: '-74.006', - accuracy: '15', - altitude: '100', - altitudeAccuracy: '', - speed: '25', - heading: '90', - } - ); - }); - - it('should not include GPS data in offline queue when location is unavailable', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '3'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '3', - '', - '', - null, - [], - undefined - ); - }); - - it('should handle high precision GPS coordinates', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up high precision location data - mockLocationStore.latitude = 40.712821; - mockLocationStore.longitude = -74.006015; - mockLocationStore.accuracy = 3; - mockLocationStore.altitude = 10.5; - mockLocationStore.speed = 5.2; - mockLocationStore.heading = 245.8; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.712821', - Longitude: '-74.006015', - Accuracy: '3', - Altitude: '10.5', - Speed: '5.2', - Heading: '245.8', - }) - ); - }); - - it('should handle edge case GPS values (zeros and negatives)', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up edge case location data - mockLocationStore.latitude = 0; - mockLocationStore.longitude = 0; - mockLocationStore.accuracy = 0; - mockLocationStore.altitude = -50; // Below sea level - mockLocationStore.speed = 0; - mockLocationStore.heading = 0; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '0', - Longitude: '0', - Accuracy: '0', - Altitude: '-50', - Speed: '0', - Heading: '0', - }) - ); - }); - - it('should prioritize input GPS coordinates over location store when both exist', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up location store data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = 10; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Pre-populate input with different GPS coordinates - input.Latitude = '41.8781'; - input.Longitude = '-87.6298'; - input.Accuracy = '5'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - // Should use the input coordinates, not location store - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '41.8781', - Longitude: '-87.6298', - Accuracy: '5', - }) - ); - }); - - it('should handle null/undefined GPS values gracefully', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Set up mixed null/undefined location data - mockLocationStore.latitude = 40.7128; - mockLocationStore.longitude = -74.0060; - mockLocationStore.accuracy = null; - mockLocationStore.altitude = undefined; - mockLocationStore.speed = null; - mockLocationStore.heading = undefined; - - mockSaveUnitStatus.mockResolvedValue({}); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Latitude: '40.7128', - Longitude: '-74.006', - Accuracy: '', - Altitude: '', - Speed: '', - Heading: '', - }) - ); - }); - }); - - describe('Offline GPS Integration', () => { - it('should queue GPS data with partial location information', async () => { - const { result } = renderHook(() => useStatusesStore()); - - // Only latitude and longitude available - mockLocationStore.latitude = 35.6762; - mockLocationStore.longitude = 139.6503; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '4'; - input.Note = 'Partial GPS'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '4', - 'Partial GPS', - '', - null, - [], - { - latitude: '35.6762', - longitude: '139.6503', - accuracy: '', - altitude: '', - altitudeAccuracy: '', - speed: '', - heading: '', - } - ); - }); - - it('should handle GPS data with roles and complex status data', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockLocationStore.latitude = 51.5074; - mockLocationStore.longitude = -0.1278; - mockLocationStore.accuracy = 8; - mockLocationStore.speed = 30; - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '5'; - input.Note = 'Complex status with GPS'; - input.RespondingTo = 'call123'; - input.Roles = [{ - Id: '1', - EventId: '', - UserId: 'user1', - RoleId: 'role1', - Name: 'Driver', - }]; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '5', - 'Complex status with GPS', - 'call123', - null, - [{ roleId: 'role1', userId: 'user1' }], - { - latitude: '51.5074', - longitude: '-0.1278', - accuracy: '8', - altitude: '', - altitudeAccuracy: '', - speed: '30', - heading: '', - } - ); - }); - }); -}); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx deleted file mode 100644 index 643d836..0000000 --- a/src/components/status/status-bottom-sheet.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { ScrollView, TouchableOpacity } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; - -import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; -import { invertColor } from '@/lib/utils'; -import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; -import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; -import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { offlineEventManager } from '@/services/offline-event-manager.service'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useRolesStore } from '@/stores/roles/store'; -import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; -import { useToastStore } from '@/stores/toast/store'; - -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; -import { Button, ButtonText } from '../ui/button'; -import { Heading } from '../ui/heading'; -import { HStack } from '../ui/hstack'; -import { Spinner } from '../ui/spinner'; -import { Text } from '../ui/text'; -import { Textarea, TextareaInput } from '../ui/textarea'; -import { VStack } from '../ui/vstack'; - -type DestinationTab = 'call' | 'station' | 'poi'; - -const getDestinationTabs = (detail: number): DestinationTab[] => { - const tabs: DestinationTab[] = []; - - if (statusDetailAllowsCalls(detail)) { - tabs.push('call'); - } - - if (statusDetailAllowsStations(detail)) { - tabs.push('station'); - } - - if (statusDetailAllowsPois(detail)) { - tabs.push('poi'); - } - - return tabs; -}; - -const getDestinationTabTranslationKey = (tab: DestinationTab): string => { - switch (tab) { - case 'call': - return 'status.calls_tab'; - case 'station': - return 'status.stations_tab'; - case 'poi': - return 'status.pois_tab'; - } -}; - -const getPreferredDestinationTab = ({ - tabs, - selectedDestinationType, - hasSelectedCall, - hasSelectedStation, - hasSelectedPoi, -}: { - tabs: DestinationTab[]; - selectedDestinationType: 'none' | 'call' | 'station' | 'poi'; - hasSelectedCall: boolean; - hasSelectedStation: boolean; - hasSelectedPoi: boolean; -}): DestinationTab => { - if (selectedDestinationType !== 'none' && tabs.includes(selectedDestinationType)) { - return selectedDestinationType; - } - - if (hasSelectedCall && tabs.includes('call')) { - return 'call'; - } - - if (hasSelectedStation && tabs.includes('station')) { - return 'station'; - } - - if (hasSelectedPoi && tabs.includes('poi')) { - return 'poi'; - } - - return tabs[0] ?? 'call'; -}; - -export const StatusBottomSheet = () => { - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const [selectedTab, setSelectedTab] = React.useState('call'); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const showToast = useToastStore((state) => state.showToast); - - React.useEffect(() => { - offlineEventManager.initialize(); - }, []); - - const isOpen = useStatusBottomSheetStore((state) => state.isOpen); - const currentStep = useStatusBottomSheetStore((state) => state.currentStep); - const selectedCall = useStatusBottomSheetStore((state) => state.selectedCall); - const selectedStation = useStatusBottomSheetStore((state) => state.selectedStation); - const selectedPoi = useStatusBottomSheetStore((state) => state.selectedPoi); - const selectedDestinationType = useStatusBottomSheetStore((state) => state.selectedDestinationType); - const selectedStatus = useStatusBottomSheetStore((state) => state.selectedStatus); - const cameFromStatusSelection = useStatusBottomSheetStore((state) => state.cameFromStatusSelection); - const note = useStatusBottomSheetStore((state) => state.note); - const availableCalls = useStatusBottomSheetStore((state) => state.availableCalls); - const availableStations = useStatusBottomSheetStore((state) => state.availableStations); - const availablePois = useStatusBottomSheetStore((state) => state.availablePois); - const availablePoiTypes = useStatusBottomSheetStore((state) => state.availablePoiTypes); - const isLoading = useStatusBottomSheetStore((state) => state.isLoading); - const setCurrentStep = useStatusBottomSheetStore((state) => state.setCurrentStep); - const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall); - const setSelectedStation = useStatusBottomSheetStore((state) => state.setSelectedStation); - const setSelectedPoi = useStatusBottomSheetStore((state) => state.setSelectedPoi); - const setSelectedDestinationType = useStatusBottomSheetStore((state) => state.setSelectedDestinationType); - const setSelectedStatus = useStatusBottomSheetStore((state) => state.setSelectedStatus); - const setNote = useStatusBottomSheetStore((state) => state.setNote); - const fetchDestinationData = useStatusBottomSheetStore((state) => state.fetchDestinationData); - const reset = useStatusBottomSheetStore((state) => state.reset); - - const activeUnit = useCoreStore((state) => state.activeUnit); - const activeCallId = useCoreStore((state) => state.activeCallId); - const setActiveCall = useCoreStore((state) => state.setActiveCall); - const activeStatuses = useCoreStore((state) => state.activeStatuses); - const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); - const saveUnitStatus = useStatusesStore((state) => state.saveUnitStatus); - const latitude = useLocationStore((state) => state.latitude); - const longitude = useLocationStore((state) => state.longitude); - const heading = useLocationStore((state) => state.heading); - const accuracy = useLocationStore((state) => state.accuracy); - const speed = useLocationStore((state) => state.speed); - const altitude = useLocationStore((state) => state.altitude); - const timestamp = useLocationStore((state) => state.timestamp); - - const poiTypesById = React.useMemo(() => createPoiTypeMap(availablePoiTypes), [availablePoiTypes]); - - const getStatusProperty = React.useCallback( - (prop: 'Detail' | 'Note', defaultValue: number): number => { - if (!selectedStatus) { - return defaultValue; - } - - const value = Number(selectedStatus[prop]); - return Number.isNaN(value) ? defaultValue : value; - }, - [selectedStatus] - ); - - const getStatusId = React.useCallback((): string => { - if (!selectedStatus) { - return '0'; - } - - return selectedStatus.Id.toString(); - }, [selectedStatus]); - - const detailLevel = getStatusProperty('Detail', 0); - const shouldShowDestinationStep = detailLevel > 0; - const destinationTabs = React.useMemo(() => getDestinationTabs(detailLevel), [detailLevel]); - const noteType = getStatusProperty('Note', 0); - const isNoteRequired = noteType === 2; - const isNoteOptional = noteType === 1; - const activeCallCandidate = React.useMemo(() => { - if (!activeCallId) { - return null; - } - - return availableCalls.find((call) => call.CallId === activeCallId) ?? null; - }, [activeCallId, availableCalls]); - - React.useEffect(() => { - if (isOpen && activeUnit) { - fetchDestinationData(activeUnit.UnitId); - } - }, [activeUnit, fetchDestinationData, isOpen]); - - React.useEffect(() => { - if (!selectedStatus) { - return; - } - - const allowsCalls = statusDetailAllowsCalls(detailLevel); - const allowsStations = statusDetailAllowsStations(detailLevel); - const allowsPois = statusDetailAllowsPois(detailLevel); - - if (!allowsCalls && selectedCall) { - setSelectedCall(null); - } - - if (!allowsStations && selectedStation) { - setSelectedStation(null); - } - - if (!allowsPois && selectedPoi) { - setSelectedPoi(null); - } - - const selectedTypeAllowed = - selectedDestinationType === 'none' || (selectedDestinationType === 'call' && allowsCalls) || (selectedDestinationType === 'station' && allowsStations) || (selectedDestinationType === 'poi' && allowsPois); - - if (!selectedTypeAllowed) { - setSelectedDestinationType('none'); - } - }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType, setSelectedPoi, setSelectedStation]); - - React.useEffect(() => { - if (!selectedStatus || selectedDestinationType !== 'none') { - return; - } - - if (selectedCall && statusDetailAllowsCalls(detailLevel)) { - setSelectedDestinationType('call'); - return; - } - - if (selectedStation && statusDetailAllowsStations(detailLevel)) { - setSelectedDestinationType('station'); - return; - } - - if (selectedPoi && statusDetailAllowsPois(detailLevel)) { - setSelectedDestinationType('poi'); - } - }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedDestinationType]); - - React.useEffect(() => { - if (!isOpen || !selectedStatus || !statusDetailAllowsCalls(detailLevel) || !activeCallCandidate) { - return; - } - - if (selectedCall || selectedStation || selectedPoi || selectedDestinationType !== 'none') { - return; - } - - setSelectedCall(activeCallCandidate); - setSelectedDestinationType('call'); - }, [activeCallCandidate, detailLevel, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]); - - React.useEffect(() => { - if (destinationTabs.length === 0) { - return; - } - - const preferredTab = getPreferredDestinationTab({ - tabs: destinationTabs, - selectedDestinationType, - hasSelectedCall: !!selectedCall, - hasSelectedStation: !!selectedStation, - hasSelectedPoi: !!selectedPoi, - }); - - if (preferredTab !== selectedTab) { - setSelectedTab(preferredTab); - } - }, [destinationTabs, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedTab]); - - const getStatusDetailDescription = React.useCallback( - (detail: number): string | null => { - switch (detail) { - case CustomStateDetailTypes.Stations: - return t('status.station_destination_enabled'); - case CustomStateDetailTypes.Calls: - return t('status.call_destination_enabled'); - case CustomStateDetailTypes.CallsAndStations: - return t('status.both_destinations_enabled'); - case CustomStateDetailTypes.Pois: - return t('status.poi_destination_enabled'); - case CustomStateDetailTypes.CallsAndPois: - return t('status.calls_and_pois_destinations_enabled'); - case CustomStateDetailTypes.StationsAndPois: - return t('status.stations_and_pois_destinations_enabled'); - case CustomStateDetailTypes.CallsStationsAndPois: - return t('status.calls_stations_pois_destinations_enabled'); - default: - return null; - } - }, - [t] - ); - - const handleClose = () => { - reset(); - }; - - const handleCallSelect = (callId: string) => { - const call = availableCalls.find((item) => item.CallId === callId); - if (!call) { - return; - } - - setSelectedCall(call); - setSelectedStation(null); - setSelectedPoi(null); - setSelectedDestinationType('call'); - }; - - const handleStationSelect = (stationId: string) => { - const station = availableStations.find((item) => item.GroupId === stationId); - if (!station) { - return; - } - - setSelectedStation(station); - setSelectedCall(null); - setSelectedPoi(null); - setSelectedDestinationType('station'); - }; - - const handlePoiSelect = (poiId: number) => { - const poi = availablePois.find((item) => item.PoiId === poiId); - if (!poi) { - return; - } - - setSelectedPoi(poi); - setSelectedCall(null); - setSelectedStation(null); - setSelectedDestinationType('poi'); - }; - - const handleNoDestinationSelect = () => { - setSelectedDestinationType('none'); - setSelectedCall(null); - setSelectedStation(null); - setSelectedPoi(null); - }; - - const handleNext = () => { - if (!canProceedFromCurrentStep()) { - return; - } - - if (currentStep === 'select-status') { - if (detailLevel > 0) { - setCurrentStep('select-destination'); - return; - } - - if (noteType === 0) { - void handleSubmit(); - } else { - setCurrentStep('add-note'); - } - - return; - } - - if (currentStep === 'select-destination') { - if (noteType === 0) { - void handleSubmit(); - } else { - setCurrentStep('add-note'); - } - } - }; - - const handlePrevious = () => { - if (currentStep === 'add-note') { - if (detailLevel > 0) { - setCurrentStep('select-destination'); - } else { - setCurrentStep('select-status'); - } - return; - } - - if (currentStep === 'select-destination') { - setCurrentStep('select-status'); - } - }; - - const handleStatusSelect = (statusId: string) => { - const status = activeStatuses?.Statuses?.find((item) => item.Id.toString() === statusId); - if (!status) { - return; - } - - setSelectedStatus(status); - }; - - const handleSubmit = React.useCallback(async () => { - if (isSubmitting || !selectedStatus || !activeUnit) { - return; - } - - try { - setIsSubmitting(true); - - const input = new SaveUnitStatusInput(); - input.Id = activeUnit.UnitId; - input.Type = getStatusId(); - input.Note = note; - input.RespondingTo = '0'; - input.RespondingToType = null; - - if (selectedDestinationType === 'call' && selectedCall) { - input.RespondingTo = selectedCall.CallId; - input.RespondingToType = DestinationEntityTypes.Call; - } else if (selectedDestinationType === 'station' && selectedStation) { - input.RespondingTo = selectedStation.GroupId; - input.RespondingToType = DestinationEntityTypes.Station; - } else if (selectedDestinationType === 'poi' && selectedPoi) { - input.RespondingTo = selectedPoi.PoiId.toString(); - input.RespondingToType = DestinationEntityTypes.Poi; - } - - if (latitude !== null && longitude !== null) { - input.Latitude = latitude.toString(); - input.Longitude = longitude.toString(); - input.Accuracy = accuracy?.toString() || '0'; - input.Altitude = altitude?.toString() || '0'; - input.AltitudeAccuracy = ''; - input.Speed = speed?.toString() || '0'; - input.Heading = heading?.toString() || '0'; - - if (timestamp) { - const locationDate = new Date(timestamp); - input.Timestamp = locationDate.toISOString(); - input.TimestampUtc = locationDate.toUTCString().replace('UTC', 'GMT'); - } - } - - input.Roles = unitRoleAssignments.map((assignment) => { - const roleInput = new SaveUnitStatusRoleInput(); - roleInput.RoleId = assignment.UnitRoleId; - roleInput.UserId = assignment.UserId; - return roleInput; - }); - - if (selectedDestinationType === 'call' && selectedCall && activeCallId !== selectedCall.CallId) { - setActiveCall(selectedCall.CallId); - } - - await saveUnitStatus(input); - showToast('success', t('status.status_saved_successfully')); - reset(); - } catch (error) { - console.error('Failed to save unit status:', error); - showToast('error', t('status.failed_to_save_status')); - } finally { - setIsSubmitting(false); - } - }, [ - accuracy, - activeCallId, - activeUnit, - altitude, - getStatusId, - heading, - isSubmitting, - latitude, - longitude, - note, - reset, - saveUnitStatus, - selectedCall, - selectedDestinationType, - selectedPoi, - selectedStation, - selectedStatus, - setActiveCall, - showToast, - speed, - t, - timestamp, - unitRoleAssignments, - ]); - - const shouldShowNoDestinationAsSelected = React.useMemo(() => { - if (selectedCall || selectedStation || selectedPoi) { - return false; - } - - const shouldPreSelectActiveCall = isOpen && !!selectedStatus && statusDetailAllowsCalls(detailLevel) && !!activeCallId && (isLoading || !!activeCallCandidate); - - if (shouldPreSelectActiveCall) { - return false; - } - - return selectedDestinationType === 'none'; - }, [activeCallCandidate, activeCallId, detailLevel, isLoading, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus]); - - const getStepTitle = () => { - switch (currentStep) { - case 'select-status': - return t('status.select_status'); - case 'select-destination': - return t('status.select_destination', { status: selectedStatus?.Text }); - case 'add-note': - return t('status.add_note'); - default: - return t('status.set_status'); - } - }; - - const getStepNumber = () => { - switch (currentStep) { - case 'select-status': - return 1; - case 'select-destination': - return cameFromStatusSelection ? 2 : 1; - case 'add-note': - if (cameFromStatusSelection) { - return shouldShowDestinationStep ? 3 : 2; - } - - return shouldShowDestinationStep ? 2 : 1; - default: - return 1; - } - }; - - const getTotalSteps = () => { - if (cameFromStatusSelection) { - let totalSteps = 1; - - if (selectedStatus) { - const hasDestinationSelection = getStatusProperty('Detail', 0) > 0; - const currentNoteType = getStatusProperty('Note', 0); - const hasNoteStep = currentNoteType > 0; - - if (hasDestinationSelection) { - totalSteps += 1; - } - - if (hasNoteStep) { - totalSteps += 1; - } - } else if (activeStatuses?.Statuses && activeStatuses.Statuses.length > 0) { - const hasAnyDestination = activeStatuses.Statuses.some((status) => Number(status.Detail) > 0); - const hasAnyNote = activeStatuses.Statuses.some((status) => Number(status.Note) > 0); - - if (hasAnyDestination) { - totalSteps += 1; - } - - if (hasAnyNote) { - totalSteps += 1; - } - } else { - totalSteps = 3; - } - - return totalSteps; - } - - let totalSteps = 0; - - if (shouldShowDestinationStep) { - totalSteps += 1; - } - - if (isNoteRequired || isNoteOptional) { - totalSteps += 1; - } - - return Math.max(totalSteps, 1); - }; - - const canProceedFromCurrentStep = () => { - if (isSubmitting) { - return false; - } - - switch (currentStep) { - case 'select-status': - return !!selectedStatus; - case 'select-destination': - return true; - case 'add-note': - return !isNoteRequired || note.trim().length > 0; - default: - return false; - } - }; - - const getSelectedDestinationDisplay = () => { - if (selectedCall) { - return `${selectedCall.Number} - ${selectedCall.Name}`; - } - - if (selectedStation) { - return selectedStation.Name; - } - - if (selectedPoi) { - return getPoiSelectionLabel(selectedPoi, poiTypesById); - } - - if (selectedDestinationType === 'call') { - if (activeCallCandidate) { - return `${activeCallCandidate.Number} - ${activeCallCandidate.Name}`; - } - - if (isLoading || (!!activeCallId && availableCalls.length === 0)) { - return t('calls.loading_calls'); - } - } - - return t('status.no_destination'); - }; - - const shouldShowDestinationTabs = destinationTabs.length > 1; - const showCalls = destinationTabs.includes('call') && (!shouldShowDestinationTabs || selectedTab === 'call'); - const showStations = destinationTabs.includes('station') && (!shouldShowDestinationTabs || selectedTab === 'station'); - const showPois = destinationTabs.includes('poi') && (!shouldShowDestinationTabs || selectedTab === 'poi'); - - return ( - - - - - - - - - - - {t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()} - - - - - {getStepTitle()} - - - {currentStep === 'select-status' ? ( - - {t('status.select_status_type')} - - - - {activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? ( - activeStatuses.Statuses.map((status) => { - const statusDetailDescription = getStatusDetailDescription(Number(status.Detail)); - const isSelected = selectedStatus?.Id.toString() === status.Id.toString(); - - return ( - handleStatusSelect(status.Id.toString())} - className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} - style={{ - backgroundColor: status.BColor || (isSelected ? '#dbeafe' : '#ffffff'), - }} - > - - - - - {status.Text} - - {Number(status.Detail) > 0 ? {statusDetailDescription} : null} - {Number(status.Note) > 0 ? {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} : null} - - - - ); - }) - ) : ( - {t('status.no_statuses_available')} - )} - - - - - - - - - ) : null} - - {currentStep === 'select-destination' && shouldShowDestinationStep ? ( - - {t('status.select_destination_type')} - - - - - - {t('status.no_destination')} - {t('status.general_status')} - - - - - {shouldShowDestinationTabs ? ( - - {destinationTabs.map((tab) => ( - setSelectedTab(tab)} className={`flex-1 rounded-lg py-3 ${selectedTab === tab ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> - {t(getDestinationTabTranslationKey(tab))} - - ))} - - ) : null} - - - {showCalls ? ( - - {isLoading ? ( - - - {t('calls.loading_calls')} - - ) : availableCalls.length > 0 ? ( - availableCalls.map((call) => ( - handleCallSelect(call.CallId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} - > - - - - - {call.Number} - {call.Name} - - {call.Address} - - - - )) - ) : ( - {t('calls.no_calls_available')} - )} - - ) : null} - - {showStations ? ( - - {isLoading ? ( - - - {t('status.loading_stations')} - - ) : availableStations.length > 0 ? ( - availableStations.map((station) => ( - handleStationSelect(station.GroupId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedStation?.GroupId === station.GroupId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} - > - - - - {station.Name} - {station.Address ? {station.Address} : null} - {station.GroupType ? {station.GroupType} : null} - - - - )) - ) : ( - {t('status.no_stations_available')} - )} - - ) : null} - - {showPois ? ( - - {isLoading ? ( - - - {t('status.loading_pois')} - - ) : availablePois.length > 0 ? ( - availablePois.map((poi) => { - const poiTypeName = poiTypesById[poi.PoiTypeId]?.Name || poi.PoiTypeName; - const poiSecondaryText = poi.Address || poi.Note || poiTypeName; - - return ( - handlePoiSelect(poi.PoiId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedPoi?.PoiId === poi.PoiId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} - > - - - - {getPoiSelectionLabel(poi, poiTypesById)} - {poiSecondaryText ? {poiSecondaryText} : null} - - - - ); - }) - ) : ( - {t('status.no_pois_available')} - )} - - ) : null} - - - - {cameFromStatusSelection ? ( - - ) : ( - - )} - - - - ) : null} - - {currentStep === 'select-destination' && !shouldShowDestinationStep ? ( - - {isNoteRequired || isNoteOptional ? ( - <> - {t('status.add_note')} - - - ) : null} - - {cameFromStatusSelection ? ( - - ) : ( - - )} - - - - ) : null} - - {currentStep === 'add-note' ? ( - - - - {t('status.selected_status')}: - - - {selectedStatus?.Text} - - - - - - {t('status.selected_destination')}: - {getSelectedDestinationDisplay()} - - - - - {t('status.note')} {isNoteRequired ? '' : `(${t('common.optional')})`}: - - - - - - - - - - - ) : null} - - - - ); -}; diff --git a/src/components/ui/alert-dialog/index.tsx b/src/components/ui/alert-dialog/index.tsx index 0474874..04d83d6 100644 --- a/src/components/ui/alert-dialog/index.tsx +++ b/src/components/ui/alert-dialog/index.tsx @@ -13,6 +13,13 @@ const AnimatedPressable = createMotionAnimatedComponent(Pressable); const SCOPE = 'ALERT_DIALOG'; +// @legendapp/motion animations don't run on web — AnimatePresence would keep the +// dialog mounted forever (and entrance animations freeze at their initial frame). +// On web, swap in a plain passthrough so open/close mount/unmount instantly. +const PassthroughPresence = ({ children }: { children?: React.ReactNode }) => <>{children}; + +const isWebPlatform = Platform.OS === 'web'; + const UIAccessibleAlertDialog = createAlertDialog({ Root: Platform.OS === 'web' ? withStyleContext(View, SCOPE) : withStyleContextAndStates(View, SCOPE), Body: ScrollView, @@ -21,7 +28,7 @@ const UIAccessibleAlertDialog = createAlertDialog({ Header: View, Footer: View, Backdrop: AnimatedPressable, - AnimatePresence: AnimatePresence, + AnimatePresence: isWebPlatform ? (PassthroughPresence as typeof AnimatePresence) : AnimatePresence, }); cssInterop(UIAccessibleAlertDialog, { className: 'style' }); @@ -106,10 +113,7 @@ const AlertDialogContent = React.forwardRef <>{children}; + const UIDrawer = createDrawer({ Root: withStyleContext(View, SCOPE), Backdrop: AnimatedPressable, @@ -27,7 +32,7 @@ const UIDrawer = createDrawer({ CloseButton: Pressable, Footer: View, Header: View, - AnimatePresence: AnimatePresence, + AnimatePresence: Platform.OS === 'web' ? (PassthroughPresence as typeof AnimatePresence) : AnimatePresence, }); // @ts-ignore - Motion component type compatibility issue @@ -152,7 +157,11 @@ const Drawer = React.forwardRef, IDrawerProps> return ; }); -const backdropInitial = { opacity: 0 }; +// @legendapp/motion does not run its enter animation on web — components stay stuck +// at their `initial` values. On web, mount directly in the visible state instead. +const isWebPlatform = Platform.OS === 'web'; + +const backdropInitial = isWebPlatform ? { opacity: 0.5 } : { opacity: 0 }; const backdropAnimate = { opacity: 0.5 }; const backdropExit = { opacity: 0 }; const backdropTransition = { @@ -189,10 +198,13 @@ const DrawerContent = React.forwardRef const isHorizontal = parentAnchor === 'left' || parentAnchor === 'right'; - const initialObj = React.useMemo( - () => (isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }), - [isHorizontal, parentAnchor, drawerWidth, drawerHeight] - ); + const initialObj = React.useMemo(() => { + // Web: skip the slide-in — motion enter animations don't run there + if (isWebPlatform) { + return isHorizontal ? { x: 0 } : { y: 0 }; + } + return isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }; + }, [isHorizontal, parentAnchor, drawerWidth, drawerHeight]); const animateObj = React.useMemo(() => (isHorizontal ? { x: 0 } : { y: 0 }), [isHorizontal]); diff --git a/src/hooks/__tests__/use-quick-check-in.test.ts b/src/hooks/__tests__/use-quick-check-in.test.ts index c616a92..069b2bf 100644 --- a/src/hooks/__tests__/use-quick-check-in.test.ts +++ b/src/hooks/__tests__/use-quick-check-in.test.ts @@ -32,14 +32,6 @@ jest.mock('@/stores/check-in-timers/store', () => ({ ), })); -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: jest.fn((selector: any) => - selector({ - activeUnit: { UnitId: '42' }, - }) - ), -})); - jest.mock('@/stores/app/location-store', () => ({ useLocationStore: jest.fn((selector: any) => selector({ @@ -67,7 +59,7 @@ describe('useQuickCheckIn', () => { jest.clearAllMocks(); }); - it('should auto-detect Unit type when active unit exists', async () => { + it('should always check in as personnel (IC app has no unit context)', async () => { mockPerformCheckIn.mockResolvedValue('success'); const { result } = renderHook(() => useQuickCheckIn(123)); @@ -79,8 +71,7 @@ describe('useQuickCheckIn', () => { expect(mockPerformCheckIn).toHaveBeenCalledWith( expect.objectContaining({ CallId: 123, - CheckInType: 1, // Unit type - UnitId: 42, + CheckInType: 0, // Personnel type Latitude: '40.7128', Longitude: '-74.006', }) diff --git a/src/hooks/__tests__/use-status-signalr-updates.test.tsx b/src/hooks/__tests__/use-status-signalr-updates.test.tsx deleted file mode 100644 index 6dbf242..0000000 --- a/src/hooks/__tests__/use-status-signalr-updates.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { getUnitStatus } from '@/api/units/unitStatuses'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useSignalRStore } from '@/stores/signalr/signalr-store'; - -import { useStatusSignalRUpdates } from '../use-status-signalr-updates'; - -// Mock the dependencies -jest.mock('@/api/units/unitStatuses'); -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/signalr/signalr-store'); - -const mockGetUnitStatus = getUnitStatus as jest.MockedFunction; -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockUseSignalRStore = useSignalRStore as jest.MockedFunction; - -describe('useStatusSignalRUpdates', () => { - const mockSetActiveUnitWithFetch = jest.fn(); - const mockCoreState = { - activeUnitId: '123', - setActiveUnitWithFetch: mockSetActiveUnitWithFetch, - } as any; - const mockSignalRState = { - lastUpdateTimestamp: 0, - lastUpdateMessage: null, - } as any; - - beforeEach(() => { - jest.clearAllMocks(); - - // Reset state to default values - mockCoreState.activeUnitId = '123'; - mockCoreState.setActiveUnitWithFetch = mockSetActiveUnitWithFetch; - mockSignalRState.lastUpdateTimestamp = 0; - mockSignalRState.lastUpdateMessage = null; - - // Mock core store with selector support - mockUseCoreStore.mockImplementation((selector) => { - if (selector) { - return selector(mockCoreState); - } - return mockCoreState; - }); - - // Mock SignalR store with selector support - mockUseSignalRStore.mockImplementation((selector) => { - if (selector) { - return selector(mockSignalRState); - } - return mockSignalRState; - }); - }); - - it('should not process updates when no active unit', () => { - mockCoreState.activeUnitId = null; - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should not process updates when timestamp is 0', () => { - mockSignalRState.lastUpdateTimestamp = 0; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should not process updates when message is null', () => { - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = null; - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should process unit status update for active unit', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - renderHook(useStatusSignalRUpdates); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - }); - - it('should not process updates for different unit', async () => { - const mockMessage = JSON.stringify({ UnitId: '456', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should handle invalid JSON message gracefully', async () => { - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = 'invalid json'; - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should not process the same timestamp twice', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - const { rerender } = renderHook(useStatusSignalRUpdates); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - - mockSetActiveUnitWithFetch.mockClear(); - - // Rerender with same timestamp - rerender({}); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should process new timestamp after initial one', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - const { rerender } = renderHook(useStatusSignalRUpdates); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - - mockSetActiveUnitWithFetch.mockClear(); - - // Update with new timestamp - mockSignalRState.lastUpdateTimestamp = 12346; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Busy' }); - - rerender({}); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - }); - - it('should handle API errors gracefully', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - mockSetActiveUnitWithFetch.mockRejectedValue(new Error('API Error')); - - renderHook(useStatusSignalRUpdates); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - - // Should not crash the hook - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledTimes(1); - }); - - it('should handle activeUnitId changes', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - const { rerender } = renderHook(useStatusSignalRUpdates); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - - mockSetActiveUnitWithFetch.mockClear(); - - // Change active unit - mockCoreState.activeUnitId = '456'; - - // Same timestamp but different unit in message - mockSignalRState.lastUpdateTimestamp = 12346; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '456', State: 'Available' }); - - rerender({}); - - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('456'); - }); - }); - - it('should handle message with no UnitId', async () => { - const mockMessage = JSON.stringify({ State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); - - it('should handle empty message object', async () => { - const mockMessage = JSON.stringify({}); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - renderHook(useStatusSignalRUpdates); - - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); - }); -}); \ No newline at end of file diff --git a/src/hooks/use-command-map-overlay.ts b/src/hooks/use-command-map-overlay.ts new file mode 100644 index 0000000..f713792 --- /dev/null +++ b/src/hooks/use-command-map-overlay.ts @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; + +import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useCommandStore } from '@/stores/command/store'; + +/** Command-board context for one map marker (unit or personnel on the active board). */ +export interface CommandMarkerInfo { + callId: string; + assignmentId: string; + /** '' = unassigned pool. */ + nodeId: string; + laneName: string; + laneColor?: string | null; + resourceKind: ResourceAssignmentKind; + resourceId: string; +} + +const isUnitKind = (kind: number) => kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit; +const isPersonnelKind = (kind: number) => kind === ResourceAssignmentKind.RealPersonnel || kind === ResourceAssignmentKind.LinkedDeptPersonnel; + +/** + * Map marker id → active-command-board context, keyed by the GetMapDataAndMarkers + * marker id convention (`u{unitId}` units, `p{userId}` personnel). + */ +export const useCommandMapOverlay = (): Record => { + const boards = useCommandStore((state) => state.boards); + const activeCallId = useCommandStore((state) => state.activeCallId); + + return useMemo(() => { + const entry = activeCallId ? boards[activeCallId] : undefined; + if (!entry?.board) { + return {}; + } + + const nodesById = new Map(entry.board.Nodes.filter((n) => !n.DeletedOn).map((n) => [n.CommandStructureNodeId, n])); + const overlay: Record = {}; + + for (const assignment of entry.board.Assignments) { + if (assignment.ReleasedOn) { + continue; + } + const markerId = isUnitKind(assignment.ResourceKind) ? `u${assignment.ResourceId}` : isPersonnelKind(assignment.ResourceKind) ? `p${assignment.ResourceId}` : null; + if (!markerId) { + continue; + } + const node = assignment.CommandStructureNodeId ? nodesById.get(assignment.CommandStructureNodeId) : undefined; + overlay[markerId] = { + callId: entry.callId, + assignmentId: assignment.ResourceAssignmentId, + nodeId: assignment.CommandStructureNodeId ?? '', + laneName: node?.Name ?? '', + laneColor: node?.Color, + resourceKind: assignment.ResourceKind, + resourceId: assignment.ResourceId, + }; + } + + return overlay; + }, [boards, activeCallId]); +}; diff --git a/src/hooks/use-map-geolocation-updates.ts b/src/hooks/use-map-geolocation-updates.ts new file mode 100644 index 0000000..4da5918 --- /dev/null +++ b/src/hooks/use-map-geolocation-updates.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; + +import { logger } from '@/lib/logging'; +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +/** + * Live position deltas from the GeolocationHub. + * The server pushes minimal payloads (id + lat/lon) for units and personnel; + * merge them into the marker set in place — no refetch needed. + * + * Marker id convention from GetMapDataAndMarkers: `u{unitId}` units, `p{userId}` personnel. + */ +export const useMapGeolocationUpdates = (onMarkersUpdate: (updater: (prev: MapMakerInfoData[]) => MapMakerInfoData[]) => void) => { + const lastProcessedTimestamp = useRef(0); + + const lastGeolocationTimestamp = useSignalRStore((state) => state.lastGeolocationTimestamp); + const lastGeolocationMessage = useSignalRStore((state) => state.lastGeolocationMessage); + + useEffect(() => { + if (!lastGeolocationTimestamp || lastGeolocationTimestamp === lastProcessedTimestamp.current || !lastGeolocationMessage) { + return; + } + lastProcessedTimestamp.current = lastGeolocationTimestamp; + + try { + const payload = JSON.parse(String(lastGeolocationMessage)) as { UnitId?: string | number; UserId?: string; Latitude?: number | string; Longitude?: number | string }; + + const latitude = Number(payload.Latitude); + const longitude = Number(payload.Longitude); + if (!isFinite(latitude) || !isFinite(longitude)) { + return; + } + + const markerId = payload.UnitId != null ? `u${payload.UnitId}` : payload.UserId ? `p${payload.UserId}` : null; + if (!markerId) { + return; + } + + onMarkersUpdate((prev) => { + const index = prev.findIndex((m) => m.Id === markerId); + if (index === -1) { + // Marker not on the board yet (filtered or new) — next snapshot refresh picks it up + return prev; + } + const next = [...prev]; + next[index] = { ...next[index], Latitude: latitude, Longitude: longitude }; + return next; + }); + } catch (error) { + logger.warn({ + message: 'Failed to apply geolocation delta to map markers', + context: { error }, + }); + } + }, [lastGeolocationTimestamp, lastGeolocationMessage, onMarkersUpdate]); +}; diff --git a/src/hooks/use-quick-check-in.ts b/src/hooks/use-quick-check-in.ts index b8df7c4..4b65bb0 100644 --- a/src/hooks/use-quick-check-in.ts +++ b/src/hooks/use-quick-check-in.ts @@ -2,21 +2,18 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import type { PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; -import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import type { CheckInResult } from '@/stores/check-in-timers/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { useToastStore } from '@/stores/toast/store'; -// Check-in types +// IC users always check in as personnel — there is no unit context in this app. const CHECK_IN_TYPE_PERSONNEL = 0; -const CHECK_IN_TYPE_UNIT = 1; export function useQuickCheckIn(callId: number) { const { t } = useTranslation(); const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn); const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn); - const activeUnit = useCoreStore((state) => state.activeUnit); const latitude = useLocationStore((state) => state.latitude); const longitude = useLocationStore((state) => state.longitude); const showToast = useToastStore((state) => state.showToast); @@ -24,8 +21,7 @@ export function useQuickCheckIn(callId: number) { const quickCheckIn = useCallback(async () => { const input: PerformCheckInInput = { CallId: callId, - CheckInType: activeUnit ? CHECK_IN_TYPE_UNIT : CHECK_IN_TYPE_PERSONNEL, - UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, + CheckInType: CHECK_IN_TYPE_PERSONNEL, Latitude: latitude?.toString(), Longitude: longitude?.toString(), }; @@ -41,7 +37,7 @@ export function useQuickCheckIn(callId: number) { } return result; - }, [callId, activeUnit, latitude, longitude, performCheckInAction, showToast, t]); + }, [callId, latitude, longitude, performCheckInAction, showToast, t]); return { quickCheckIn, isCheckingIn }; } diff --git a/src/hooks/use-status-signalr-updates.ts b/src/hooks/use-status-signalr-updates.ts deleted file mode 100644 index 6893a4c..0000000 --- a/src/hooks/use-status-signalr-updates.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { logger } from '@/lib/logging'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useSignalRStore } from '@/stores/signalr/signalr-store'; - -export const useStatusSignalRUpdates = () => { - const lastProcessedTimestamp = useRef(0); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const setActiveUnitWithFetch = useCoreStore((state) => state.setActiveUnitWithFetch); - - const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp); - const lastUpdateMessage = useSignalRStore((state) => state.lastUpdateMessage); - - useEffect(() => { - const handleStatusUpdate = async () => { - try { - if (!activeUnitId) { - logger.info({ - message: 'No active unit, skipping status update', - }); - return; - } - - // Parse the SignalR message to check if it's a unit status update - if (lastUpdateMessage && typeof lastUpdateMessage === 'string') { - try { - const parsedMessage = JSON.parse(lastUpdateMessage); - - // Check if this is a unit status update message - if (parsedMessage && parsedMessage.UnitId === activeUnitId) { - logger.info({ - message: 'Processing unit status update for active unit', - context: { - unitId: activeUnitId, - timestamp: lastUpdateTimestamp, - message: parsedMessage, - }, - }); - - // Refresh the active unit status - await setActiveUnitWithFetch(activeUnitId); - - // Update the last processed timestamp - lastProcessedTimestamp.current = lastUpdateTimestamp; - } - } catch (parseError) { - logger.error({ - message: 'Failed to parse SignalR message', - context: { error: parseError, message: lastUpdateMessage }, - }); - } - } - } catch (error) { - logger.error({ - message: 'Failed to process unit status update', - context: { error }, - }); - } - }; - - if (lastUpdateTimestamp > 0 && lastUpdateTimestamp !== lastProcessedTimestamp.current && activeUnitId) { - handleStatusUpdate(); - } - }, [lastUpdateTimestamp, lastUpdateMessage, activeUnitId, setActiveUnitWithFetch]); -}; diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index e669035..69cb746 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -49,13 +49,29 @@ export class CacheManager { const cacheItem: CacheItem = JSON.parse(cached); if (this.isExpired(cacheItem.timestamp, cacheItem.expiresIn)) { - storage.delete(key); + // Keep the entry on disk — getStale() serves it as an offline fallback. return null; } return cacheItem.data; } + /** + * Returns the cached value even when its TTL has expired. + * Used as a fallback when the device is offline or a request fails. + */ + getStale(endpoint: string, params?: Record): T | null { + const key = this.getCacheKey(endpoint, params); + const cached = storage.getString(key); + + if (!cached) { + return null; + } + + const cacheItem: CacheItem = JSON.parse(cached); + return cacheItem.data; + } + remove(endpoint: string, params?: Record): void { const key = this.getCacheKey(endpoint, params); storage.delete(key); diff --git a/src/lib/incident-command-utils.ts b/src/lib/incident-command-utils.ts new file mode 100644 index 0000000..a334990 --- /dev/null +++ b/src/lib/incident-command-utils.ts @@ -0,0 +1,136 @@ +import { type TFunction } from 'i18next'; + +import { CommandNodeType, IncidentRoleType, TacticalObjectiveType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** i18n keys for the roles offered as quick presets in the assignment sheet. */ +export const ICS_ROLE_I18N_KEYS: Partial> = { + [IncidentRoleType.IncidentCommander]: 'command.role_ic', + [IncidentRoleType.OperationsSectionChief]: 'command.role_operations', + [IncidentRoleType.PlanningSectionChief]: 'command.role_planning', + [IncidentRoleType.LogisticsSectionChief]: 'command.role_logistics', + [IncidentRoleType.FinanceAdminSectionChief]: 'command.role_finance', + [IncidentRoleType.SafetyOfficer]: 'command.role_safety', + [IncidentRoleType.LiaisonOfficer]: 'command.role_liaison', + [IncidentRoleType.PublicInformationOfficer]: 'command.role_pio', + [IncidentRoleType.StagingAreaManager]: 'command.role_staging', + [IncidentRoleType.TriageOfficer]: 'command.role_triage', + [IncidentRoleType.TransportOfficer]: 'command.role_transport', + [IncidentRoleType.RehabOfficer]: 'command.role_rehab', +}; + +/** Standard NIMS/ICS position titles — fallback labels for roles without a translation key. */ +const ICS_ROLE_DEFAULT_NAMES: Record = { + [IncidentRoleType.IncidentCommander]: 'Incident Commander', + [IncidentRoleType.DeputyIncidentCommander]: 'Deputy Incident Commander', + [IncidentRoleType.UnifiedCommandMember]: 'Unified Command Member', + [IncidentRoleType.OperationsSectionChief]: 'Operations Section Chief', + [IncidentRoleType.PlanningSectionChief]: 'Planning Section Chief', + [IncidentRoleType.LogisticsSectionChief]: 'Logistics Section Chief', + [IncidentRoleType.FinanceAdminSectionChief]: 'Finance/Admin Section Chief', + [IncidentRoleType.SafetyOfficer]: 'Safety Officer', + [IncidentRoleType.LiaisonOfficer]: 'Liaison Officer', + [IncidentRoleType.PublicInformationOfficer]: 'Public Information Officer', + [IncidentRoleType.StagingAreaManager]: 'Staging Area Manager', + [IncidentRoleType.ResourcesUnitLeader]: 'Resources Unit Leader', + [IncidentRoleType.SituationUnitLeader]: 'Situation Unit Leader', + [IncidentRoleType.DocumentationUnitLeader]: 'Documentation Unit Leader', + [IncidentRoleType.CommunicationsUnitLeader]: 'Communications Unit Leader', + [IncidentRoleType.DivisionGroupSupervisor]: 'Division/Group Supervisor', + [IncidentRoleType.BranchDirector]: 'Branch Director', + [IncidentRoleType.StrikeTeamTaskForceLeader]: 'Strike Team/Task Force Leader', + [IncidentRoleType.MedicalUnitLeader]: 'Medical Unit Leader', + [IncidentRoleType.RehabOfficer]: 'Rehab Officer', + [IncidentRoleType.MedicalBranchDirector]: 'Medical Branch Director', + [IncidentRoleType.TriageOfficer]: 'Triage Officer', + [IncidentRoleType.TreatmentOfficer]: 'Treatment Officer', + [IncidentRoleType.TransportOfficer]: 'Transport Officer', + [IncidentRoleType.HazMatGroupSupervisor]: 'HazMat Group Supervisor', + [IncidentRoleType.DeconOfficer]: 'Decon Officer', + [IncidentRoleType.EntryTeamLeader]: 'Entry Team Leader', + [IncidentRoleType.SearchGroupSupervisor]: 'Search Group Supervisor', + [IncidentRoleType.AirOperationsBranchDirector]: 'Air Operations Branch Director', + [IncidentRoleType.ShelterMassCareCoordinator]: 'Shelter/Mass Care Coordinator', + [IncidentRoleType.DamageAssessmentLead]: 'Damage Assessment Lead', +}; + +/** Roles offered as quick preset chips when assigning (core command + common field positions). */ +export const ICS_ROLE_PRESETS: IncidentRoleType[] = [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.PlanningSectionChief, + IncidentRoleType.LogisticsSectionChief, + IncidentRoleType.FinanceAdminSectionChief, + IncidentRoleType.PublicInformationOfficer, + IncidentRoleType.LiaisonOfficer, + IncidentRoleType.StagingAreaManager, + IncidentRoleType.TriageOfficer, + IncidentRoleType.TransportOfficer, + IncidentRoleType.RehabOfficer, +]; + +/** Resolve the display name for an ICS role — translated when a key exists, standard NIMS title otherwise. */ +export const getIncidentRoleName = (t: TFunction, roleType: IncidentRoleType): string => { + const key = ICS_ROLE_I18N_KEYS[roleType]; + if (key) { + return t(key); + } + return ICS_ROLE_DEFAULT_NAMES[roleType] ?? `Role ${roleType}`; +}; + +/** i18n keys for ICS structural lane types. */ +const NODE_TYPE_I18N_KEYS: Record = { + [CommandNodeType.Division]: 'command.node_division', + [CommandNodeType.Group]: 'command.node_group', + [CommandNodeType.Branch]: 'command.node_branch', + [CommandNodeType.Sector]: 'command.node_sector', + [CommandNodeType.StrikeTeam]: 'command.node_strike_team', + [CommandNodeType.TaskForce]: 'command.node_task_force', + [CommandNodeType.Staging]: 'command.node_staging', + [CommandNodeType.UnifiedCommand]: 'command.node_unified_command', +}; + +/** All lane types offered when adding a lane. */ +export const COMMAND_NODE_TYPES: CommandNodeType[] = [ + CommandNodeType.Division, + CommandNodeType.Group, + CommandNodeType.Branch, + CommandNodeType.Sector, + CommandNodeType.StrikeTeam, + CommandNodeType.TaskForce, + CommandNodeType.Staging, + CommandNodeType.UnifiedCommand, +]; + +export const getCommandNodeTypeName = (t: TFunction, nodeType: CommandNodeType): string => { + const key = NODE_TYPE_I18N_KEYS[nodeType]; + return key ? t(key) : `Node ${nodeType}`; +}; + +/** i18n keys for tactical objective types. */ +const OBJECTIVE_TYPE_I18N_KEYS: Record = { + [TacticalObjectiveType.General]: 'command.objective_general', + [TacticalObjectiveType.Benchmark]: 'command.objective_benchmark', + [TacticalObjectiveType.Safety]: 'command.objective_safety', +}; + +export const OBJECTIVE_TYPES: TacticalObjectiveType[] = [TacticalObjectiveType.General, TacticalObjectiveType.Benchmark, TacticalObjectiveType.Safety]; + +export const getObjectiveTypeName = (t: TFunction, objectiveType: TacticalObjectiveType): string => { + const key = OBJECTIVE_TYPE_I18N_KEYS[objectiveType]; + return key ? t(key) : `Objective ${objectiveType}`; +}; + +/** Badge action color for a PAR/accountability status from the server. */ +export const getParBadgeAction = (status: string): 'success' | 'warning' | 'error' | 'muted' => { + switch (status) { + case 'Green': + return 'success'; + case 'Warning': + return 'warning'; + case 'Critical': + return 'error'; + default: + return 'muted'; + } +}; diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx index 30c3042..30d8051 100644 --- a/src/lib/storage/app.tsx +++ b/src/lib/storage/app.tsx @@ -3,7 +3,6 @@ import { Env } from '@env'; import { getItem, removeItem, setItem } from '@/lib/storage'; const BASE_URL = 'baseUrl'; -const ACTIVE_UNIT_ID = 'activeUnitId'; const ACTIVE_CALL_ID = 'activeCallId'; const DEVICE_UUID = 'unitDeviceUuid'; @@ -18,17 +17,6 @@ export const getBaseApiUrl = () => { return baseUrl; }; -export const removeActiveUnitId = () => removeItem(ACTIVE_UNIT_ID); -export const setActiveUnitId = (value: string) => setItem(ACTIVE_UNIT_ID, value); - -export const getActiveUnitId = () => { - const activeUnitId = getItem(ACTIVE_UNIT_ID); - if (!activeUnitId) { - return activeUnitId; - } - return ''; -}; - export const removeActiveCallId = () => removeItem(ACTIVE_CALL_ID); export const setActiveCallId = (value: string) => setItem(ACTIVE_CALL_ID, value); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6633492..8aa4cb9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -124,6 +124,19 @@ export function getMinutesBetweenDates(startDate: Date, endDate: Date): number { return diff / 60000; } +/** + * Epoch ms from a server timestamp. Core sends UTC datetimes without a timezone + * suffix — bare strings are treated as UTC instead of device-local time. + */ +export function parseUtcMs(value?: string | null): number | null { + if (!value) { + return null; + } + const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value); + const ms = Date.parse(hasZone ? value : `${value}Z`); + return Number.isNaN(ms) ? null : ms; +} + export function parseDateISOString(s: string): Date { const b = s.split(/\D/); return new Date(parseInt(b[0], 10), parseInt(b[1], 10) - 1, parseInt(b[2], 10), parseInt(b[3], 10), parseInt(b[4], 10), parseInt(b[5], 10)); diff --git a/src/models/offline-queue/queued-event.ts b/src/models/offline-queue/queued-event.ts index 9ad90af..ab58e85 100644 --- a/src/models/offline-queue/queued-event.ts +++ b/src/models/offline-queue/queued-event.ts @@ -3,6 +3,22 @@ export enum QueuedEventType { LOCATION_UPDATE = 'location_update', CALL_IMAGE_UPLOAD = 'call_image_upload', CHECK_IN = 'check_in', + // Incident command (ICS) events — replayed against the Core IncidentCommand APIs + ESTABLISH_COMMAND = 'establish_command', + CLOSE_COMMAND = 'close_command', + ASSIGN_INCIDENT_ROLE = 'assign_incident_role', + REMOVE_INCIDENT_ROLE = 'remove_incident_role', + CREATE_ADHOC_UNIT = 'create_adhoc_unit', + RELEASE_ADHOC_UNIT = 'release_adhoc_unit', + CREATE_ADHOC_PERSONNEL = 'create_adhoc_personnel', + RELEASE_ADHOC_PERSONNEL = 'release_adhoc_personnel', + SAVE_COMMAND_NODE = 'save_command_node', + DELETE_COMMAND_NODE = 'delete_command_node', + ASSIGN_COMMAND_RESOURCE = 'assign_command_resource', + MOVE_COMMAND_RESOURCE = 'move_command_resource', + RELEASE_COMMAND_RESOURCE = 'release_command_resource', + SAVE_OBJECTIVE = 'save_objective', + COMPLETE_OBJECTIVE = 'complete_objective', } export enum QueuedEventStatus { @@ -87,3 +103,84 @@ export interface QueuedCheckInEvent extends Omit { timestamp: string; }; } + +export interface QueuedEstablishCommandEvent extends Omit { + type: QueuedEventType.ESTABLISH_COMMAND; + data: { callId: string; commandDefinitionId?: number | null }; +} + +export interface QueuedCloseCommandEvent extends Omit { + type: QueuedEventType.CLOSE_COMMAND; + data: { callId: string; incidentCommandId: string }; +} + +export interface QueuedAssignIncidentRoleEvent extends Omit { + type: QueuedEventType.ASSIGN_INCIDENT_ROLE; + data: { callId: string; roleType: number; userId: string }; +} + +export interface QueuedRemoveIncidentRoleEvent extends Omit { + type: QueuedEventType.REMOVE_INCIDENT_ROLE; + data: { callId: string; incidentRoleAssignmentId: string }; +} + +export interface QueuedCreateAdHocUnitEvent extends Omit { + type: QueuedEventType.CREATE_ADHOC_UNIT; + data: { callId: string; name: string; type?: string }; +} + +export interface QueuedReleaseAdHocUnitEvent extends Omit { + type: QueuedEventType.RELEASE_ADHOC_UNIT; + data: { callId: string; incidentAdHocUnitId: string }; +} + +export interface QueuedCreateAdHocPersonnelEvent extends Omit { + type: QueuedEventType.CREATE_ADHOC_PERSONNEL; + data: { callId: string; name: string; role?: string; agency?: string }; +} + +export interface QueuedReleaseAdHocPersonnelEvent extends Omit { + type: QueuedEventType.RELEASE_ADHOC_PERSONNEL; + data: { callId: string; incidentAdHocPersonnelId: string }; +} + +export interface QueuedSaveCommandNodeEvent extends Omit { + type: QueuedEventType.SAVE_COMMAND_NODE; + data: { + callId: string; + name: string; + nodeType: number; + color?: string; + limits?: { minUnits?: number; maxUnits?: number; minUnitPersonnel?: number; maxUnitPersonnel?: number; minTimeInRole?: number; maxTimeInRole?: number }; + }; +} + +export interface QueuedDeleteCommandNodeEvent extends Omit { + type: QueuedEventType.DELETE_COMMAND_NODE; + data: { callId: string; commandStructureNodeId: string }; +} + +export interface QueuedAssignCommandResourceEvent extends Omit { + type: QueuedEventType.ASSIGN_COMMAND_RESOURCE; + data: { callId: string; commandStructureNodeId: string; resourceKind: number; resourceId: string }; +} + +export interface QueuedMoveCommandResourceEvent extends Omit { + type: QueuedEventType.MOVE_COMMAND_RESOURCE; + data: { callId: string; resourceAssignmentId: string; targetNodeId: string }; +} + +export interface QueuedReleaseCommandResourceEvent extends Omit { + type: QueuedEventType.RELEASE_COMMAND_RESOURCE; + data: { callId: string; resourceAssignmentId: string }; +} + +export interface QueuedSaveObjectiveEvent extends Omit { + type: QueuedEventType.SAVE_OBJECTIVE; + data: { callId: string; name: string; objectiveType: number }; +} + +export interface QueuedCompleteObjectiveEvent extends Omit { + type: QueuedEventType.COMPLETE_OBJECTIVE; + data: { callId: string; tacticalObjectiveId: string }; +} diff --git a/src/models/v4/device/pushRegistrationUnitInput.ts b/src/models/v4/device/pushRegistrationInput.ts similarity index 63% rename from src/models/v4/device/pushRegistrationUnitInput.ts rename to src/models/v4/device/pushRegistrationInput.ts index ca077d5..67d5eab 100644 --- a/src/models/v4/device/pushRegistrationUnitInput.ts +++ b/src/models/v4/device/pushRegistrationInput.ts @@ -1,5 +1,5 @@ -export class PushRegistrationUnitInput { - public UnitId: string = ''; +export class PushRegistrationInput { + public UserId: string = ''; public Token: string = ''; public Platform: number = 0; public DeviceUuid: string = ''; diff --git a/src/models/v4/incidentCommand/incidentCommandModels.ts b/src/models/v4/incidentCommand/incidentCommandModels.ts new file mode 100644 index 0000000..c338ca7 --- /dev/null +++ b/src/models/v4/incidentCommand/incidentCommandModels.ts @@ -0,0 +1,405 @@ +/** + * Models for the Resgrid Core Incident Command (ICS) v4 API. + * Mirrors Core/Resgrid.Model/IncidentCommand/* and + * Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs. + */ + +/** Functional incident-command positions (NIMS/ICS) — mirrors Core IncidentRoleType. */ +export enum IncidentRoleType { + IncidentCommander = 0, + DeputyIncidentCommander = 1, + UnifiedCommandMember = 2, + OperationsSectionChief = 3, + PlanningSectionChief = 4, + LogisticsSectionChief = 5, + FinanceAdminSectionChief = 6, + SafetyOfficer = 7, + LiaisonOfficer = 8, + PublicInformationOfficer = 9, + StagingAreaManager = 10, + ResourcesUnitLeader = 11, + SituationUnitLeader = 12, + DocumentationUnitLeader = 13, + CommunicationsUnitLeader = 14, + DivisionGroupSupervisor = 15, + BranchDirector = 16, + StrikeTeamTaskForceLeader = 17, + MedicalUnitLeader = 18, + RehabOfficer = 19, + MedicalBranchDirector = 20, + TriageOfficer = 21, + TreatmentOfficer = 22, + TransportOfficer = 23, + HazMatGroupSupervisor = 24, + DeconOfficer = 25, + EntryTeamLeader = 26, + SearchGroupSupervisor = 27, + AirOperationsBranchDirector = 28, + ShelterMassCareCoordinator = 29, + DamageAssessmentLead = 30, +} + +/** ICS structural node types (the "lanes" on the command board) — mirrors Core CommandNodeType. */ +export enum CommandNodeType { + Division = 0, + Group = 1, + Branch = 2, + Sector = 3, + StrikeTeam = 4, + TaskForce = 5, + Staging = 6, + UnifiedCommand = 7, +} + +/** What kind of resource a ResourceAssignment points at — mirrors Core ResourceAssignmentKind. */ +export enum ResourceAssignmentKind { + RealUnit = 0, + RealPersonnel = 1, + LinkedDeptUnit = 2, + LinkedDeptPersonnel = 3, + AdHocUnit = 4, + AdHocPersonnel = 5, +} + +/** Classification of a tactical objective — mirrors Core TacticalObjectiveType. */ +export enum TacticalObjectiveType { + General = 0, + Benchmark = 1, + Safety = 2, +} + +/** Completion state of a tactical objective — mirrors Core TacticalObjectiveStatus. */ +export enum TacticalObjectiveStatus { + Pending = 0, + Complete = 1, +} + +export interface IncidentCommand { + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + SourceCommandDefinitionId?: number | null; + EstablishedByUserId: string; + EstablishedOn: string; + CurrentCommanderUserId: string; + CommandPostLatitude?: string | null; + CommandPostLongitude?: string | null; + IncidentActionPlan?: string | null; + IcsLevel: number; + Status: number; + ClosedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface CommandStructureNode { + CommandStructureNodeId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + NodeType: number; + Name: string; + /** Lane display color (hex); resources in the lane inherit it on maps. */ + Color?: string | null; + /** Minimum personnel riding a unit for it to fill this lane (0 = none). */ + MinUnitPersonnel?: number; + /** Maximum personnel riding a unit for it to fill this lane (0 = none). */ + MaxUnitPersonnel?: number; + /** Minimum units this lane wants filled (advisory under-filled indicator). */ + MinUnits?: number; + /** Maximum units in this lane at once (0 = unlimited). */ + MaxUnits?: number; + /** Minimum minutes a resource should stay before rotating out (advisory). */ + MinTimeInRole?: number; + /** Maximum minutes before a resource is rotation-due in this lane (0 = none). */ + MaxTimeInRole?: number; + /** When true, unmet lane requirements block assignment instead of warning. */ + ForceRequirements?: boolean; + ParentNodeId?: string | null; + SupervisorUserId?: string | null; + SupervisorUnitId?: number | null; + SortOrder: number; + SourceRoleId?: number | null; + DeletedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface ResourceAssignment { + ResourceAssignmentId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + CommandStructureNodeId: string; + ResourceKind: number; + ResourceId: string; + AssignedByUserId: string; + AssignedOn: string; + ReleasedOn?: string | null; + RequirementsWarning: boolean; + RequirementsWarningMessage?: string | null; + ModifiedOn?: string | null; +} + +export interface TacticalObjective { + TacticalObjectiveId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + Name: string; + ObjectiveType: number; + Status: number; + AutoPopulated: boolean; + CompletedByUserId?: string | null; + CompletedOn?: string | null; + SortOrder: number; + ModifiedOn?: string | null; +} + +export interface IncidentTimer { + IncidentTimerId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + TimerType: number; + ScopeType: number; + ScopeId?: string | null; + Name: string; + IntervalSeconds: number; + StartedOn: string; + NextDueOn?: string | null; + Status: number; + AcknowledgedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface IncidentMapAnnotation { + IncidentMapAnnotationId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + AnnotationType: number; + GeoJson: string; + IcsSymbolCode?: string | null; + Label?: string | null; + CreatedByUserId: string; + CreatedOn: string; + DeletedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface IncidentRoleAssignment { + IncidentRoleAssignmentId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + UserId: string; + RoleType: IncidentRoleType; + ScopeNodeId?: string | null; + AssignedByUserId: string; + AssignedOn: string; + RemovedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface IncidentAdHocUnit { + IncidentAdHocUnitId: string; + DepartmentId: number; + CallId: number; + Name: string; + UnitTypeId?: number | null; + Type?: string | null; + ExternalAgencyName?: string | null; + CreatedByUserId: string; + CreatedOn: string; + ReleasedOn?: string | null; + ModifiedOn?: string | null; +} + +export interface IncidentAdHocPersonnel { + IncidentAdHocPersonnelId: string; + DepartmentId: number; + CallId: number; + Name: string; + Role?: string | null; + ExternalAgencyName?: string | null; + Contact?: string | null; + RidingResourceKind: number; + RidingResourceId?: string | null; + CreatedByUserId: string; + CreatedOn: string; + ReleasedOn?: string | null; + ModifiedOn?: string | null; +} + +/** PAR / accountability row — computed server-side from check-in timers. */ +export interface PersonnelCallCheckInStatus { + UserId: string; + FullName: string; + LastCheckIn?: string | null; + NeedsCheckIn: boolean; + MinutesRemaining: number; + Status: 'Green' | 'Warning' | 'Critical' | string; + DurationMinutes: number; + WarningThresholdMinutes: number; +} + +export interface CommandLogEntry { + CommandLogEntryId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + EntryType: number; + Description: string; + UserId?: string | null; + Latitude?: string | null; + Longitude?: string | null; + OccurredOn: string; +} + +export interface CommandTransfer { + CommandTransferId: string; + IncidentCommandId: string; + DepartmentId: number; + CallId: number; + FromUserId: string; + ToUserId: string; + TransferredOn: string; + Notes?: string | null; +} + +/** Full render-ready board — response of GetCommandBoard and Sync Bundle entries. */ +export interface IncidentCommandBoard { + Command: IncidentCommand; + Nodes: CommandStructureNode[]; + Assignments: ResourceAssignment[]; + Objectives: TacticalObjective[]; + Timers: IncidentTimer[]; + Annotations: IncidentMapAnnotation[]; + Accountability: PersonnelCallCheckInStatus[]; + Roles: IncidentRoleAssignment[]; +} + +// ---- API result envelopes (mirror the standard v4 result shape) ---- + +interface V4Result { + Data: T; + Status?: string; + Message?: string; +} + +export type IncidentCommandResult = V4Result; +export type IncidentCommandBoardResult = V4Result; +export type CommandTransferResult = V4Result; +export type CommandAccountabilityResult = V4Result; +export type EvaluateAccountabilityResult = V4Result; +export type CommandNodeResult = V4Result; +export type ResourceAssignmentResult = V4Result & { Message?: string }; +export type TacticalObjectiveResult = V4Result; +export type IncidentTimerResult = V4Result; +export type IncidentMapAnnotationResult = V4Result; +export type CommandTimelineResult = V4Result; +export type IncidentCommandActionResult = V4Result; +export type IncidentRoleResult = V4Result; +export type IncidentRolesResult = V4Result; +export type IncidentCapabilitiesResult = V4Result<{ Value: number; Capabilities: string[] }>; +export type AdHocUnitResult = V4Result; +export type AdHocUnitsResult = V4Result; +export type AdHocPersonnelResult = V4Result; +export type AdHocPersonnelListResult = V4Result; + +// ---- Request inputs ---- + +export interface EstablishCommandInput { + CallId: number; + CommandDefinitionId?: number | null; +} + +export interface TransferCommandInput { + IncidentCommandId: string; + ToUserId: string; + Notes?: string; +} + +export interface UpdateActionPlanInput { + IncidentCommandId: string; + ActionPlan: string; +} + +export interface MoveResourceInput { + ResourceAssignmentId: string; + TargetNodeId: string; +} + +// ---- Sync models (SyncController) ---- + +export interface IncidentCommandChanges { + ServerTimestampMs: number; + Commands: IncidentCommand[]; + Nodes: CommandStructureNode[]; + Assignments: ResourceAssignment[]; + Objectives: TacticalObjective[]; + Timers: IncidentTimer[]; + Annotations: IncidentMapAnnotation[]; + Roles: IncidentRoleAssignment[]; + AdHocUnits: IncidentAdHocUnit[]; + AdHocPersonnel: IncidentAdHocPersonnel[]; + TimelineEntries: CommandLogEntry[]; +} + +export interface IncidentCommandBundle { + ServerTimestampMs: number; + Boards: IncidentCommandBoard[]; + AdHocUnits: IncidentAdHocUnit[]; + AdHocPersonnel: IncidentAdHocPersonnel[]; +} + +export type SyncChangesResult = V4Result; +export type SyncBundleResult = V4Result; +export type SyncReferenceResult = V4Result>; + +// ---- Incident voice (PTT) ---- + +/** On-demand tactical voice channel scoped to a call (Resgrid PTT addon). */ +export interface IncidentVoiceChannel { + DepartmentVoiceChannelId: string; + DepartmentId: number; + CallId?: number | null; + Name: string; + IsOnDemand: boolean; + ClosedOn?: string | null; +} + +/** One PTT transmission: who keyed up, on which channel, start/end. */ +export interface VoiceTransmissionLog { + VoiceTransmissionLogId: string; + DepartmentId: number; + CallId: number; + DepartmentVoiceChannelId: string; + UserId: string; + StartedOn: string; + EndedOn?: string | null; +} + +export type IncidentVoiceChannelResult = V4Result; +export type IncidentVoiceChannelsResult = V4Result; +export type VoiceTransmissionLogResult = V4Result; +export type VoiceTransmissionLogsResult = V4Result; + +// ---- Incident reporting ---- + +/** NFIRS/NERIS-oriented key-times report for federal/NFPA reporting. */ +export interface IncidentTimesReport { + CallId: number; + AlarmOn?: string | null; + CommandEstablishedOn?: string | null; + FirstResourceAssignedOn?: string | null; + FirstBenchmarkCompletedOn?: string | null; + LastBenchmarkCompletedOn?: string | null; + CommandClosedOn?: string | null; + DurationMinutes: number; + UnitResourceCount: number; + PersonnelResourceCount: number; + MutualAidResourceCount: number; + Benchmarks: { Name: string; CompletedOn?: string | null; MinutesFromAlarm?: number | null }[]; +} + +export type IncidentTimesReportResult = V4Result; diff --git a/src/models/v4/mapping/mappingResults.ts b/src/models/v4/mapping/mappingResults.ts index 4a2f9af..18e03e9 100644 --- a/src/models/v4/mapping/mappingResults.ts +++ b/src/models/v4/mapping/mappingResults.ts @@ -32,14 +32,16 @@ export class GetGeoJSONResult extends BaseV4Request { public Data: FeatureCollection = { type: 'FeatureCollection', features: [] }; } +/** Mirrors Core ActiveLayerResultData (MappingController.GetAllActiveLayers). */ export interface ActiveLayerSummary { - LayerId: string; + Id: string; Name: string; - Type: string; + /** 'maplayer' (vector GeoJSON layer) or 'custommaplayer' (custom-map layer regions). */ + LayerSource: 'maplayer' | 'custommaplayer' | string; + Type: number; Color: string; + IsSearchable: boolean; IsOnByDefault: boolean; - Source: 'indoor' | 'custom'; - MapId: string; } export class GetAllActiveLayersResult extends BaseV4Request { diff --git a/src/services/__tests__/app-initialization.service.test.ts b/src/services/__tests__/app-initialization.service.test.ts index 375b933..6d8dae0 100644 --- a/src/services/__tests__/app-initialization.service.test.ts +++ b/src/services/__tests__/app-initialization.service.test.ts @@ -23,6 +23,13 @@ jest.mock('../callkeep.service.ios', () => ({ })); // Mock Push Notification service +jest.mock('../offline-event-manager.service', () => ({ + offlineEventManager: { + initialize: jest.fn(), + syncNow: jest.fn(), + }, +})); + jest.mock('../push-notification', () => ({ pushNotificationService: { initialize: jest.fn(), @@ -57,7 +64,7 @@ describe('AppInitializationService', () => { await appInitializationService.initialize(); expect(mockCallKeepService.setup).toHaveBeenCalledWith({ - appName: 'Resgrid Unit', + appName: 'Resgrid IC', maximumCallGroups: 1, maximumCallsPerCallGroup: 1, includesCallsInRecents: false, @@ -88,7 +95,7 @@ describe('AppInitializationService', () => { await appInitializationService.initialize(); expect(mockCallKeepService.setup).toHaveBeenCalledWith({ - appName: 'Resgrid Unit', + appName: 'Resgrid IC', maximumCallGroups: 1, maximumCallsPerCallGroup: 1, includesCallsInRecents: false, diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 304d0d5..4fc72be 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -18,7 +18,6 @@ jest.mock('@/lib/storage', () => ({ // Mock storage/app functions jest.mock('@/lib/storage/app', () => ({ - removeActiveUnitId: jest.fn(), removeActiveCallId: jest.fn(), removeDeviceUuid: jest.fn(), })); @@ -137,13 +136,6 @@ jest.mock('@/stores/security/store', () => ({ }, })); -jest.mock('@/stores/status/store', () => ({ - useStatusBottomSheetStore: { - setState: jest.fn(), - getState: jest.fn(), - }, -})); - jest.mock('@/stores/units/store', () => ({ useUnitsStore: { setState: jest.fn(), @@ -177,7 +169,6 @@ const mockStorage = jest.requireMock('@/lib/storage').storage; const mockStorageApp = jest.requireMock('@/lib/storage/app'); // Mock function references for store methods -const mockStatusReset = jest.fn(); const mockOfflineQueueClear = jest.fn(); const mockLoadingReset = jest.fn(); const mockAudioCleanup = jest.fn().mockResolvedValue(undefined); @@ -192,7 +183,6 @@ describe('app-reset.service', () => { const { useAudioStreamStore } = jest.requireMock('@/stores/app/audio-stream-store'); const { useLoadingStore } = jest.requireMock('@/stores/app/loading-store'); const { useOfflineQueueStore } = jest.requireMock('@/stores/offline-queue/store'); - const { useStatusBottomSheetStore } = jest.requireMock('@/stores/status/store'); useLiveKitStore.getState.mockReturnValue({ isConnected: false, @@ -211,18 +201,11 @@ describe('app-reset.service', () => { clearAllEvents: mockOfflineQueueClear, }); - useStatusBottomSheetStore.getState.mockReturnValue({ - reset: mockStatusReset, - }); }); describe('Initial State Constants', () => { it('should export INITIAL_CORE_STATE with correct shape', () => { expect(INITIAL_CORE_STATE).toEqual({ - activeUnitId: null, - activeUnit: null, - activeUnitStatus: null, - activeUnitStatusType: null, activeCallId: null, activeCall: null, activePriority: null, @@ -231,7 +214,6 @@ describe('app-reset.service', () => { isInitialized: false, isInitializing: false, error: null, - activeStatuses: null, }); }); @@ -385,7 +367,6 @@ describe('app-reset.service', () => { it('should call all remove functions', () => { clearAppStorageItems(); - expect(mockStorageApp.removeActiveUnitId).toHaveBeenCalled(); expect(mockStorageApp.removeActiveCallId).toHaveBeenCalled(); expect(mockStorageApp.removeDeviceUuid).toHaveBeenCalled(); }); @@ -413,7 +394,6 @@ describe('app-reset.service', () => { expect(useCoreStore.setState).toHaveBeenCalledWith(INITIAL_CORE_STATE); expect(useCallsStore.setState).toHaveBeenCalledWith(INITIAL_CALLS_STATE); expect(useUnitsStore.setState).toHaveBeenCalledWith(INITIAL_UNITS_STATE); - expect(mockStatusReset).toHaveBeenCalled(); expect(mockOfflineQueueClear).toHaveBeenCalled(); expect(mockLoadingReset).toHaveBeenCalled(); expect(mockAudioCleanup).toHaveBeenCalled(); @@ -454,7 +434,6 @@ describe('app-reset.service', () => { await clearAllAppData(); // Should clear app storage items - expect(mockStorageApp.removeActiveUnitId).toHaveBeenCalled(); expect(mockStorageApp.removeActiveCallId).toHaveBeenCalled(); expect(mockStorageApp.removeDeviceUuid).toHaveBeenCalled(); diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts index cd77b71..e7c27b3 100644 --- a/src/services/__tests__/location-foreground-permissions.test.ts +++ b/src/services/__tests__/location-foreground-permissions.test.ts @@ -301,14 +301,8 @@ describe('LocationService - Foreground-Only Permissions', () => { // Should update the store expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - // Should send to API - expect(mockSetUnitLocation).toHaveBeenCalledWith( - expect.objectContaining({ - UnitId: 'unit-123', - Latitude: mockLocationObject.coords.latitude.toString(), - Longitude: mockLocationObject.coords.longitude.toString(), - }) - ); + // IC app never reports unit AVL — location stays local + expect(mockSetUnitLocation).not.toHaveBeenCalled(); // Should log the location update expect(mockLogger.info).toHaveBeenCalledWith({ diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 1adbf4c..eaba99f 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -402,7 +402,7 @@ describe('LocationService', () => { expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); }); - it('should handle location updates and send to store and API', async () => { + it('should handle location updates and store them locally without calling the unit AVL API', async () => { await locationService.startLocationUpdates(); // Get the callback function passed to watchPositionAsync @@ -410,7 +410,7 @@ describe('LocationService', () => { await locationCallback(mockLocationObject); expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.any(SaveUnitLocationInput)); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Foreground location update received', context: { @@ -474,7 +474,7 @@ describe('LocationService', () => { }); }); - it('should handle background location updates and send to API', async () => { + it('should handle background location updates and store them locally without calling the unit AVL API', async () => { await locationService.startBackgroundUpdates(); // Get the callback function @@ -482,112 +482,17 @@ describe('LocationService', () => { await locationCallback(mockLocationObject); expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.any(SaveUnitLocationInput)); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); }); }); describe('API Integration', () => { - it('should send location data to API with correct format', async () => { - await locationService.startLocationUpdates(); - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockSetUnitLocation).toHaveBeenCalledWith( - expect.objectContaining({ - UnitId: 'unit-123', - Latitude: mockLocationObject.coords.latitude.toString(), - Longitude: mockLocationObject.coords.longitude.toString(), - Accuracy: mockLocationObject.coords.accuracy?.toString(), - Altitude: mockLocationObject.coords.altitude?.toString(), - AltitudeAccuracy: mockLocationObject.coords.altitudeAccuracy?.toString(), - Speed: mockLocationObject.coords.speed?.toString(), - Heading: mockLocationObject.coords.heading?.toString(), - Timestamp: expect.any(String), - }) - ); - }); - - it('should handle null values in location data', async () => { - const locationWithNulls: Location.LocationObject = { - coords: { - latitude: 37.7749, - longitude: -122.4194, - altitude: null, - accuracy: null, - altitudeAccuracy: null, - heading: null, - speed: null, - }, - timestamp: Date.now(), - }; - - await locationService.startLocationUpdates(); - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(locationWithNulls); - - expect(mockSetUnitLocation).toHaveBeenCalledWith( - expect.objectContaining({ - Accuracy: '0', - Altitude: '0', - AltitudeAccuracy: '0', - Speed: '0', - Heading: '0', - }) - ); - }); - - it('should skip API call if no active unit is selected', async () => { - // Change the core store state for this test - mockCoreStoreState.activeUnitId = null; - + it('should never send location to the unit AVL API (IC app has no unit context)', async () => { await locationService.startLocationUpdates(); const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; await locationCallback(mockLocationObject); expect(mockSetUnitLocation).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'No active unit selected, skipping location API call', - }); - - // Reset for other tests - mockCoreStoreState.activeUnitId = 'unit-123'; - }); - - it('should handle API errors gracefully', async () => { - const apiError = new Error('API Error'); - mockSetUnitLocation.mockRejectedValue(apiError); - - await locationService.startLocationUpdates(); - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Failed to send location to API', - context: { - error: 'API Error', - latitude: mockLocationObject.coords.latitude, - longitude: mockLocationObject.coords.longitude, - }, - }); - }); - - it('should log successful API calls', async () => { - // Reset mock to resolved value - mockSetUnitLocation.mockResolvedValue(mockApiResponse); - - await locationService.startLocationUpdates(); - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Location successfully sent to API', - context: { - unitId: 'unit-123', - resultId: mockApiResponse.Id, - latitude: mockLocationObject.coords.latitude, - longitude: mockLocationObject.coords.longitude, - }, - }); }); }); @@ -740,7 +645,7 @@ describe('LocationService', () => { await locationCallback(mockLocationObject); expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.any(SaveUnitLocationInput)); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); }); it('should not enable background geolocation when permissions are denied', async () => { diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index e7781b4..e6ea5fc 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -32,10 +32,32 @@ jest.mock('@/api/check-in-timers/check-in-timers', () => ({ performCheckIn: jest.fn(), })); +jest.mock('@/api/incidentCommand/incidentCommand', () => ({ + establishCommand: jest.fn(), + closeCommand: jest.fn(), +})); + +jest.mock('@/api/incidentCommand/incidentResources', () => ({ + createAdHocUnit: jest.fn(), + releaseAdHocUnit: jest.fn(), +})); + +jest.mock('@/api/incidentCommand/incidentRoles', () => ({ + assignIncidentRole: jest.fn(), + removeIncidentRole: jest.fn(), +})); + +jest.mock('@/stores/command/store', () => ({ + useCommandStore: { + getState: jest.fn(() => ({ refreshBoard: jest.fn(), syncFromServer: jest.fn() })), + }, +})); + // Mock the offline queue store jest.mock('@/stores/offline-queue/store', () => ({ useOfflineQueueStore: { getState: jest.fn(), + subscribe: jest.fn(() => jest.fn()), }, })); diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index 2372baa..e69dd1e 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -38,15 +38,23 @@ jest.mock('@/lib/logging', () => ({ jest.mock('@/lib/storage/app', () => ({ getDeviceUuid: jest.fn(() => 'test-device-uuid'), + getBaseApiUrl: jest.fn(() => 'https://api.mock.com'), })); jest.mock('@/api/devices/push', () => ({ - registerUnitDevice: jest.fn(), + registerDevice: jest.fn(), +})); + +jest.mock('@/lib/auth', () => ({ + useAuthStore: jest.fn((selector) => { + const state = { userId: 'test-user' }; + return selector ? selector(state) : state; + }), })); jest.mock('@/stores/app/core-store', () => ({ useCoreStore: jest.fn((selector) => { - const state = { activeUnitId: 'test-unit' }; + const state = { activeCall: null }; return selector ? selector(state) : state; }), })); @@ -516,7 +524,7 @@ describe('Push Notification Service Integration', () => { mockHasPermission.mockResolvedValueOnce(1); // AUTHORIZED mockGetToken.mockResolvedValueOnce('test-fcm-token'); - const token = await pushNotificationService.registerForPushNotifications('unit-123', 'TEST'); + const token = await pushNotificationService.registerForPushNotifications('user-123', 'TEST'); expect(token).toBe('test-fcm-token'); expect(mockHasPermission).toHaveBeenCalled(); @@ -528,7 +536,7 @@ describe('Push Notification Service Integration', () => { mockFcmRequestPermission.mockResolvedValueOnce(1); // AUTHORIZED mockGetToken.mockResolvedValueOnce('test-fcm-token'); - const token = await pushNotificationService.registerForPushNotifications('unit-123', 'TEST'); + const token = await pushNotificationService.registerForPushNotifications('user-123', 'TEST'); expect(token).toBe('test-fcm-token'); expect(mockHasPermission).toHaveBeenCalled(); @@ -540,7 +548,7 @@ describe('Push Notification Service Integration', () => { mockHasPermission.mockResolvedValueOnce(2); // DENIED mockFcmRequestPermission.mockResolvedValueOnce(2); // DENIED - const token = await pushNotificationService.registerForPushNotifications('unit-123', 'TEST'); + const token = await pushNotificationService.registerForPushNotifications('user-123', 'TEST'); expect(token).toBeNull(); expect(mockGetToken).not.toHaveBeenCalled(); diff --git a/src/services/app-initialization.service.ts b/src/services/app-initialization.service.ts index 7e3d515..d665960 100644 --- a/src/services/app-initialization.service.ts +++ b/src/services/app-initialization.service.ts @@ -4,6 +4,7 @@ import { Platform } from 'react-native'; import { logger } from '../lib/logging'; import { callKeepService } from './callkeep.service'; import { notificationSoundService } from './notification-sound.service'; +import { offlineEventManager } from './offline-event-manager.service'; import { pushNotificationService } from './push-notification'; /** @@ -89,10 +90,33 @@ class AppInitializationService { // Initialize Push Notification Service await this._initializePushNotifications(); + // Initialize offline sync — network listener, reconnect drain, and interval processing + this._initializeOfflineSync(); + // Add other global initialization tasks here as needed // e.g., analytics, crash reporting, background services, etc. } + /** + * Initialize the offline event manager so queued writes (check-ins, notes, etc.) + * sync automatically when connectivity is restored. + */ + private _initializeOfflineSync(): void { + try { + offlineEventManager.initialize(); + + logger.info({ + message: 'Offline event manager initialized successfully', + }); + } catch (error) { + logger.error({ + message: 'Failed to initialize offline event manager', + context: { error }, + }); + // Don't throw — offline sync failure shouldn't prevent app startup + } + } + /** * Register the Notifee foreground service task handler for Android. * This keeps the voice channel alive when the app is in the background. @@ -140,7 +164,7 @@ class AppInitializationService { try { await callKeepService.setup({ - appName: 'Resgrid Unit', + appName: 'Resgrid IC', maximumCallGroups: 1, maximumCallsPerCallGroup: 1, includesCallsInRecents: false, diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index e274a8b..b986eac 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -8,7 +8,7 @@ import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; -import { removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; +import { removeActiveCallId, removeDeviceUuid } from '@/lib/storage/app'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; import { INITIAL_STATE as BLUETOOTH_INITIAL_STATE, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useCoreStore } from '@/stores/app/core-store'; @@ -24,7 +24,6 @@ import { useProtocolsStore } from '@/stores/protocols/store'; import { usePushNotificationModalStore } from '@/stores/push-notification/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; -import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useUnitsStore } from '@/stores/units/store'; // ============================================================================ @@ -33,10 +32,6 @@ import { useUnitsStore } from '@/stores/units/store'; // ============================================================================ export const INITIAL_CORE_STATE = { - activeUnitId: null, - activeUnit: null, - activeUnitStatus: null, - activeUnitStatusType: null, activeCallId: null, activeCall: null, activePriority: null, @@ -45,7 +40,6 @@ export const INITIAL_CORE_STATE = { isInitialized: false, isInitializing: false, error: null, - activeStatuses: null, }; export const INITIAL_CALLS_STATE = { @@ -176,10 +170,9 @@ export const clearPersistedStorage = (): void => { }; /** - * Clears app-specific storage items (active unit, call, device UUID) + * Clears app-specific storage items (active call, device UUID) */ export const clearAppStorageItems = (): void => { - removeActiveUnitId(); removeActiveCallId(); removeDeviceUuid(); }; @@ -201,7 +194,6 @@ export const resetAllStores = async (): Promise => { securityStore.setState(INITIAL_SECURITY_STATE); // Stores with existing reset/clear methods - useStatusBottomSheetStore.getState().reset(); useOfflineQueueStore.getState().clearAllEvents(); useLoadingStore.getState().resetLoading(); diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index afa5324..1439f11 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -78,9 +78,9 @@ export class CallKeepService { // Self-managed connection service for VoIP apps selfManaged: true, foregroundService: { - channelId: 'com.resgrid.unit.voip', + channelId: 'com.resgrid.command.voip', channelName: 'Voice Calls', - notificationTitle: 'Resgrid Unit Voice Call', + notificationTitle: 'Resgrid IC Voice Call', notificationIcon: 'ic_launcher', }, }, @@ -119,7 +119,7 @@ export class CallKeepService { if (!this.isSetup) { // Auto-setup if not done (defensive programming) await this.setup({ - appName: 'Resgrid Unit', + appName: 'Resgrid IC', maximumCallGroups: 1, maximumCallsPerCallGroup: 1, includesCallsInRecents: false, diff --git a/src/services/location.ts b/src/services/location.ts index b6997a6..d40e187 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -2,61 +2,18 @@ import * as Location from 'expo-location'; import * as TaskManager from 'expo-task-manager'; import { AppState, type AppStateStatus } from 'react-native'; -import { setUnitLocation } from '@/api/units/unitLocation'; import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; import { logger } from '@/lib/logging'; import { isWeb } from '@/lib/platform'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; -import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; -import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; const LOCATION_TASK_NAME = 'location-updates'; -// Helper function to send location to API -const sendLocationToAPI = async (location: Location.LocationObject): Promise => { - try { - const { activeUnitId } = useCoreStore.getState(); - - if (!activeUnitId) { - logger.warn({ - message: 'No active unit selected, skipping location API call', - }); - return; - } - - const locationInput = new SaveUnitLocationInput(); - locationInput.UnitId = activeUnitId; - locationInput.Timestamp = new Date(location.timestamp).toISOString(); - locationInput.Latitude = location.coords.latitude.toString(); - locationInput.Longitude = location.coords.longitude.toString(); - locationInput.Accuracy = location.coords.accuracy?.toString() || '0'; - locationInput.Altitude = location.coords.altitude?.toString() || '0'; - locationInput.AltitudeAccuracy = location.coords.altitudeAccuracy?.toString() || '0'; - locationInput.Speed = location.coords.speed?.toString() || '0'; - locationInput.Heading = location.coords.heading?.toString() || '0'; - - const result = await setUnitLocation(locationInput); - - logger.info({ - message: 'Location successfully sent to API', - context: { - unitId: activeUnitId, - resultId: result.Id, - latitude: location.coords.latitude, - longitude: location.coords.longitude, - }, - }); - } catch (error) { - logger.warn({ - message: 'Failed to send location to API', - context: { - error: error instanceof Error ? error.message : String(error), - latitude: location.coords.latitude, - longitude: location.coords.longitude, - }, - }); - } +// IC app has no unit context — location is only tracked locally (map centering, +// distance calculations); it is never reported to the unit AVL API. +const sendLocationToAPI = async (_location: Location.LocationObject): Promise => { + // Intentionally a no-op for the IC app. }; // Define the background task (native only — TaskManager is unsupported on web) diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 55a7a44..4e86e49 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -2,16 +2,45 @@ import { AppState, type AppStateStatus } from 'react-native'; import { saveCallImage } from '@/api/calls/callFiles'; import { performCheckIn } from '@/api/check-in-timers/check-in-timers'; +import { + assignResource, + closeCommand, + completeObjective, + deleteCommandNode, + establishCommand, + getCommandBoard, + moveResource, + releaseResource, + saveCommandNode, + saveObjective, +} from '@/api/incidentCommand/incidentCommand'; +import { createAdHocPersonnel, createAdHocUnit, releaseAdHocPersonnel, releaseAdHocUnit } from '@/api/incidentCommand/incidentResources'; +import { assignIncidentRole, removeIncidentRole } from '@/api/incidentCommand/incidentRoles'; import { setUnitLocation } from '@/api/units/unitLocation'; import { saveUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { + type QueuedAssignCommandResourceEvent, + type QueuedAssignIncidentRoleEvent, type QueuedCallImageUploadEvent, type QueuedCheckInEvent, + type QueuedCloseCommandEvent, + type QueuedCompleteObjectiveEvent, + type QueuedCreateAdHocPersonnelEvent, + type QueuedCreateAdHocUnitEvent, + type QueuedDeleteCommandNodeEvent, + type QueuedEstablishCommandEvent, type QueuedEvent, QueuedEventStatus, QueuedEventType, type QueuedLocationUpdateEvent, + type QueuedMoveCommandResourceEvent, + type QueuedReleaseAdHocPersonnelEvent, + type QueuedReleaseAdHocUnitEvent, + type QueuedReleaseCommandResourceEvent, + type QueuedRemoveIncidentRoleEvent, + type QueuedSaveCommandNodeEvent, + type QueuedSaveObjectiveEvent, type QueuedUnitStatusEvent, } from '@/models/offline-queue/queued-event'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; @@ -23,6 +52,7 @@ class OfflineEventManager { private processingInterval: ReturnType | null = null; private isProcessing = false; private appStateSubscription: { remove: () => void } | null = null; + private reconnectUnsubscribe: (() => void) | null = null; private readonly PROCESSING_INTERVAL = 10000; // 10 seconds private readonly MAX_CONCURRENT_EVENTS = 3; @@ -48,10 +78,61 @@ class OfflineEventManager { // Initialize network listener useOfflineQueueStore.getState().initializeNetworkListener(); + // Drain the queue as soon as connectivity is restored (in addition to the interval) + this.initializeReconnectListener(); + // Start processing when app becomes active this.handleAppStateChange(AppState.currentState); } + /** + * Manually trigger a sync (user-initiated "Sync Now"): + * push all pending queued writes, then pull the latest command-board state. + */ + public async syncNow(): Promise { + logger.info({ + message: 'Manual sync requested', + }); + await this.processQueuedEvents(); + await this.pullCommandSync(); + } + + /** + * Pull the latest incident-command state from the server (Sync Bundle). + */ + private async pullCommandSync(): Promise { + try { + // Late import via require to avoid a module cycle + const { useCommandStore } = require('@/stores/command/store'); + await useCommandStore.getState().syncFromServer(); + } catch (error) { + logger.warn({ + message: 'Command sync pull failed', + context: { error }, + }); + } + } + + /** + * Subscribe to network state changes and process the queue immediately + * when the device transitions from offline to online. + */ + private initializeReconnectListener(): void { + if (this.reconnectUnsubscribe) { + return; + } + this.reconnectUnsubscribe = useOfflineQueueStore.subscribe((state, prevState) => { + const wasOffline = !prevState.isConnected || !prevState.isNetworkReachable; + const isOnline = state.isConnected && state.isNetworkReachable; + if (wasOffline && isOnline) { + logger.info({ + message: 'Connectivity restored — processing offline queue and pulling command sync', + }); + this.processQueuedEvents().then(() => this.pullCommandSync()); + } + }); + } + /** * Start background processing of queued events */ @@ -181,6 +262,130 @@ class OfflineEventManager { return useOfflineQueueStore.getState().addEvent(QueuedEventType.CHECK_IN, data); } + // ---- Incident command (ICS) event processors ---- + // After each successful replay the command store refreshes the affected board + // so the local optimistic state converges to the server state. + + private async refreshCommandBoard(callId: string): Promise { + try { + // Late import via require to avoid a module cycle (store → queue store; manager → store) + const { useCommandStore } = require('@/stores/command/store'); + await useCommandStore.getState().refreshBoard(callId); + } catch { + // Refresh is best-effort — the next sync pass converges the state + } + } + + private async processEstablishCommandEvent(event: QueuedEstablishCommandEvent): Promise { + const callId = parseInt(event.data.callId, 10); + await establishCommand({ CallId: Number.isNaN(callId) ? 0 : callId, CommandDefinitionId: event.data.commandDefinitionId ?? null }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processCloseCommandEvent(event: QueuedCloseCommandEvent): Promise { + await closeCommand(event.data.incidentCommandId); + } + + private async processAssignIncidentRoleEvent(event: QueuedAssignIncidentRoleEvent): Promise { + const callId = parseInt(event.data.callId, 10); + await assignIncidentRole({ + CallId: Number.isNaN(callId) ? 0 : callId, + RoleType: event.data.roleType, + UserId: event.data.userId, + }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processRemoveIncidentRoleEvent(event: QueuedRemoveIncidentRoleEvent): Promise { + await removeIncidentRole(event.data.incidentRoleAssignmentId); + } + + private async processCreateAdHocUnitEvent(event: QueuedCreateAdHocUnitEvent): Promise { + const callId = parseInt(event.data.callId, 10); + await createAdHocUnit({ CallId: Number.isNaN(callId) ? 0 : callId, Name: event.data.name, Type: event.data.type }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processReleaseAdHocUnitEvent(event: QueuedReleaseAdHocUnitEvent): Promise { + await releaseAdHocUnit(event.data.incidentAdHocUnitId); + } + + private async processCreateAdHocPersonnelEvent(event: QueuedCreateAdHocPersonnelEvent): Promise { + const callId = parseInt(event.data.callId, 10); + await createAdHocPersonnel({ CallId: Number.isNaN(callId) ? 0 : callId, Name: event.data.name, Role: event.data.role, ExternalAgencyName: event.data.agency }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processReleaseAdHocPersonnelEvent(event: QueuedReleaseAdHocPersonnelEvent): Promise { + await releaseAdHocPersonnel(event.data.incidentAdHocPersonnelId); + } + + /** Resolve the server-side IncidentCommandId for a call (needed when the write was queued offline). */ + private async resolveIncidentCommandId(callId: string): Promise { + const board = await getCommandBoard(callId); + const incidentCommandId = board.Data?.Command?.IncidentCommandId; + if (!incidentCommandId) { + throw new Error(`No active incident command found for call ${callId}`); + } + return incidentCommandId; + } + + private async processSaveCommandNodeEvent(event: QueuedSaveCommandNodeEvent): Promise { + const callId = parseInt(event.data.callId, 10); + const incidentCommandId = await this.resolveIncidentCommandId(event.data.callId); + await saveCommandNode({ + IncidentCommandId: incidentCommandId, + CallId: Number.isNaN(callId) ? 0 : callId, + Name: event.data.name, + NodeType: event.data.nodeType, + Color: event.data.color, + MinUnits: event.data.limits?.minUnits ?? 0, + MaxUnits: event.data.limits?.maxUnits ?? 0, + MinUnitPersonnel: event.data.limits?.minUnitPersonnel ?? 0, + MaxUnitPersonnel: event.data.limits?.maxUnitPersonnel ?? 0, + MinTimeInRole: event.data.limits?.minTimeInRole ?? 0, + MaxTimeInRole: event.data.limits?.maxTimeInRole ?? 0, + }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processDeleteCommandNodeEvent(event: QueuedDeleteCommandNodeEvent): Promise { + await deleteCommandNode(event.data.commandStructureNodeId); + } + + private async processAssignCommandResourceEvent(event: QueuedAssignCommandResourceEvent): Promise { + const callId = parseInt(event.data.callId, 10); + const incidentCommandId = await this.resolveIncidentCommandId(event.data.callId); + await assignResource({ + IncidentCommandId: incidentCommandId, + CallId: Number.isNaN(callId) ? 0 : callId, + CommandStructureNodeId: event.data.commandStructureNodeId, + ResourceKind: event.data.resourceKind, + ResourceId: event.data.resourceId, + }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processMoveCommandResourceEvent(event: QueuedMoveCommandResourceEvent): Promise { + await moveResource({ ResourceAssignmentId: event.data.resourceAssignmentId, TargetNodeId: event.data.targetNodeId }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processReleaseCommandResourceEvent(event: QueuedReleaseCommandResourceEvent): Promise { + await releaseResource(event.data.resourceAssignmentId); + } + + private async processSaveObjectiveEvent(event: QueuedSaveObjectiveEvent): Promise { + const callId = parseInt(event.data.callId, 10); + const incidentCommandId = await this.resolveIncidentCommandId(event.data.callId); + await saveObjective({ IncidentCommandId: incidentCommandId, CallId: Number.isNaN(callId) ? 0 : callId, Name: event.data.name, ObjectiveType: event.data.objectiveType }); + await this.refreshCommandBoard(event.data.callId); + } + + private async processCompleteObjectiveEvent(event: QueuedCompleteObjectiveEvent): Promise { + await completeObjective(event.data.tacticalObjectiveId); + } + /** * Process check-in event */ @@ -274,6 +479,51 @@ class OfflineEventManager { case QueuedEventType.CHECK_IN: await this.processCheckInEvent(event as QueuedCheckInEvent); break; + case QueuedEventType.ESTABLISH_COMMAND: + await this.processEstablishCommandEvent(event as QueuedEstablishCommandEvent); + break; + case QueuedEventType.CLOSE_COMMAND: + await this.processCloseCommandEvent(event as QueuedCloseCommandEvent); + break; + case QueuedEventType.ASSIGN_INCIDENT_ROLE: + await this.processAssignIncidentRoleEvent(event as QueuedAssignIncidentRoleEvent); + break; + case QueuedEventType.REMOVE_INCIDENT_ROLE: + await this.processRemoveIncidentRoleEvent(event as QueuedRemoveIncidentRoleEvent); + break; + case QueuedEventType.CREATE_ADHOC_UNIT: + await this.processCreateAdHocUnitEvent(event as QueuedCreateAdHocUnitEvent); + break; + case QueuedEventType.RELEASE_ADHOC_UNIT: + await this.processReleaseAdHocUnitEvent(event as QueuedReleaseAdHocUnitEvent); + break; + case QueuedEventType.CREATE_ADHOC_PERSONNEL: + await this.processCreateAdHocPersonnelEvent(event as QueuedCreateAdHocPersonnelEvent); + break; + case QueuedEventType.RELEASE_ADHOC_PERSONNEL: + await this.processReleaseAdHocPersonnelEvent(event as QueuedReleaseAdHocPersonnelEvent); + break; + case QueuedEventType.SAVE_COMMAND_NODE: + await this.processSaveCommandNodeEvent(event as QueuedSaveCommandNodeEvent); + break; + case QueuedEventType.DELETE_COMMAND_NODE: + await this.processDeleteCommandNodeEvent(event as QueuedDeleteCommandNodeEvent); + break; + case QueuedEventType.ASSIGN_COMMAND_RESOURCE: + await this.processAssignCommandResourceEvent(event as QueuedAssignCommandResourceEvent); + break; + case QueuedEventType.MOVE_COMMAND_RESOURCE: + await this.processMoveCommandResourceEvent(event as QueuedMoveCommandResourceEvent); + break; + case QueuedEventType.RELEASE_COMMAND_RESOURCE: + await this.processReleaseCommandResourceEvent(event as QueuedReleaseCommandResourceEvent); + break; + case QueuedEventType.SAVE_OBJECTIVE: + await this.processSaveObjectiveEvent(event as QueuedSaveObjectiveEvent); + break; + case QueuedEventType.COMPLETE_OBJECTIVE: + await this.processCompleteObjectiveEvent(event as QueuedCompleteObjectiveEvent); + break; default: throw new Error(`Unknown event type: ${event.type}`); } @@ -411,6 +661,11 @@ class OfflineEventManager { this.appStateSubscription = null; } + if (this.reconnectUnsubscribe) { + this.reconnectUnsubscribe(); + this.reconnectUnsubscribe = null; + } + logger.info({ message: 'Offline event manager cleaned up', }); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 9362179..5a60f4d 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -4,7 +4,8 @@ import * as Device from 'expo-device'; import { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; -import { registerUnitDevice } from '@/api/devices/push'; +import { registerDevice } from '@/api/devices/push'; +import { useAuthStore } from '@/lib/auth'; import { logger } from '@/lib/logging'; import { getDeviceUuid } from '@/lib/storage/app'; import { useCoreStore } from '@/stores/app/core-store'; @@ -195,28 +196,21 @@ class PushNotificationService { context: { type, detail: { id: detail.notification?.id, data: detail.notification?.data } }, }); - // Handle check-in action press + // Handle check-in action press — IC users always check in as personnel if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') { logger.info({ message: 'Check-in action pressed from notification' }); const activeCall = useCoreStore.getState().activeCall; - const activeUnit = useCoreStore.getState().activeUnit; if (activeCall) { const callId = parseInt(activeCall.CallId, 10); if (Number.isNaN(callId)) { logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } }); } else { - const unitId = activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined; - if (activeUnit && Number.isNaN(unitId)) { - logger.error({ message: 'Check-in action aborted: invalid UnitId', context: { UnitId: activeUnit.UnitId } }); - } else { - await useCheckInTimerStore.getState().performCheckIn({ - CallId: callId, - CheckInType: activeUnit ? CHECK_IN_TYPE_UNIT : CHECK_IN_TYPE_PERSONNEL, - UnitId: unitId, - Latitude: useLocationStore.getState().latitude?.toString(), - Longitude: useLocationStore.getState().longitude?.toString(), - }); - } + await useCheckInTimerStore.getState().performCheckIn({ + CallId: callId, + CheckInType: CHECK_IN_TYPE_PERSONNEL, + Latitude: useLocationStore.getState().latitude?.toString(), + Longitude: useLocationStore.getState().longitude?.toString(), + }); } } } @@ -408,7 +402,7 @@ class PushNotificationService { }); } - public async registerForPushNotifications(unitId: string, departmentCode: string): Promise { + public async registerForPushNotifications(userId: string, departmentCode: string): Promise { if (!Device.isDevice) { logger.warn({ message: 'Push notifications are not available on simulator/emulator', @@ -416,9 +410,9 @@ class PushNotificationService { return null; } - if (!unitId || unitId.trim() === '') { + if (!userId || userId.trim() === '') { logger.warn({ - message: 'Cannot register for push notifications without an active unit ID', + message: 'Cannot register for push notifications without a signed-in user ID', }); return null; } @@ -499,14 +493,14 @@ class PushNotificationService { message: 'Push notification token obtained', context: { token: this.pushToken, - unitId, + userId, platform: Platform.OS, }, }); - // Register device with backend - await registerUnitDevice({ - UnitId: unitId, + // Register device with backend (user-scoped — the IC app has no unit context) + await registerDevice({ + UserId: userId, Token: this.pushToken || '', Platform: Platform.OS === 'ios' ? 1 : 2, DeviceUuid: getDeviceUuid() || '', @@ -544,23 +538,23 @@ export const pushNotificationService = PushNotificationService.getInstance(); // React hook for component usage export const usePushNotifications = () => { - const activeUnitId = useCoreStore((state) => state.activeUnitId); + const userId = useAuthStore((state) => state.userId); const rights = securityStore((state) => state.rights); - const previousUnitIdRef = useRef(null); + const previousUserIdRef = useRef(null); useEffect(() => { // Push notifications are native-only; skip on web if (Platform.OS === 'web') return; - // Only register if we have an active unit ID and it's different from the previous one - if (rights && activeUnitId && activeUnitId !== previousUnitIdRef.current) { + // Only register if we have a signed-in user ID and it's different from the previous one + if (rights && userId && userId !== previousUserIdRef.current) { pushNotificationService - .registerForPushNotifications(activeUnitId, rights.DepartmentCode) + .registerForPushNotifications(userId, rights.DepartmentCode) .then((token) => { if (token) { logger.info({ message: 'Successfully registered for push notifications', - context: { unitId: activeUnitId }, + context: { userId }, }); } }) @@ -571,14 +565,14 @@ export const usePushNotifications = () => { }); }); - previousUnitIdRef.current = activeUnitId; + previousUserIdRef.current = userId; } // Cleanup function return () => { // No need to clean up here as the service handles its own cleanup }; - }, [activeUnitId, rights]); + }, [userId, rights]); return { pushToken: pushNotificationService.getPushToken(), diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index cb140b0..0864528 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -548,7 +548,7 @@ class SignalRService { } } - public async invoke(hubName: string, method: string, data: unknown): Promise { + public async invoke(hubName: string, method: string, data?: unknown): Promise { // Wait for any ongoing connection attempt to complete const existingLock = this.connectionLocks.get(hubName); if (existingLock) { @@ -562,7 +562,9 @@ class SignalRService { const connection = this.connections.get(hubName); if (connection) { try { - return await connection.invoke(method, data); + // Hub methods without parameters must be invoked without a data argument — + // SignalR matches server methods by arity. + return data === undefined ? await connection.invoke(method) : await connection.invoke(method, data); } catch (error) { logger.error({ message: `Error invoking method ${method} from hub: ${hubName}`, diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 69c5147..c449c49 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -6,18 +6,8 @@ jest.mock('@/api/config', () => ({ getConfig: jest.fn(), })); -jest.mock('@/api/satuses/statuses', () => ({ - getAllUnitStatuses: jest.fn(), -})); - -jest.mock('@/api/units/unitStatuses', () => ({ - getUnitStatus: jest.fn(), -})); - jest.mock('@/lib/storage/app', () => ({ - getActiveUnitId: jest.fn(), getActiveCallId: jest.fn(), - setActiveUnitId: jest.fn(), setActiveCallId: jest.fn(), })); @@ -39,16 +29,6 @@ jest.mock('@/stores/calls/store', () => ({ }, })); -jest.mock('@/stores/units/store', () => ({ - useUnitsStore: { - getState: jest.fn(() => ({ - fetchUnits: jest.fn(), - units: [], - unitStatuses: [], - })), - }, -})); - // Mock the storage layer used by zustand persist jest.mock('@/lib/storage', () => ({ zustandStorage: { @@ -60,11 +40,10 @@ jest.mock('@/lib/storage', () => ({ // Import after mocks import { useCoreStore } from '../core-store'; -import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app'; +import { getActiveCallId } from '@/lib/storage/app'; import { getConfig } from '@/api/config'; import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; -const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction; const mockGetActiveCallId = getActiveCallId as jest.MockedFunction; const mockGetConfig = getConfig as jest.MockedFunction; @@ -75,11 +54,6 @@ describe('Core Store', () => { // Reset store state by creating a fresh instance useCoreStore.setState({ - activeUnitId: null, - activeUnit: null, - activeUnitStatus: null, - activeUnitStatusType: null, - activeStatuses: null, activeCallId: null, activeCall: null, activePriority: null, @@ -93,7 +67,6 @@ describe('Core Store', () => { describe('Initialization', () => { it('should prevent multiple simultaneous initializations', async () => { - mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); mockGetConfig.mockResolvedValue({ Data: { @@ -125,7 +98,6 @@ describe('Core Store', () => { }); it('should skip initialization if already initialized', async () => { - mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); mockGetConfig.mockResolvedValue({ Data: { @@ -155,8 +127,7 @@ describe('Core Store', () => { expect(mockGetConfig).not.toHaveBeenCalled(); }); - it('should handle initialization with no active unit or call', async () => { - mockGetActiveUnitId.mockReturnValue(null); + it('should handle initialization with no active call', async () => { mockGetActiveCallId.mockReturnValue(null); mockGetConfig.mockResolvedValue({ Data: { @@ -180,9 +151,8 @@ describe('Core Store', () => { }); it('should fetch config first during initialization', async () => { - mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); - + const mockConfigData = { EventingUrl: 'https://eventing.example.com/', GoogleMapsKey: 'test-google-key', @@ -206,9 +176,8 @@ describe('Core Store', () => { }); it('should handle config fetch errors during initialization', async () => { - mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); - + const configError = new Error('Failed to fetch config'); mockGetConfig.mockRejectedValue(configError); @@ -292,8 +261,6 @@ describe('Core Store', () => { it('should have correct initial state', () => { const { result } = renderHook(() => useCoreStore()); - expect(result.current.activeUnitId).toBe(null); - expect(result.current.activeUnit).toBe(null); expect(result.current.activeCallId).toBe(null); expect(result.current.activeCall).toBe(null); expect(result.current.config).toBe(null); @@ -307,8 +274,6 @@ describe('Core Store', () => { const { result } = renderHook(() => useCoreStore()); expect(typeof result.current.init).toBe('function'); - expect(typeof result.current.setActiveUnit).toBe('function'); - expect(typeof result.current.setActiveUnitWithFetch).toBe('function'); expect(typeof result.current.setActiveCall).toBe('function'); expect(typeof result.current.fetchConfig).toBe('function'); }); diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 8c2c5c7..2b253f5 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -400,7 +400,7 @@ describe('LiveKit Store - Permission Management', () => { // Note: setupCallKeep is now handled globally via app initialization service // This test just verifies the CallKeep service methods can be called await mockCallKeepService.setup({ - appName: 'Resgrid Unit', + appName: 'Resgrid IC', maximumCallGroups: 1, maximumCallsPerCallGroup: 1, includesCallsInRecents: false, diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index 059ec84..f93c51c 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -1,33 +1,18 @@ import { Env } from '@env'; -import find from 'lodash/find'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { getConfig } from '@/api/config'; -import { getAllUnitStatuses } from '@/api/satuses/statuses'; -import { getUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { zustandStorage } from '@/lib/storage'; -import { getActiveCallId, getActiveUnitId, setActiveCallId, setActiveUnitId } from '@/lib/storage/app'; +import { getActiveCallId, setActiveCallId } from '@/lib/storage/app'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; -import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; -import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; -import { type UnitResultData } from '@/models/v4/units/unitResultData'; -import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; import { useCallsStore } from '../calls/store'; -//import { useRolesStore } from '../roles/store'; -import { useUnitsStore } from '../units/store'; interface CoreState { - activeUnitId: string | null; - activeUnit: UnitResultData | null; - activeUnitStatus: UnitStatusResultData | null; - activeUnitStatusType: StatusesResultData | null; - activeStatuses: UnitTypeStatusResultData | null; - activeCallId: string | null; activeCall: CallResultData | null; activePriority: CallPriorityResultData | null; @@ -39,8 +24,6 @@ interface CoreState { isInitializing: boolean; error: string | null; init: () => Promise; - setActiveUnit: (unitId: string) => void; - setActiveUnitWithFetch: (unitId: string) => Promise; setActiveCall: (callId: string | null) => Promise; fetchConfig: () => Promise; } @@ -48,10 +31,6 @@ interface CoreState { export const useCoreStore = create()( persist( (set, get) => ({ - activeUnitId: null, - activeUnit: null, - activeUnitStatus: null, - activeUnitStatusType: null, activeCallId: null, activeCall: null, activePriority: null, @@ -60,7 +39,6 @@ export const useCoreStore = create()( isInitialized: false, isInitializing: false, error: null, - activeStatuses: null, init: async () => { const state = get(); @@ -91,14 +69,8 @@ export const useCoreStore = create()( throw new Error('Config fetch failed, cannot continue initialization'); } - const activeUnitId = getActiveUnitId(); const activeCallId = getActiveCallId(); - // Initialize in sequence to prevent race conditions - if (activeUnitId) { - await get().setActiveUnit(activeUnitId); - } - if (activeCallId) { await get().setActiveCall(activeCallId); } @@ -125,91 +97,6 @@ export const useCoreStore = create()( throw error; } }, - setActiveUnit: async (unitId: string) => { - set({ isLoading: true, error: null, activeUnitId: unitId }); - try { - await setActiveUnitId(unitId); - await useUnitsStore.getState().fetchUnits(); - const units = useUnitsStore.getState().units; - const unitStatuses = useUnitsStore.getState().unitStatuses; - const activeUnit = units.find((unit) => unit.UnitId === unitId); - if (activeUnit) { - let activeStatuses: UnitTypeStatusResultData | undefined = undefined; - const allStatuses = await getAllUnitStatuses(); - const defaultStatuses = find(allStatuses.Data, ['UnitType', '0']); - - if (activeUnit.Type) { - const statusesForType = find(allStatuses.Data, ['UnitType', activeUnit.Type.toString()]); - - if (statusesForType) { - activeStatuses = statusesForType; - } else { - activeStatuses = defaultStatuses; - } - } else { - activeStatuses = defaultStatuses; - } - - set({ - activeUnit: activeUnit, - activeStatuses: activeStatuses, - isLoading: false, - }); - } - - const unitStatus = await getUnitStatus(unitId); - - if (unitStatus) { - const unitStatusType = unitStatuses.find((status) => status.UnitType === activeUnit?.Type); - if (unitStatusType) { - const unitStatusInfo = unitStatusType.Statuses.find((status) => status.Text === unitStatus.Data.State); - set({ - activeUnitStatus: unitStatus.Data, - activeUnitStatusType: unitStatusInfo, - }); - } else { - set({ - activeUnitStatus: unitStatus.Data, - activeUnitStatusType: null, - }); - } - } - - //await useRolesStore.getState().fetchRolesForUnit(unitId); - } catch (error) { - set({ error: 'Failed to set active unit', isLoading: false }); - logger.error({ - message: `Failed to set active unit: ${JSON.stringify(error)}`, - context: { error }, - }); - } - }, - setActiveUnitWithFetch: async (unitId: string) => { - set({ isLoading: true, error: null, activeUnitId: unitId }); - try { - await useUnitsStore.getState().fetchUnits(); - - const units = useUnitsStore.getState().units; - const activeUnit = units.find((unit) => unit.UnitId === unitId); - - const unitStatus = await getUnitStatus(unitId); - - set({ - activeUnit: activeUnit, - activeUnitStatus: unitStatus.Data, - isLoading: false, - }); - } catch (error) { - set({ - error: 'Failed to fetch and set active unit', - isLoading: false, - }); - logger.error({ - message: `Failed to fetch and set active unit: ${JSON.stringify(error)}`, - context: { error }, - }); - } - }, setActiveCall: async (callId: string | null) => { if (!callId) { // Deselect the call @@ -267,15 +154,10 @@ export const useCoreStore = create()( name: 'core-storage', storage: createJSONStorage(() => zustandStorage), partialize: (state) => ({ - activeUnitId: state.activeUnitId, - activeUnit: state.activeUnit, - activeUnitStatus: state.activeUnitStatus, - activeUnitStatusType: state.activeUnitStatusType, activeCallId: state.activeCallId, activeCall: state.activeCall, activePriority: state.activePriority, config: state.config, - activeStatuses: state.activeStatuses, // Exclude: isLoading, isInitialized, isInitializing, error // These are transient flags that must NOT persist across reloads }), diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0fda9bb..36c5a9d 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -679,7 +679,7 @@ export const useLiveKitStore = create((set, get) => ({ channelId: 'notif', asForegroundService: true, foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE], - smallIcon: 'ic_launcher', + smallIcon: 'ic_notification', }, }); logger.info({ diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 7f3b077..496e1d6 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; import { getCallPriorities } from '@/api/calls/callPriorities'; import { getCallExtraData, getCalls } from '@/api/calls/calls'; import { getCallTypes } from '@/api/calls/callTypes'; import { getNewCallData } from '@/api/dispatch/dispatch'; +import { zustandStorage } from '@/lib/storage'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; @@ -30,128 +32,145 @@ interface CallsState { init: () => Promise; } -export const useCallsStore = create((set, get) => ({ - calls: [], - callPriorities: [], - callTypes: [], - destinationPois: [], - poiTypes: [], - callDispatches: {}, - isLoading: false, - isInitialized: false, - isCallFormDataLoaded: false, - error: null, - lastFetchedAt: 0, - init: async () => { - // Prevent re-initialization during tree remounts - if (get().isInitialized || get().isLoading) { - return; - } - set({ isLoading: true, error: null }); - const callsResponse = await getCalls(); - const callPrioritiesResponse = await getCallPriorities(); - const callTypesResponse = await getCallTypes(); - set({ - calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [], - callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [], - callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [], +export const useCallsStore = create()( + persist( + (set, get) => ({ + calls: [], + callPriorities: [], + callTypes: [], + destinationPois: [], + poiTypes: [], + callDispatches: {}, isLoading: false, - isInitialized: true, - lastFetchedAt: Date.now(), - }); - }, - fetchCalls: async () => { - set({ isLoading: true, error: null }); - try { - const response = await getCalls(); - const newCalls = Array.isArray(response.Data) ? response.Data : []; + isInitialized: false, + isCallFormDataLoaded: false, + error: null, + lastFetchedAt: 0, + init: async () => { + // Prevent re-initialization during tree remounts + if (get().isInitialized || get().isLoading) { + return; + } + set({ isLoading: true, error: null }); + const callsResponse = await getCalls(); + const callPrioritiesResponse = await getCallPriorities(); + const callTypesResponse = await getCallTypes(); + set({ + calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [], + callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [], + callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [], + isLoading: false, + isInitialized: true, + lastFetchedAt: Date.now(), + }); + }, + fetchCalls: async () => { + set({ isLoading: true, error: null }); + try { + const response = await getCalls(); + const newCalls = Array.isArray(response.Data) ? response.Data : []; - // Evict dispatches for calls no longer in the active list to prevent unbounded memory growth - const activeIds = new Set(newCalls.map((c) => c.CallId)); - const existing = get().callDispatches; - const pruned: Record = {}; - for (const id in existing) { - if (activeIds.has(id)) { - pruned[id] = existing[id]; + // Evict dispatches for calls no longer in the active list to prevent unbounded memory growth + const activeIds = new Set(newCalls.map((c) => c.CallId)); + const existing = get().callDispatches; + const pruned: Record = {}; + for (const id in existing) { + if (activeIds.has(id)) { + pruned[id] = existing[id]; + } + } + + set({ calls: newCalls, callDispatches: pruned, isLoading: false, lastFetchedAt: Date.now() }); + } catch (error) { + set({ error: 'Failed to fetch calls', isLoading: false }); + } + }, + fetchCallPriorities: async () => { + set({ isLoading: true, error: null }); + try { + const response = await getCallPriorities(); + set({ callPriorities: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); + } catch (error) { + set({ error: 'Failed to fetch call priorities', isLoading: false }); + } + }, + fetchCallTypes: async () => { + // Only fetch if we don't have call types in the store + const { callTypes } = get(); + if (callTypes.length > 0) { + return; } - } - set({ calls: newCalls, callDispatches: pruned, isLoading: false, lastFetchedAt: Date.now() }); - } catch (error) { - set({ error: 'Failed to fetch calls', isLoading: false }); - } - }, - fetchCallPriorities: async () => { - set({ isLoading: true, error: null }); - try { - const response = await getCallPriorities(); - set({ callPriorities: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); - } catch (error) { - set({ error: 'Failed to fetch call priorities', isLoading: false }); - } - }, - fetchCallTypes: async () => { - // Only fetch if we don't have call types in the store - const { callTypes } = get(); - if (callTypes.length > 0) { - return; - } + set({ isLoading: true, error: null }); + try { + const response = await getCallTypes(); + set({ callTypes: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); + } catch (error) { + set({ error: 'Failed to fetch call types', isLoading: false }); + } + }, + fetchCallFormData: async () => { + if (get().isCallFormDataLoaded) { + return; + } - set({ isLoading: true, error: null }); - try { - const response = await getCallTypes(); - set({ callTypes: Array.isArray(response.Data) ? response.Data : [], isLoading: false }); - } catch (error) { - set({ error: 'Failed to fetch call types', isLoading: false }); - } - }, - fetchCallFormData: async () => { - if (get().isCallFormDataLoaded) { - return; - } + set({ isLoading: true, error: null }); + try { + const response = await getNewCallData(); + const data = response.Data; + set({ + callPriorities: Array.isArray(data?.Priorities) ? data.Priorities : [], + callTypes: Array.isArray(data?.CallTypes) ? data.CallTypes : [], + destinationPois: Array.isArray(data?.DestinationPois) ? data.DestinationPois : [], + poiTypes: Array.isArray(data?.PoiTypes) ? data.PoiTypes : [], + isCallFormDataLoaded: true, + isLoading: false, + }); + } catch (error) { + set({ error: 'Failed to fetch call form data', isLoading: false }); + } + }, + fetchCallDispatches: async (callIds: string[]) => { + const existing = get().callDispatches; + // Only fetch for call IDs that aren't already cached + const uncachedIds = callIds.filter((id) => !(id in existing)); + if (uncachedIds.length === 0) return; - set({ isLoading: true, error: null }); - try { - const response = await getNewCallData(); - const data = response.Data; - set({ - callPriorities: Array.isArray(data?.Priorities) ? data.Priorities : [], - callTypes: Array.isArray(data?.CallTypes) ? data.CallTypes : [], - destinationPois: Array.isArray(data?.DestinationPois) ? data.DestinationPois : [], - poiTypes: Array.isArray(data?.PoiTypes) ? data.PoiTypes : [], - isCallFormDataLoaded: true, - isLoading: false, - }); - } catch (error) { - set({ error: 'Failed to fetch call form data', isLoading: false }); - } - }, - fetchCallDispatches: async (callIds: string[]) => { - const existing = get().callDispatches; - // Only fetch for call IDs that aren't already cached - const uncachedIds = callIds.filter((id) => !(id in existing)); - if (uncachedIds.length === 0) return; + try { + const results = await Promise.all( + uncachedIds.map(async (callId) => { + try { + const result = await getCallExtraData(callId); + const dispatches = result?.Data?.Dispatches ?? []; + return { callId, dispatches: dispatches as DispatchedEventResultData[] }; + } catch { + return { callId, dispatches: [] as DispatchedEventResultData[] }; + } + }) + ); - try { - const results = await Promise.all( - uncachedIds.map(async (callId) => { - try { - const result = await getCallExtraData(callId); - const dispatches = result?.Data?.Dispatches ?? []; - return { callId, dispatches: dispatches as DispatchedEventResultData[] }; - } catch { - return { callId, dispatches: [] as DispatchedEventResultData[] }; + const newDispatches: Record = {}; + for (const { callId, dispatches } of results) { + newDispatches[callId] = dispatches; } - }) - ); - - const newDispatches: Record = {}; - for (const { callId, dispatches } of results) { - newDispatches[callId] = dispatches; - } - set({ callDispatches: { ...get().callDispatches, ...newDispatches } }); - } catch (error) { - console.warn('Failed to fetch call dispatches:', error); + set({ callDispatches: { ...get().callDispatches, ...newDispatches } }); + } catch (error) { + console.warn('Failed to fetch call dispatches:', error); + } + }, + }), + { + name: 'calls-storage', + storage: createJSONStorage(() => zustandStorage), + // Persist fetched data so the calls list works offline across app restarts. + // Exclude transient flags (isLoading, error, isInitialized). + partialize: (state) => ({ + calls: state.calls, + callPriorities: state.callPriorities, + callTypes: state.callTypes, + callDispatches: state.callDispatches, + lastFetchedAt: state.lastFetchedAt, + }), } - }, -})); + ) +); diff --git a/src/stores/command/__tests__/store.test.ts b/src/stores/command/__tests__/store.test.ts new file mode 100644 index 0000000..a98b6ce --- /dev/null +++ b/src/stores/command/__tests__/store.test.ts @@ -0,0 +1,497 @@ +import { act } from '@testing-library/react-native'; + +const mockSetActiveCall = jest.fn(() => Promise.resolve()); +const mockAddEvent = jest.fn(() => 'event-id'); + +let mockOnline = true; + +jest.mock('@/lib/storage', () => ({ + zustandStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { + getState: jest.fn(() => ({ setActiveCall: mockSetActiveCall })), + }, +})); + +jest.mock('@/stores/offline-queue/store', () => ({ + useOfflineQueueStore: { + getState: jest.fn(() => ({ + isConnected: mockOnline, + isNetworkReachable: mockOnline, + addEvent: mockAddEvent, + })), + }, +})); + +const mockEstablishCommand = jest.fn(); +const mockGetCommandBoard = jest.fn(); +const mockCloseCommand = jest.fn(); +const mockGetAccountability = jest.fn(); +const mockEvaluateAccountability = jest.fn(); + +const mockSaveCommandNode = jest.fn(); +const mockDeleteCommandNode = jest.fn(); +const mockAssignResource = jest.fn(); +const mockMoveResource = jest.fn(); +const mockReleaseResource = jest.fn(); +const mockSaveObjective = jest.fn(); +const mockCompleteObjective = jest.fn(); + +jest.mock('@/api/incidentCommand/incidentCommand', () => ({ + establishCommand: (...args: unknown[]) => mockEstablishCommand(...args), + getCommandBoard: (...args: unknown[]) => mockGetCommandBoard(...args), + closeCommand: (...args: unknown[]) => mockCloseCommand(...args), + getAccountability: (...args: unknown[]) => mockGetAccountability(...args), + evaluateAccountability: (...args: unknown[]) => mockEvaluateAccountability(...args), + saveCommandNode: (...args: unknown[]) => mockSaveCommandNode(...args), + deleteCommandNode: (...args: unknown[]) => mockDeleteCommandNode(...args), + assignResource: (...args: unknown[]) => mockAssignResource(...args), + moveResource: (...args: unknown[]) => mockMoveResource(...args), + releaseResource: (...args: unknown[]) => mockReleaseResource(...args), + saveObjective: (...args: unknown[]) => mockSaveObjective(...args), + completeObjective: (...args: unknown[]) => mockCompleteObjective(...args), +})); + +const mockCreateAdHocUnit = jest.fn(); +const mockGetAdHocUnits = jest.fn(); +const mockReleaseAdHocUnit = jest.fn(); + +const mockCreateAdHocPersonnel = jest.fn(); +const mockGetAdHocPersonnel = jest.fn(); +const mockReleaseAdHocPersonnel = jest.fn(); + +jest.mock('@/api/incidentCommand/incidentVoice', () => ({ + createIncidentChannel: jest.fn().mockResolvedValue({ Data: { DepartmentVoiceChannelId: 'ch-1', Name: 'Tactical' } }), + getChannelsForCall: jest.fn().mockResolvedValue({ Data: [] }), + closeIncidentChannels: jest.fn().mockResolvedValue({ Data: true }), + logTransmission: jest.fn().mockResolvedValue({ Data: null }), + getTransmissionLog: jest.fn().mockResolvedValue({ Data: [] }), +})); + +jest.mock('@/api/incidentCommand/incidentResources', () => ({ + createAdHocUnit: (...args: unknown[]) => mockCreateAdHocUnit(...args), + getAdHocUnits: (...args: unknown[]) => mockGetAdHocUnits(...args), + releaseAdHocUnit: (...args: unknown[]) => mockReleaseAdHocUnit(...args), + createAdHocPersonnel: (...args: unknown[]) => mockCreateAdHocPersonnel(...args), + getAdHocPersonnel: (...args: unknown[]) => mockGetAdHocPersonnel(...args), + releaseAdHocPersonnel: (...args: unknown[]) => mockReleaseAdHocPersonnel(...args), +})); + +const mockAssignIncidentRole = jest.fn(); +const mockRemoveIncidentRole = jest.fn(); + +jest.mock('@/api/incidentCommand/incidentRoles', () => ({ + assignIncidentRole: (...args: unknown[]) => mockAssignIncidentRole(...args), + removeIncidentRole: (...args: unknown[]) => mockRemoveIncidentRole(...args), +})); + +const mockGetSyncBundle = jest.fn(); + +jest.mock('@/api/incidentCommand/sync', () => ({ + getSyncBundle: (...args: unknown[]) => mockGetSyncBundle(...args), +})); + +import { CommandNodeType, IncidentRoleType, ResourceAssignmentKind, TacticalObjectiveStatus, TacticalObjectiveType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { useCommandStore } from '../store'; + +const serverBoard = (callId: number) => ({ + Command: { + IncidentCommandId: `cmd-${callId}`, + DepartmentId: 1, + CallId: callId, + EstablishedByUserId: 'u1', + EstablishedOn: '2026-07-19T10:00:00Z', + CurrentCommanderUserId: 'u1', + IcsLevel: 1, + Status: 0, + }, + Nodes: [], + Assignments: [], + Objectives: [], + Timers: [], + Annotations: [], + Accountability: [], + Roles: [], +}); + +describe('Command Store (server-backed)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnline = true; + useCommandStore.setState({ boards: {}, activeCallId: null, lastSyncTimestampMs: 0, isRefreshing: false }); + + mockEstablishCommand.mockResolvedValue({ Data: serverBoard(101).Command }); + mockGetCommandBoard.mockResolvedValue({ Data: serverBoard(101) }); + mockGetAdHocUnits.mockResolvedValue({ Data: [] }); + mockGetAdHocPersonnel.mockResolvedValue({ Data: [] }); + mockCreateAdHocPersonnel.mockResolvedValue({ Data: {} }); + mockReleaseAdHocPersonnel.mockResolvedValue({ Data: true }); + mockCloseCommand.mockResolvedValue({ Data: serverBoard(101).Command }); + mockAssignIncidentRole.mockResolvedValue({ Data: {} }); + mockRemoveIncidentRole.mockResolvedValue({ Data: true }); + mockCreateAdHocUnit.mockResolvedValue({ Data: {} }); + mockReleaseAdHocUnit.mockResolvedValue({ Data: true }); + mockGetAccountability.mockResolvedValue({ Data: [] }); + mockEvaluateAccountability.mockResolvedValue({ Data: [] }); + mockGetSyncBundle.mockResolvedValue({ Data: { ServerTimestampMs: 123, Boards: [], AdHocUnits: [], AdHocPersonnel: [] } }); + mockSaveCommandNode.mockResolvedValue({ Data: {} }); + mockDeleteCommandNode.mockResolvedValue({ Data: true }); + mockAssignResource.mockResolvedValue({ Data: { RequirementsWarning: false } }); + mockMoveResource.mockResolvedValue({ Data: { RequirementsWarning: false } }); + mockReleaseResource.mockResolvedValue({ Data: true }); + mockSaveObjective.mockResolvedValue({ Data: {} }); + mockCompleteObjective.mockResolvedValue({ Data: {} }); + }); + + it('startCommand establishes on the server and loads the board', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + + expect(mockEstablishCommand).toHaveBeenCalledWith({ CallId: 101, CommandDefinitionId: null }); + expect(mockGetCommandBoard).toHaveBeenCalledWith('101'); + const state = useCommandStore.getState(); + expect(state.boards['101'].board?.Command.IncidentCommandId).toBe('cmd-101'); + expect(state.boards['101'].isProvisional).toBe(false); + expect(state.activeCallId).toBe('101'); + expect(mockSetActiveCall).toHaveBeenCalledWith('101'); + }); + + it('startCommand offline creates a provisional board and queues the establish event', async () => { + mockOnline = false; + + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + + expect(mockEstablishCommand).not.toHaveBeenCalled(); + expect(mockAddEvent).toHaveBeenCalledWith('establish_command', { callId: '101', commandDefinitionId: null }); + const state = useCommandStore.getState(); + expect(state.boards['101'].isProvisional).toBe(true); + expect(state.boards['101'].board).toBeNull(); + }); + + it('supports multiple concurrent boards and switching', async () => { + mockGetCommandBoard.mockImplementation((callId: unknown) => Promise.resolve({ Data: serverBoard(parseInt(String(callId), 10)) })); + + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().startCommand('102'); + await useCommandStore.getState().switchCommand('101'); + }); + + const state = useCommandStore.getState(); + expect(Object.keys(state.boards).sort()).toEqual(['101', '102']); + expect(state.activeCallId).toBe('101'); + expect(mockSetActiveCall).toHaveBeenLastCalledWith('101'); + }); + + it('endCommand closes on the server and activates the next open board', async () => { + mockGetCommandBoard.mockImplementation((callId: unknown) => Promise.resolve({ Data: serverBoard(parseInt(String(callId), 10)) })); + + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().startCommand('102'); + await useCommandStore.getState().endCommand('102'); + }); + + expect(mockCloseCommand).toHaveBeenCalledWith('cmd-102'); + const state = useCommandStore.getState(); + expect(state.boards['102']).toBeUndefined(); + expect(state.activeCallId).toBe('101'); + }); + + it('endCommand offline queues the close event', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + + mockOnline = false; + await act(async () => { + await useCommandStore.getState().endCommand('101'); + }); + + expect(mockAddEvent).toHaveBeenCalledWith('close_command', { callId: '101', incidentCommandId: 'cmd-101' }); + expect(useCommandStore.getState().boards['101']).toBeUndefined(); + expect(mockSetActiveCall).toHaveBeenLastCalledWith(null); + }); + + it('assignRole calls the IncidentRoles API and refreshes the board', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().assignRole('101', IncidentRoleType.SafetyOfficer, 'user-9'); + }); + + expect(mockAssignIncidentRole).toHaveBeenCalledWith(expect.objectContaining({ CallId: 101, RoleType: IncidentRoleType.SafetyOfficer, UserId: 'user-9' })); + }); + + it('assignRole offline queues the event and applies an optimistic local row', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + + mockOnline = false; + await act(async () => { + await useCommandStore.getState().assignRole('101', IncidentRoleType.IncidentCommander, 'user-1'); + }); + + expect(mockAddEvent).toHaveBeenCalledWith('assign_incident_role', { callId: '101', roleType: IncidentRoleType.IncidentCommander, userId: 'user-1' }); + const roles = useCommandStore.getState().boards['101'].board?.Roles ?? []; + expect(roles).toHaveLength(1); + expect(roles[0].IncidentRoleAssignmentId.startsWith('local-')).toBe(true); + }); + + it('removeRole for a local-only assignment does not queue or call the API', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + mockOnline = false; + await act(async () => { + await useCommandStore.getState().assignRole('101', IncidentRoleType.IncidentCommander, 'user-1'); + }); + const localId = useCommandStore.getState().boards['101'].board!.Roles[0].IncidentRoleAssignmentId; + mockAddEvent.mockClear(); + + await act(async () => { + await useCommandStore.getState().removeRole('101', localId); + }); + + expect(mockRemoveIncidentRole).not.toHaveBeenCalled(); + expect(mockAddEvent).not.toHaveBeenCalled(); + expect(useCommandStore.getState().boards['101'].board?.Roles).toHaveLength(0); + }); + + it('addAdHocUnit calls the IncidentResources API when online and queues offline', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().addAdHocUnit('101', 'Mutual Aid Engine', 'Engine'); + }); + expect(mockCreateAdHocUnit).toHaveBeenCalledWith({ CallId: 101, Name: 'Mutual Aid Engine', Type: 'Engine' }); + + mockOnline = false; + await act(async () => { + await useCommandStore.getState().addAdHocUnit('101', 'Offline Tender', 'Tender'); + }); + expect(mockAddEvent).toHaveBeenCalledWith('create_adhoc_unit', { callId: '101', name: 'Offline Tender', type: 'Tender' }); + expect(useCommandStore.getState().boards['101'].adHocUnits.some((u) => u.Name === 'Offline Tender')).toBe(true); + }); + + it('syncFromServer hydrates boards from the Sync Bundle and stores the cursor', async () => { + mockGetSyncBundle.mockResolvedValue({ + Data: { + ServerTimestampMs: 999, + Boards: [serverBoard(202)], + AdHocUnits: [{ IncidentAdHocUnitId: 'ah-1', DepartmentId: 1, CallId: 202, Name: 'Aid 1', CreatedByUserId: 'u1', CreatedOn: '2026-07-19T10:00:00Z' }], + AdHocPersonnel: [], + }, + }); + + await act(async () => { + await useCommandStore.getState().syncFromServer(); + }); + + const state = useCommandStore.getState(); + expect(state.boards['202'].board?.Command.IncidentCommandId).toBe('cmd-202'); + expect(state.boards['202'].adHocUnits).toHaveLength(1); + expect(state.lastSyncTimestampMs).toBe(999); + }); + + it('refreshAccountability evaluates then reloads PAR data', async () => { + mockGetAccountability.mockResolvedValue({ Data: [{ UserId: 'u1', FullName: 'Smith', NeedsCheckIn: false, MinutesRemaining: 10, Status: 'Green', DurationMinutes: 20, WarningThresholdMinutes: 5 }] }); + + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().refreshAccountability('101'); + }); + + expect(mockEvaluateAccountability).toHaveBeenCalledWith('101'); + expect(useCommandStore.getState().boards['101'].board?.Accountability).toHaveLength(1); + }); + + describe('command structure lanes', () => { + it('addNode saves the lane against the server command', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().addNode('101', 'Division A', CommandNodeType.Division); + }); + + expect(mockSaveCommandNode).toHaveBeenCalledWith(expect.objectContaining({ IncidentCommandId: 'cmd-101', CallId: 101, Name: 'Division A', NodeType: CommandNodeType.Division })); + }); + + it('addNode offline queues the event and adds an optimistic lane', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + mockOnline = false; + + await act(async () => { + await useCommandStore.getState().addNode('101', 'Staging', CommandNodeType.Staging); + }); + + expect(mockAddEvent).toHaveBeenCalledWith('save_command_node', { callId: '101', name: 'Staging', nodeType: CommandNodeType.Staging }); + const nodes = useCommandStore.getState().boards['101'].board?.Nodes ?? []; + expect(nodes).toHaveLength(1); + expect(nodes[0].CommandStructureNodeId.startsWith('local-')).toBe(true); + }); + + it('assignResourceToNode returns a blocked outcome when the lane forces unmet requirements', async () => { + // Forced rejection: HTTP 200, no Data, reason in Message + mockAssignResource.mockResolvedValue({ Data: null, Status: 'Failure', Message: "Unit 'Brush 1' does not match the unit types required by lane 'Entry Group'." }); + + let outcome: import("../store").AssignmentOutcome | null = null; + await act(async () => { + await useCommandStore.getState().startCommand('101'); + outcome = await useCommandStore.getState().assignResourceToNode('101', 'node-1', ResourceAssignmentKind.RealUnit, 'unit-9'); + }); + + expect(outcome).toEqual({ blocked: "Unit 'Brush 1' does not match the unit types required by lane 'Entry Group'." }); + }); + + it('assignResourceToNode returns the requirements warning message', async () => { + mockAssignResource.mockResolvedValue({ Data: { RequirementsWarning: true, RequirementsWarningMessage: 'Needs 2 personnel' } }); + + let warning: import("../store").AssignmentOutcome | null = null; + await act(async () => { + await useCommandStore.getState().startCommand('101'); + warning = await useCommandStore.getState().assignResourceToNode('101', 'node-1', ResourceAssignmentKind.RealUnit, 'unit-5'); + }); + + expect(mockAssignResource).toHaveBeenCalledWith(expect.objectContaining({ CommandStructureNodeId: 'node-1', ResourceKind: ResourceAssignmentKind.RealUnit, ResourceId: 'unit-5' })); + expect(warning).toEqual({ warning: 'Needs 2 personnel' }); + }); + + it('moveResourceAssignment uses the move endpoint and returns requirements warnings', async () => { + const board = { + ...serverBoard(101), + Assignments: [ + { + ResourceAssignmentId: 'assignment-1', + IncidentCommandId: 'cmd-101', + DepartmentId: 1, + CallId: 101, + CommandStructureNodeId: 'node-1', + ResourceKind: ResourceAssignmentKind.RealUnit, + ResourceId: 'unit-5', + AssignedByUserId: 'u1', + AssignedOn: '2026-07-19T10:00:00Z', + RequirementsWarning: false, + }, + ], + }; + useCommandStore.setState({ boards: { '101': { callId: '101', board, adHocUnits: [], adHocPersonnel: [], isProvisional: false, lastRefreshed: null } }, activeCallId: '101' }); + mockMoveResource.mockResolvedValue({ Data: { RequirementsWarning: true, RequirementsWarningMessage: 'Supervisor required' } }); + + let warning: import("../store").AssignmentOutcome | null = null; + await act(async () => { + warning = await useCommandStore.getState().moveResourceAssignment('101', 'assignment-1', 'node-2'); + }); + + expect(mockMoveResource).toHaveBeenCalledWith({ ResourceAssignmentId: 'assignment-1', TargetNodeId: 'node-2' }); + expect(warning).toEqual({ warning: 'Supervisor required' }); + }); + + it('moveResourceAssignment updates the lane immediately and queues the move offline', async () => { + const board = { + ...serverBoard(101), + Assignments: [ + { + ResourceAssignmentId: 'assignment-1', + IncidentCommandId: 'cmd-101', + DepartmentId: 1, + CallId: 101, + CommandStructureNodeId: 'node-1', + ResourceKind: ResourceAssignmentKind.RealPersonnel, + ResourceId: 'user-5', + AssignedByUserId: 'u1', + AssignedOn: '2026-07-19T10:00:00Z', + RequirementsWarning: false, + }, + ], + }; + useCommandStore.setState({ boards: { '101': { callId: '101', board, adHocUnits: [], adHocPersonnel: [], isProvisional: false, lastRefreshed: null } }, activeCallId: '101' }); + mockOnline = false; + + await act(async () => { + await useCommandStore.getState().moveResourceAssignment('101', 'assignment-1', 'node-2'); + }); + + expect(useCommandStore.getState().boards['101'].board?.Assignments[0].CommandStructureNodeId).toBe('node-2'); + expect(mockAddEvent).toHaveBeenCalledWith('move_command_resource', { callId: '101', resourceAssignmentId: 'assignment-1', targetNodeId: 'node-2' }); + expect(mockMoveResource).not.toHaveBeenCalled(); + }); + + it('deleteNode drops local lanes without calling the API', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + mockOnline = false; + await act(async () => { + await useCommandStore.getState().addNode('101', 'Staging', CommandNodeType.Staging); + }); + const nodeId = useCommandStore.getState().boards['101'].board!.Nodes[0].CommandStructureNodeId; + mockAddEvent.mockClear(); + + await act(async () => { + await useCommandStore.getState().deleteNode('101', nodeId); + }); + + expect(mockDeleteCommandNode).not.toHaveBeenCalled(); + expect(mockAddEvent).not.toHaveBeenCalled(); + expect(useCommandStore.getState().boards['101'].board?.Nodes).toHaveLength(0); + }); + }); + + describe('tactical objectives', () => { + it('addObjective saves against the server command', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().addObjective('101', 'Primary search', TacticalObjectiveType.Benchmark); + }); + + expect(mockSaveObjective).toHaveBeenCalledWith(expect.objectContaining({ IncidentCommandId: 'cmd-101', CallId: 101, Name: 'Primary search', ObjectiveType: TacticalObjectiveType.Benchmark })); + }); + + it('completeObjectiveEntry optimistically completes and calls the API', async () => { + const boardWithObjective = serverBoard(101) as any; + boardWithObjective.Objectives = [ + { TacticalObjectiveId: 'obj-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, Name: 'Vent roof', ObjectiveType: 0, Status: TacticalObjectiveStatus.Pending, AutoPopulated: false, SortOrder: 0 }, + ]; + mockGetCommandBoard.mockResolvedValue({ Data: boardWithObjective }); + + await act(async () => { + await useCommandStore.getState().startCommand('101'); + await useCommandStore.getState().completeObjectiveEntry('101', 'obj-1'); + }); + + expect(mockCompleteObjective).toHaveBeenCalledWith('obj-1'); + expect(useCommandStore.getState().boards['101'].board?.Objectives[0].Status).toBe(TacticalObjectiveStatus.Complete); + }); + + it('addObjective offline queues the event with an optimistic pending row', async () => { + await act(async () => { + await useCommandStore.getState().startCommand('101'); + }); + mockOnline = false; + + await act(async () => { + await useCommandStore.getState().addObjective('101', 'Utilities secured', TacticalObjectiveType.General); + }); + + expect(mockAddEvent).toHaveBeenCalledWith('save_objective', { callId: '101', name: 'Utilities secured', objectiveType: TacticalObjectiveType.General }); + const objectives = useCommandStore.getState().boards['101'].board?.Objectives ?? []; + expect(objectives).toHaveLength(1); + expect(objectives[0].Status).toBe(TacticalObjectiveStatus.Pending); + }); + }); +}); diff --git a/src/stores/command/store.ts b/src/stores/command/store.ts new file mode 100644 index 0000000..34c65a9 --- /dev/null +++ b/src/stores/command/store.ts @@ -0,0 +1,1150 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { + acknowledgeIncidentTimer, + assignResource, + closeCommand, + completeObjective, + deleteCommandNode, + establishCommand, + evaluateAccountability, + getAccountability, + getCommandBoard, + getCommandTimeline, + moveResource, + releaseResource, + saveCommandNode, + saveObjective, + startIncidentTimer, + transferCommand, +} from '@/api/incidentCommand/incidentCommand'; +import { createAdHocPersonnel, createAdHocUnit, getAdHocPersonnel, getAdHocUnits, releaseAdHocPersonnel, releaseAdHocUnit } from '@/api/incidentCommand/incidentResources'; +import { assignIncidentRole, removeIncidentRole } from '@/api/incidentCommand/incidentRoles'; +import { closeIncidentChannels, createIncidentChannel, getChannelsForCall, getTransmissionLog, logTransmission } from '@/api/incidentCommand/incidentVoice'; +import { getSyncBundle } from '@/api/incidentCommand/sync'; +import { type LaneLimits } from '@/components/command/add-lane-sheet'; +import { logger } from '@/lib/logging'; +import { zustandStorage } from '@/lib/storage'; +import { uuidv4 } from '@/lib/utils'; +import { QueuedEventType } from '@/models/offline-queue/queued-event'; +import { + type CommandLogEntry, + type CommandNodeType, + type IncidentAdHocPersonnel, + type IncidentAdHocUnit, + type IncidentCommandBoard, + type IncidentRoleType, + type IncidentVoiceChannel, + type ResourceAssignmentKind, + TacticalObjectiveStatus, + type TacticalObjectiveType, + type VoiceTransmissionLog, +} from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useOfflineQueueStore } from '@/stores/offline-queue/store'; + +/** Result of a lane assign/move: advisory requirements warning, or a forced-requirements rejection. */ +export interface AssignmentOutcome { + warning?: string; + blocked?: string; +} + +/** Locally-tracked state for one incident's command board. */ +export interface CommandBoardState { + callId: string; + /** Server board — null only for a provisional board established offline. */ + board: IncidentCommandBoard | null; + /** Ad-hoc (non-Resgrid) units tracked against this incident. */ + adHocUnits: IncidentAdHocUnit[]; + /** Ad-hoc (external / temp / volunteer) personnel tracked against this incident. */ + adHocPersonnel: IncidentAdHocPersonnel[]; + /** True when the command was established offline and hasn't reached the server yet. */ + isProvisional: boolean; + lastRefreshed: number | null; + /** Server-side auto-logged incident log (fetched on demand, newest first). */ + timeline?: CommandLogEntry[]; + /** Open on-demand PTT channels for this incident. */ + voiceChannels?: IncidentVoiceChannel[]; + /** PTT transmission log (who keyed up when), newest first. */ + transmissionLog?: VoiceTransmissionLog[]; +} + +interface CommandState { + /** One board per call — an IC can run multiple incidents at once. */ + boards: Record; + /** The board currently shown on the Command Board tab. */ + activeCallId: string | null; + /** Server sync cursor from the last Sync Bundle/Changes pull (unix epoch ms). */ + lastSyncTimestampMs: number; + isRefreshing: boolean; + + startCommand: (callId: string, commandDefinitionId?: number | null) => Promise; + switchCommand: (callId: string) => Promise; + endCommand: (callId: string) => Promise; + refreshBoard: (callId: string) => Promise; + /** Pull the full active-incident bundle from the server (shift-start / reconnect). */ + syncFromServer: () => Promise; + + assignRole: (callId: string, roleType: IncidentRoleType, userId: string) => Promise; + removeRole: (callId: string, incidentRoleAssignmentId: string) => Promise; + addAdHocUnit: (callId: string, name: string, type?: string) => Promise; + releaseAdHocUnitEntry: (callId: string, incidentAdHocUnitId: string) => Promise; + addAdHocPersonnel: (callId: string, name: string, role?: string, agency?: string) => Promise; + releaseAdHocPersonnelEntry: (callId: string, incidentAdHocPersonnelId: string) => Promise; + refreshAccountability: (callId: string) => Promise; + + addNode: (callId: string, name: string, nodeType: CommandNodeType, color?: string, limits?: LaneLimits) => Promise; + deleteNode: (callId: string, commandStructureNodeId: string) => Promise; + /** + * Assign a resource to a lane. Returns null on clean success, { warning } when the lane's + * requirements are advisory-violated, or { blocked } when the lane forces requirements the + * resource doesn't meet (the assignment was rejected server-side). + */ + assignResourceToNode: (callId: string, commandStructureNodeId: string, resourceKind: ResourceAssignmentKind, resourceId: string) => Promise; + /** Move an existing resource assignment to another lane. Same outcome semantics as assignResourceToNode. */ + moveResourceAssignment: (callId: string, resourceAssignmentId: string, targetNodeId: string) => Promise; + releaseResourceAssignment: (callId: string, resourceAssignmentId: string) => Promise; + addObjective: (callId: string, name: string, objectiveType: TacticalObjectiveType) => Promise; + completeObjectiveEntry: (callId: string, tacticalObjectiveId: string) => Promise; + + /** Start a repeating incident timer (PAR check, benchmark reminder). Online-only. */ + startTimer: (callId: string, name: string, intervalSeconds: number) => Promise; + /** Acknowledge a due timer — the server resets its next-due time. Online-only. */ + acknowledgeTimer: (callId: string, incidentTimerId: string) => Promise; + /** Transfer command of this incident to another user. Online-only. */ + transferIncidentCommand: (callId: string, toUserId: string) => Promise; + /** Pull the server-side incident log for this board. */ + fetchTimeline: (callId: string) => Promise; + + /** Open tactical/command PTT channels for this incident (voice addon). Online-only. */ + createVoiceChannel: (callId: string, name: string) => Promise; + fetchVoiceChannels: (callId: string) => Promise; + closeVoiceChannels: (callId: string) => Promise; + fetchTransmissionLog: (callId: string) => Promise; + /** Record one completed local PTT transmission against a channel. */ + recordTransmission: (callId: string, departmentVoiceChannelId: string, startedOn: string, endedOn: string) => Promise; +} + +const isOffline = () => { + const queue = useOfflineQueueStore.getState(); + return !queue.isConnected || !queue.isNetworkReachable; +}; + +const queueEvent = (type: QueuedEventType, data: Record) => useOfflineQueueStore.getState().addEvent(type, data); + +const toNumericCallId = (callId: string) => { + const parsed = parseInt(callId, 10); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const localId = (prefix: string) => `local-${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const emptyBoardState = (callId: string, provisional: boolean): CommandBoardState => ({ + callId, + board: null, + adHocUnits: [], + adHocPersonnel: [], + isProvisional: provisional, + lastRefreshed: null, +}); + +export const useCommandStore = create()( + persist( + (set, get) => ({ + boards: {}, + activeCallId: null, + lastSyncTimestampMs: 0, + isRefreshing: false, + + startCommand: async (callId: string, commandDefinitionId?: number | null) => { + const existing = get().boards[callId]; + + if (!existing) { + if (isOffline()) { + // Establish offline: provisional board now, replayed to the server on reconnect + queueEvent(QueuedEventType.ESTABLISH_COMMAND, { callId, commandDefinitionId: commandDefinitionId ?? null }); + set({ boards: { ...get().boards, [callId]: emptyBoardState(callId, true) }, activeCallId: callId }); + } else { + try { + await establishCommand({ CallId: toNumericCallId(callId), CommandDefinitionId: commandDefinitionId ?? null }); + } catch (error) { + logger.warn({ + message: 'EstablishCommand failed — queueing for retry and continuing with a provisional board', + context: { error, callId }, + }); + queueEvent(QueuedEventType.ESTABLISH_COMMAND, { callId, commandDefinitionId: commandDefinitionId ?? null }); + } + set({ boards: { ...get().boards, [callId]: emptyBoardState(callId, false) }, activeCallId: callId }); + await get().refreshBoard(callId); + } + } else { + set({ activeCallId: callId }); + } + + // Keep the core active call in sync so call summaries and badges follow the board + await useCoreStore.getState().setActiveCall(callId); + }, + + switchCommand: async (callId: string) => { + if (!get().boards[callId]) { + return; + } + set({ activeCallId: callId }); + await useCoreStore.getState().setActiveCall(callId); + // Best-effort refresh; harmless offline + get() + .refreshBoard(callId) + .catch(() => {}); + }, + + endCommand: async (callId: string) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId; + + if (incidentCommandId && !entry?.isProvisional) { + if (isOffline()) { + queueEvent(QueuedEventType.CLOSE_COMMAND, { callId, incidentCommandId }); + } else { + try { + await closeCommand(incidentCommandId); + } catch (error) { + logger.warn({ + message: 'CloseCommand failed — queueing for retry', + context: { error, callId, incidentCommandId }, + }); + queueEvent(QueuedEventType.CLOSE_COMMAND, { callId, incidentCommandId }); + } + } + } + // A provisional board that never reached the server just gets dropped locally. + + const boards = { ...get().boards }; + delete boards[callId]; + const remaining = Object.keys(boards); + const nextActive = get().activeCallId === callId ? (remaining.length > 0 ? remaining[0] : null) : get().activeCallId; + + set({ boards, activeCallId: nextActive }); + await useCoreStore.getState().setActiveCall(nextActive); + }, + + refreshBoard: async (callId: string) => { + if (isOffline()) { + return; + } + try { + set({ isRefreshing: true }); + const [boardResult, adHocResult, adHocPersonnelResult] = await Promise.all([getCommandBoard(callId), getAdHocUnits(callId), getAdHocPersonnel(callId)]); + const existing = get().boards[callId] ?? emptyBoardState(callId, false); + set({ + boards: { + ...get().boards, + [callId]: { + ...existing, + board: boardResult.Data, + adHocUnits: (adHocResult.Data ?? []).filter((u) => !u.ReleasedOn), + adHocPersonnel: (adHocPersonnelResult.Data ?? []).filter((p) => !p.ReleasedOn), + isProvisional: false, + lastRefreshed: Date.now(), + }, + }, + isRefreshing: false, + }); + } catch (error) { + set({ isRefreshing: false }); + logger.warn({ + message: 'Failed to refresh command board', + context: { error, callId }, + }); + } + }, + + syncFromServer: async () => { + if (isOffline()) { + return; + } + try { + set({ isRefreshing: true }); + const bundle = await getSyncBundle(true); + const boards = { ...get().boards }; + + for (const board of bundle.Data?.Boards ?? []) { + const callId = String(board.Command.CallId); + const existing = boards[callId] ?? emptyBoardState(callId, false); + boards[callId] = { + ...existing, + board, + adHocUnits: (bundle.Data?.AdHocUnits ?? []).filter((u) => String(u.CallId) === callId && !u.ReleasedOn), + adHocPersonnel: (bundle.Data?.AdHocPersonnel ?? []).filter((p) => String(p.CallId) === callId && !p.ReleasedOn), + isProvisional: false, + lastRefreshed: Date.now(), + }; + } + + set({ + boards, + lastSyncTimestampMs: bundle.Data?.ServerTimestampMs ?? get().lastSyncTimestampMs, + isRefreshing: false, + }); + } catch (error) { + set({ isRefreshing: false }); + logger.warn({ + message: 'Failed to sync command boards from server', + context: { error }, + }); + } + }, + + assignRole: async (callId: string, roleType: IncidentRoleType, userId: string) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId ?? ''; + + const applyOptimistic = () => { + const current = get().boards[callId]; + if (!current?.board) return; + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Roles: [ + ...current.board.Roles, + { + IncidentRoleAssignmentId: localId('role'), + IncidentCommandId: incidentCommandId, + DepartmentId: 0, + CallId: toNumericCallId(callId), + UserId: userId, + RoleType: roleType, + AssignedByUserId: '', + AssignedOn: new Date().toISOString(), + }, + ], + }, + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.ASSIGN_INCIDENT_ROLE, { callId, roleType, userId }); + applyOptimistic(); + return; + } + + try { + await assignIncidentRole({ IncidentCommandId: incidentCommandId, CallId: toNumericCallId(callId), UserId: userId, RoleType: roleType }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'AssignRole failed — queueing for retry', + context: { error, callId, roleType, userId }, + }); + queueEvent(QueuedEventType.ASSIGN_INCIDENT_ROLE, { callId, roleType, userId }); + applyOptimistic(); + } + }, + + removeRole: async (callId: string, incidentRoleAssignmentId: string) => { + // Optimistically drop the row locally + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { ...current.board, Roles: current.board.Roles.filter((r) => r.IncidentRoleAssignmentId !== incidentRoleAssignmentId) }, + }, + }, + }); + } + + // Locally-created assignments never reached the server — nothing to replay + if (incidentRoleAssignmentId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.REMOVE_INCIDENT_ROLE, { callId, incidentRoleAssignmentId }); + return; + } + + try { + await removeIncidentRole(incidentRoleAssignmentId); + } catch (error) { + logger.warn({ + message: 'RemoveRole failed — queueing for retry', + context: { error, callId, incidentRoleAssignmentId }, + }); + queueEvent(QueuedEventType.REMOVE_INCIDENT_ROLE, { callId, incidentRoleAssignmentId }); + } + }, + + addAdHocUnit: async (callId: string, name: string, type?: string) => { + const applyOptimistic = () => { + const current = get().boards[callId] ?? emptyBoardState(callId, true); + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + adHocUnits: [ + ...current.adHocUnits, + { + IncidentAdHocUnitId: localId('unit'), + DepartmentId: 0, + CallId: toNumericCallId(callId), + Name: name, + Type: type ?? null, + CreatedByUserId: '', + CreatedOn: new Date().toISOString(), + } as IncidentAdHocUnit, + ], + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.CREATE_ADHOC_UNIT, { callId, name, type }); + applyOptimistic(); + return; + } + + try { + await createAdHocUnit({ CallId: toNumericCallId(callId), Name: name, Type: type }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'CreateAdHocUnit failed — queueing for retry', + context: { error, callId, name }, + }); + queueEvent(QueuedEventType.CREATE_ADHOC_UNIT, { callId, name, type }); + applyOptimistic(); + } + }, + + releaseAdHocUnitEntry: async (callId: string, incidentAdHocUnitId: string) => { + const current = get().boards[callId]; + if (current) { + set({ + boards: { + ...get().boards, + [callId]: { ...current, adHocUnits: current.adHocUnits.filter((u) => u.IncidentAdHocUnitId !== incidentAdHocUnitId) }, + }, + }); + } + + if (incidentAdHocUnitId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.RELEASE_ADHOC_UNIT, { callId, incidentAdHocUnitId }); + return; + } + + try { + await releaseAdHocUnit(incidentAdHocUnitId); + } catch (error) { + logger.warn({ + message: 'ReleaseAdHocUnit failed — queueing for retry', + context: { error, callId, incidentAdHocUnitId }, + }); + queueEvent(QueuedEventType.RELEASE_ADHOC_UNIT, { callId, incidentAdHocUnitId }); + } + }, + + addNode: async (callId: string, name: string, nodeType: CommandNodeType, color?: string, limits?: LaneLimits) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId ?? ''; + + const applyOptimistic = () => { + const current = get().boards[callId]; + if (!current?.board) return; + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Nodes: [ + ...current.board.Nodes, + { + CommandStructureNodeId: localId('node'), + IncidentCommandId: incidentCommandId, + DepartmentId: 0, + CallId: toNumericCallId(callId), + NodeType: nodeType, + Name: name, + Color: color ?? null, + MinUnits: limits?.minUnits ?? 0, + MaxUnits: limits?.maxUnits ?? 0, + MinUnitPersonnel: limits?.minUnitPersonnel ?? 0, + MaxUnitPersonnel: limits?.maxUnitPersonnel ?? 0, + MinTimeInRole: limits?.minTimeInRole ?? 0, + MaxTimeInRole: limits?.maxTimeInRole ?? 0, + SortOrder: current.board.Nodes.length, + }, + ], + }, + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.SAVE_COMMAND_NODE, { callId, name, nodeType, color, limits }); + applyOptimistic(); + return; + } + + try { + await saveCommandNode({ + IncidentCommandId: incidentCommandId, + CallId: toNumericCallId(callId), + Name: name, + NodeType: nodeType, + Color: color, + MinUnits: limits?.minUnits ?? 0, + MaxUnits: limits?.maxUnits ?? 0, + MinUnitPersonnel: limits?.minUnitPersonnel ?? 0, + MaxUnitPersonnel: limits?.maxUnitPersonnel ?? 0, + MinTimeInRole: limits?.minTimeInRole ?? 0, + MaxTimeInRole: limits?.maxTimeInRole ?? 0, + SortOrder: entry?.board?.Nodes.length ?? 0, + }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'SaveNode failed — queueing for retry', + context: { error, callId, name }, + }); + queueEvent(QueuedEventType.SAVE_COMMAND_NODE, { callId, name, nodeType, color, limits }); + applyOptimistic(); + } + }, + + deleteNode: async (callId: string, commandStructureNodeId: string) => { + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Nodes: current.board.Nodes.filter((n) => n.CommandStructureNodeId !== commandStructureNodeId), + Assignments: current.board.Assignments.filter((a) => a.CommandStructureNodeId !== commandStructureNodeId), + }, + }, + }, + }); + } + + if (commandStructureNodeId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.DELETE_COMMAND_NODE, { callId, commandStructureNodeId }); + return; + } + + try { + await deleteCommandNode(commandStructureNodeId); + } catch (error) { + logger.warn({ + message: 'DeleteNode failed — queueing for retry', + context: { error, callId, commandStructureNodeId }, + }); + queueEvent(QueuedEventType.DELETE_COMMAND_NODE, { callId, commandStructureNodeId }); + } + }, + + assignResourceToNode: async (callId: string, commandStructureNodeId: string, resourceKind: ResourceAssignmentKind, resourceId: string) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId ?? ''; + + const applyOptimistic = () => { + const current = get().boards[callId]; + if (!current?.board) return; + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Assignments: [ + ...current.board.Assignments, + { + ResourceAssignmentId: localId('assignment'), + IncidentCommandId: incidentCommandId, + DepartmentId: 0, + CallId: toNumericCallId(callId), + CommandStructureNodeId: commandStructureNodeId, + ResourceKind: resourceKind, + ResourceId: resourceId, + AssignedByUserId: '', + AssignedOn: new Date().toISOString(), + RequirementsWarning: false, + }, + ], + }, + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.ASSIGN_COMMAND_RESOURCE, { callId, commandStructureNodeId, resourceKind, resourceId }); + applyOptimistic(); + return null; + } + + try { + const result = await assignResource({ + IncidentCommandId: incidentCommandId, + CallId: toNumericCallId(callId), + CommandStructureNodeId: commandStructureNodeId, + ResourceKind: resourceKind, + ResourceId: resourceId, + }); + // Forced requirements rejection: HTTP 200 with no Data and the reason in Message. + if (!result.Data && result.Message) { + return { blocked: result.Message }; + } + await get().refreshBoard(callId); + return result.Data?.RequirementsWarning ? { warning: result.Data.RequirementsWarningMessage ?? result.Message ?? undefined } : null; + } catch (error) { + logger.warn({ + message: 'AssignResource failed — queueing for retry', + context: { error, callId, commandStructureNodeId, resourceId }, + }); + queueEvent(QueuedEventType.ASSIGN_COMMAND_RESOURCE, { callId, commandStructureNodeId, resourceKind, resourceId }); + applyOptimistic(); + return null; + } + }, + + moveResourceAssignment: async (callId: string, resourceAssignmentId: string, targetNodeId: string) => { + const entry = get().boards[callId]; + const assignment = entry?.board?.Assignments.find((item) => item.ResourceAssignmentId === resourceAssignmentId && !item.ReleasedOn); + if (!assignment || assignment.CommandStructureNodeId === targetNodeId) { + return null; + } + + const sourceNodeId = assignment.CommandStructureNodeId; + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Assignments: current.board.Assignments.map((item) => (item.ResourceAssignmentId === resourceAssignmentId ? { ...item, CommandStructureNodeId: targetNodeId } : item)), + }, + }, + }, + }); + } + + if (resourceAssignmentId.startsWith('local-')) { + const queuedAssignments = useOfflineQueueStore + .getState() + .getPendingEvents() + .filter( + (event) => + event.type === QueuedEventType.ASSIGN_COMMAND_RESOURCE && + event.data.callId === callId && + event.data.commandStructureNodeId === sourceNodeId && + event.data.resourceKind === assignment.ResourceKind && + event.data.resourceId === assignment.ResourceId + ); + const queuedAssignment = queuedAssignments[queuedAssignments.length - 1]; + if (queuedAssignment) { + useOfflineQueueStore.setState((state) => ({ + queuedEvents: state.queuedEvents.map((event) => (event.id === queuedAssignment.id ? { ...event, data: { ...event.data, commandStructureNodeId: targetNodeId } } : event)), + })); + } + return null; + } + + if (isOffline()) { + queueEvent(QueuedEventType.MOVE_COMMAND_RESOURCE, { callId, resourceAssignmentId, targetNodeId }); + return null; + } + + try { + const result = await moveResource({ ResourceAssignmentId: resourceAssignmentId, TargetNodeId: targetNodeId }); + // Forced requirements rejection — undo the optimistic lane change and report the reason. + if (!result.Data && result.Message) { + const after = get().boards[callId]; + if (after?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...after, + board: { + ...after.board, + Assignments: after.board.Assignments.map((item) => (item.ResourceAssignmentId === resourceAssignmentId ? { ...item, CommandStructureNodeId: sourceNodeId } : item)), + }, + }, + }, + }); + } + return { blocked: result.Message }; + } + await get().refreshBoard(callId); + return result.Data?.RequirementsWarning ? { warning: result.Data.RequirementsWarningMessage ?? result.Message ?? undefined } : null; + } catch (error) { + logger.warn({ + message: 'MoveResource failed — queueing for retry', + context: { error, callId, resourceAssignmentId, targetNodeId }, + }); + queueEvent(QueuedEventType.MOVE_COMMAND_RESOURCE, { callId, resourceAssignmentId, targetNodeId }); + return null; + } + }, + + releaseResourceAssignment: async (callId: string, resourceAssignmentId: string) => { + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { ...current.board, Assignments: current.board.Assignments.filter((a) => a.ResourceAssignmentId !== resourceAssignmentId) }, + }, + }, + }); + } + + if (resourceAssignmentId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.RELEASE_COMMAND_RESOURCE, { callId, resourceAssignmentId }); + return; + } + + try { + await releaseResource(resourceAssignmentId); + } catch (error) { + logger.warn({ + message: 'ReleaseResource failed — queueing for retry', + context: { error, callId, resourceAssignmentId }, + }); + queueEvent(QueuedEventType.RELEASE_COMMAND_RESOURCE, { callId, resourceAssignmentId }); + } + }, + + addObjective: async (callId: string, name: string, objectiveType: TacticalObjectiveType) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId ?? ''; + + const applyOptimistic = () => { + const current = get().boards[callId]; + if (!current?.board) return; + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Objectives: [ + ...current.board.Objectives, + { + TacticalObjectiveId: localId('objective'), + IncidentCommandId: incidentCommandId, + DepartmentId: 0, + CallId: toNumericCallId(callId), + Name: name, + ObjectiveType: objectiveType, + Status: TacticalObjectiveStatus.Pending, + AutoPopulated: false, + SortOrder: current.board.Objectives.length, + }, + ], + }, + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.SAVE_OBJECTIVE, { callId, name, objectiveType }); + applyOptimistic(); + return; + } + + try { + await saveObjective({ IncidentCommandId: incidentCommandId, CallId: toNumericCallId(callId), Name: name, ObjectiveType: objectiveType, SortOrder: entry?.board?.Objectives.length ?? 0 }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'SaveObjective failed — queueing for retry', + context: { error, callId, name }, + }); + queueEvent(QueuedEventType.SAVE_OBJECTIVE, { callId, name, objectiveType }); + applyOptimistic(); + } + }, + + completeObjectiveEntry: async (callId: string, tacticalObjectiveId: string) => { + // Optimistically flip the row to Complete + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Objectives: current.board.Objectives.map((o) => (o.TacticalObjectiveId === tacticalObjectiveId ? { ...o, Status: TacticalObjectiveStatus.Complete, CompletedOn: new Date().toISOString() } : o)), + }, + }, + }, + }); + } + + if (tacticalObjectiveId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.COMPLETE_OBJECTIVE, { callId, tacticalObjectiveId }); + return; + } + + try { + await completeObjective(tacticalObjectiveId); + } catch (error) { + logger.warn({ + message: 'CompleteObjective failed — queueing for retry', + context: { error, callId, tacticalObjectiveId }, + }); + queueEvent(QueuedEventType.COMPLETE_OBJECTIVE, { callId, tacticalObjectiveId }); + } + }, + + addAdHocPersonnel: async (callId: string, name: string, role?: string, agency?: string) => { + const applyOptimistic = () => { + const current = get().boards[callId] ?? emptyBoardState(callId, true); + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + adHocPersonnel: [ + ...current.adHocPersonnel, + { + IncidentAdHocPersonnelId: localId('person'), + DepartmentId: 0, + CallId: toNumericCallId(callId), + Name: name, + Role: role ?? null, + ExternalAgencyName: agency ?? null, + RidingResourceKind: 0, + CreatedByUserId: '', + CreatedOn: new Date().toISOString(), + } as IncidentAdHocPersonnel, + ], + }, + }, + }); + }; + + if (isOffline()) { + queueEvent(QueuedEventType.CREATE_ADHOC_PERSONNEL, { callId, name, role, agency }); + applyOptimistic(); + return; + } + + try { + await createAdHocPersonnel({ CallId: toNumericCallId(callId), Name: name, Role: role, ExternalAgencyName: agency }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'CreateAdHocPersonnel failed — queueing for retry', + context: { error, callId, name }, + }); + queueEvent(QueuedEventType.CREATE_ADHOC_PERSONNEL, { callId, name, role, agency }); + applyOptimistic(); + } + }, + + releaseAdHocPersonnelEntry: async (callId: string, incidentAdHocPersonnelId: string) => { + const current = get().boards[callId]; + if (current) { + set({ + boards: { + ...get().boards, + [callId]: { ...current, adHocPersonnel: current.adHocPersonnel.filter((p) => p.IncidentAdHocPersonnelId !== incidentAdHocPersonnelId) }, + }, + }); + } + + if (incidentAdHocPersonnelId.startsWith('local-')) { + return; + } + + if (isOffline()) { + queueEvent(QueuedEventType.RELEASE_ADHOC_PERSONNEL, { callId, incidentAdHocPersonnelId }); + return; + } + + try { + await releaseAdHocPersonnel(incidentAdHocPersonnelId); + } catch (error) { + logger.warn({ + message: 'ReleaseAdHocPersonnel failed — queueing for retry', + context: { error, callId, incidentAdHocPersonnelId }, + }); + queueEvent(QueuedEventType.RELEASE_ADHOC_PERSONNEL, { callId, incidentAdHocPersonnelId }); + } + }, + + startTimer: async (callId: string, name: string, intervalSeconds: number) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId; + if (!incidentCommandId) { + return; + } + try { + await startIncidentTimer({ + IncidentTimerId: uuidv4(), + IncidentCommandId: incidentCommandId, + CallId: toNumericCallId(callId), + TimerType: 3, // Custom + ScopeType: 0, // Incident + Name: name, + IntervalSeconds: intervalSeconds, + Status: 0, + StartedOn: new Date().toISOString(), + }); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'StartTimer failed', + context: { error, callId, name }, + }); + } + }, + + acknowledgeTimer: async (callId: string, incidentTimerId: string) => { + // Optimistically bump the next-due time so the row leaves its overdue state immediately + const current = get().boards[callId]; + const timer = current?.board?.Timers.find((item) => item.IncidentTimerId === incidentTimerId); + if (current?.board && timer) { + const nextDue = timer.IntervalSeconds > 0 ? new Date(Date.now() + timer.IntervalSeconds * 1000).toISOString() : timer.NextDueOn; + set({ + boards: { + ...get().boards, + [callId]: { + ...current, + board: { + ...current.board, + Timers: current.board.Timers.map((item) => (item.IncidentTimerId === incidentTimerId ? { ...item, Status: 2, AcknowledgedOn: new Date().toISOString(), NextDueOn: nextDue } : item)), + }, + }, + }, + }); + } + try { + await acknowledgeIncidentTimer(incidentTimerId); + await get().refreshBoard(callId); + } catch (error) { + logger.warn({ + message: 'AcknowledgeTimer failed', + context: { error, callId, incidentTimerId }, + }); + } + }, + + transferIncidentCommand: async (callId: string, toUserId: string) => { + const entry = get().boards[callId]; + const incidentCommandId = entry?.board?.Command?.IncidentCommandId; + if (!incidentCommandId) { + return false; + } + try { + await transferCommand({ IncidentCommandId: incidentCommandId, ToUserId: toUserId }); + await get().refreshBoard(callId); + return true; + } catch (error) { + logger.warn({ + message: 'TransferCommand failed', + context: { error, callId, toUserId }, + }); + return false; + } + }, + + fetchTimeline: async (callId: string) => { + if (isOffline()) { + return; + } + try { + const result = await getCommandTimeline(callId); + const current = get().boards[callId]; + if (current) { + const entries = [...(result.Data ?? [])].sort((a, b) => new Date(b.OccurredOn).getTime() - new Date(a.OccurredOn).getTime()); + set({ + boards: { + ...get().boards, + [callId]: { ...current, timeline: entries }, + }, + }); + } + } catch (error) { + logger.warn({ + message: 'Failed to fetch command timeline', + context: { error, callId }, + }); + } + }, + + createVoiceChannel: async (callId: string, name: string) => { + try { + const result = await createIncidentChannel({ CallId: toNumericCallId(callId), Name: name }); + if (!result.Data) { + return false; + } + await get().fetchVoiceChannels(callId); + return true; + } catch (error) { + logger.warn({ + message: 'CreateIncidentChannel failed', + context: { error, callId, name }, + }); + return false; + } + }, + + fetchVoiceChannels: async (callId: string) => { + if (isOffline()) { + return; + } + try { + const result = await getChannelsForCall(callId); + const current = get().boards[callId]; + if (current) { + set({ boards: { ...get().boards, [callId]: { ...current, voiceChannels: result.Data ?? [] } } }); + } + } catch (error) { + logger.warn({ + message: 'Failed to fetch incident voice channels', + context: { error, callId }, + }); + } + }, + + closeVoiceChannels: async (callId: string) => { + try { + await closeIncidentChannels(callId); + await get().fetchVoiceChannels(callId); + } catch (error) { + logger.warn({ + message: 'CloseIncidentChannels failed', + context: { error, callId }, + }); + } + }, + + fetchTransmissionLog: async (callId: string) => { + if (isOffline()) { + return; + } + try { + const result = await getTransmissionLog(callId); + const current = get().boards[callId]; + if (current) { + set({ boards: { ...get().boards, [callId]: { ...current, transmissionLog: result.Data ?? [] } } }); + } + } catch (error) { + logger.warn({ + message: 'Failed to fetch transmission log', + context: { error, callId }, + }); + } + }, + + recordTransmission: async (callId: string, departmentVoiceChannelId: string, startedOn: string, endedOn: string) => { + try { + await logTransmission({ CallId: toNumericCallId(callId), DepartmentVoiceChannelId: departmentVoiceChannelId, StartedOn: startedOn, EndedOn: endedOn }); + await get().fetchTransmissionLog(callId); + } catch (error) { + logger.warn({ + message: 'LogTransmission failed', + context: { error, callId, departmentVoiceChannelId }, + }); + } + }, + + refreshAccountability: async (callId: string) => { + if (isOffline()) { + return; + } + try { + await evaluateAccountability(callId); + const result = await getAccountability(callId); + const current = get().boards[callId]; + if (current?.board) { + set({ + boards: { + ...get().boards, + [callId]: { ...current, board: { ...current.board, Accountability: result.Data ?? [] } }, + }, + }); + } + } catch (error) { + logger.warn({ + message: 'Failed to refresh accountability', + context: { error, callId }, + }); + } + }, + }), + { + name: 'command-storage', + storage: createJSONStorage(() => zustandStorage), + version: 3, + // v1 stored device-local boards (assignments/resources arrays); v2 boards wrap the + // server IncidentCommandBoard; v3 adds adHocPersonnel. Drop v1 entries, backfill v2. + migrate: (persistedState: unknown) => { + const state = (persistedState ?? {}) as { boards?: Record; activeCallId?: string | null; lastSyncTimestampMs?: number }; + const boards: Record = {}; + for (const [callId, entry] of Object.entries(state.boards ?? {})) { + if (entry && typeof entry === 'object' && Array.isArray((entry as CommandBoardState).adHocUnits)) { + const board = entry as CommandBoardState; + boards[callId] = { ...board, adHocPersonnel: Array.isArray(board.adHocPersonnel) ? board.adHocPersonnel : [] }; + } + } + return { + boards, + activeCallId: state.activeCallId && boards[state.activeCallId] ? state.activeCallId : (Object.keys(boards)[0] ?? null), + lastSyncTimestampMs: state.lastSyncTimestampMs ?? 0, + }; + }, + partialize: (state) => ({ + boards: state.boards, + activeCallId: state.activeCallId, + lastSyncTimestampMs: state.lastSyncTimestampMs, + }), + } + ) +); diff --git a/src/stores/maps/store.ts b/src/stores/maps/store.ts index 65068bc..72c48f5 100644 --- a/src/stores/maps/store.ts +++ b/src/stores/maps/store.ts @@ -1,7 +1,18 @@ import { type FeatureCollection } from 'geojson'; import { create } from 'zustand'; -import { getAllActiveLayers, getCustomMap, getCustomMaps, getIndoorMap, getIndoorMapFloor, getIndoorMaps, getIndoorMapZonesGeoJSON, getMapLayerGeoJSON, searchAllMapFeatures } from '@/api/mapping/mapping'; +import { + getAllActiveLayers, + getCustomMap, + getCustomMapRegionsGeoJSON, + getCustomMaps, + getIndoorMap, + getIndoorMapFloor, + getIndoorMaps, + getIndoorMapZonesGeoJSON, + getMapLayerGeoJSON, + searchAllMapFeatures, +} from '@/api/mapping/mapping'; import { type CustomMapResultData } from '@/models/v4/mapping/customMapResultData'; import { type IndoorMapFloorResultData, type IndoorMapResultData } from '@/models/v4/mapping/indoorMapResultData'; import { type ActiveLayerSummary, type UnifiedSearchResultItem } from '@/models/v4/mapping/mappingResults'; @@ -36,7 +47,7 @@ interface MapsState { // Actions - Layer management fetchActiveLayers: () => Promise; toggleLayer: (layerId: string) => void; - fetchLayerGeoJSON: (layerId: string) => Promise; + fetchLayerGeoJSON: (layerId: string, layerSource?: string) => Promise; // Actions - Indoor maps fetchIndoorMaps: () => Promise; @@ -89,7 +100,8 @@ export const useMapsStore = create((set, get) => ({ const layers = Array.isArray(response.Data) ? response.Data : []; const toggles: Record = {}; layers.forEach((layer) => { - toggles[layer.LayerId] = layer.IsOnByDefault; + // Preserve an existing user toggle across refetches; default to the server flag + toggles[layer.Id] = get().layerToggles[layer.Id] ?? layer.IsOnByDefault; }); set({ activeLayers: layers, layerToggles: toggles, isLoadingLayers: false }); } catch (error) { @@ -107,7 +119,7 @@ export const useMapsStore = create((set, get) => ({ }); }, - fetchLayerGeoJSON: async (layerId: string) => { + fetchLayerGeoJSON: async (layerId: string, layerSource?: string) => { const { cachedGeoJSON } = get(); if (cachedGeoJSON[layerId]) { return cachedGeoJSON[layerId]; @@ -115,7 +127,8 @@ export const useMapsStore = create((set, get) => ({ set({ isLoadingGeoJSON: true }); try { - const response = await getMapLayerGeoJSON(layerId); + // Custom-map layers serve their regions from a different endpoint than vector map layers + const response = layerSource === 'custommaplayer' ? await getCustomMapRegionsGeoJSON(layerId) : await getMapLayerGeoJSON(layerId); const geoJSON = response.Data; set({ cachedGeoJSON: { ...get().cachedGeoJSON, [layerId]: geoJSON }, diff --git a/src/stores/roles/store.ts b/src/stores/roles/store.ts index e912c39..8c82b19 100644 --- a/src/stores/roles/store.ts +++ b/src/stores/roles/store.ts @@ -5,12 +5,12 @@ import { getAllUnitRolesAndAssignmentsForDepartment, getRoleAssignmentsForUnit, import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; import { type ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; import { type SetUnitRolesInput } from '@/models/v4/unitRoles/setUnitRolesInput'; -import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; import { useCoreStore } from '../app/core-store'; interface RolesState { - roles: UnitRoleResultData[]; + /** Department-wide unit roles with their current assignments (FullName empty = open seat). */ + roles: ActiveUnitRoleResultData[]; unitRoleAssignments: ActiveUnitRoleResultData[]; users: PersonnelInfoResultData[]; isLoading: boolean; @@ -47,12 +47,6 @@ export const useRolesStore = create((set) => ({ isLoading: false, isInitialized: true, }); - - const activeUnit = useCoreStore.getState().activeUnit; - if (activeUnit) { - const unitRoles = await getRoleAssignmentsForUnit(activeUnit.UnitId); - set({ unitRoleAssignments: unitRoles.Data }); - } } catch (error) { set({ error: 'Failed to fetch unit roles and assignments', diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 8dae579..e5fe431 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -236,6 +236,17 @@ export const useSignalRStore = create((set, get) => ({ }); set({ isGeolocationHubConnected: true, error: null }); }); + + // Join the department group — without this the server never sends + // onUnitLocationUpdated / onPersonnelLocationUpdated to this client. + try { + await signalRService.invoke(Env.REALTIME_GEO_HUB_NAME, 'GeolocationConnect'); + } catch (invokeError) { + logger.warn({ + message: 'Failed to join geolocation department group', + context: { error: invokeError }, + }); + } } catch (error) { const err = error instanceof Error ? error : new Error('Unknown error occurred'); logger.warn({ diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts deleted file mode 100644 index 607f3be..0000000 --- a/src/stores/status/__tests__/store.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -// Mock Platform first before any imports -jest.mock('react-native', () => ({ - Platform: { - OS: 'ios', - select: jest.fn((specifics) => specifics.ios || specifics.default), - Version: 17, - }, -})); - -// Mock MMKV storage -jest.mock('react-native-mmkv', () => ({ - MMKV: jest.fn().mockImplementation(() => ({ - set: jest.fn(), - getString: jest.fn(), - delete: jest.fn(), - })), - useMMKVBoolean: jest.fn(() => [false, jest.fn()]), -})); - -import { act, renderHook } from '@testing-library/react-native'; - -import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; -import { saveUnitStatus } from '@/api/units/unitStatuses'; -import { CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; -import { GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; -import { UnitTypeStatusesResult } from '@/models/v4/statuses/unitTypeStatusesResult'; -import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { offlineEventManager } from '@/services/offline-event-manager.service'; -import { useCoreStore } from '@/stores/app/core-store'; - -import { useStatusBottomSheetStore, useStatusesStore } from '../store'; - -// Mock the API calls -jest.mock('@/api/dispatch/dispatch'); -jest.mock('@/api/units/unitStatuses'); -jest.mock('@/stores/app/core-store'); -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: { - getState: jest.fn(() => ({ - latitude: null, - longitude: null, - accuracy: null, - altitude: null, - speed: null, - heading: null, - })), - }, -})); -jest.mock('@/stores/roles/store', () => ({ - useRolesStore: { - getState: jest.fn(() => ({ - roles: [], - })), - }, -})); -jest.mock('@/stores/calls/store', () => ({ - useCallsStore: { - getState: jest.fn(() => ({ - calls: [], - lastFetchedAt: 0, - })), - setState: jest.fn(), - }, -})); -jest.mock('@/services/offline-event-manager.service', () => ({ - offlineEventManager: { - queueUnitStatusEvent: jest.fn(), - }, -})); -jest.mock('@/lib/logging', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction; -const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; -const mockUseCoreStore = useCoreStore as jest.MockedFunction; -const mockOfflineEventManager = offlineEventManager as jest.Mocked; - -describe('StatusBottomSheetStore', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('initializes with correct default values', () => { - const { result } = renderHook(() => useStatusBottomSheetStore()); - - expect(result.current.isOpen).toBe(false); - expect(result.current.currentStep).toBe('select-destination'); - expect(result.current.selectedCall).toBe(null); - expect(result.current.selectedStation).toBe(null); - expect(result.current.selectedPoi).toBe(null); - expect(result.current.selectedDestinationType).toBe('none'); - expect(result.current.selectedStatus).toBe(null); - expect(result.current.note).toBe(''); - expect(result.current.availableCalls).toEqual([]); - expect(result.current.availableStations).toEqual([]); - expect(result.current.availablePois).toEqual([]); - expect(result.current.availablePoiTypes).toEqual([]); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('updates isOpen and selectedStatus when setIsOpen is called', () => { - const { result } = renderHook(() => useStatusBottomSheetStore()); - - const testStatus = new CustomStatusResultData(); - testStatus.Id = '1'; - testStatus.Text = 'Responding'; - testStatus.Note = 1; - testStatus.Detail = 3; - - act(() => { - result.current.setIsOpen(true, testStatus); - }); - - expect(result.current.isOpen).toBe(true); - expect(result.current.selectedStatus).toEqual(testStatus); - }); - - it('fetches destination data successfully', async () => { - const mockResponse = new GetSetUnitStateResult(); - mockResponse.Data.Calls = [ - { - CallId: '1', - Number: 'CALL001', - Name: 'Test Call', - Address: '123 Test St', - } as any, - ]; - mockResponse.Data.Stations = [ - { - GroupId: '1', - Name: 'Station 1', - Address: '456 Station Ave', - } as any, - ]; - mockResponse.Data.DestinationPois = [ - { - PoiId: 9, - PoiTypeId: 1, - PoiTypeName: 'Hospital', - Name: 'Mercy Hospital', - Address: '789 Care Way', - } as any, - ]; - mockResponse.Data.PoiTypes = [ - { - PoiTypeId: 1, - Name: 'Hospital', - IsDestination: true, - } as any, - ]; - - mockGetSetUnitStatusData.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useStatusBottomSheetStore()); - - await act(async () => { - await result.current.fetchDestinationData('unit1'); - }); - - expect(mockGetSetUnitStatusData).toHaveBeenCalledWith('unit1'); - expect(result.current.availableCalls).toEqual(mockResponse.Data.Calls); - expect(result.current.availableStations).toEqual(mockResponse.Data.Stations); - expect(result.current.availablePois).toEqual(mockResponse.Data.DestinationPois); - expect(result.current.availablePoiTypes).toEqual(mockResponse.Data.PoiTypes); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('resets all state when reset is called', () => { - const { result } = renderHook(() => useStatusBottomSheetStore()); - - const testStatus = new CustomStatusResultData(); - testStatus.Id = '1'; - testStatus.Text = 'Test'; - testStatus.Note = 1; - testStatus.Detail = 3; - - // Set some state - act(() => { - result.current.setIsOpen(true, testStatus); - result.current.setCurrentStep('add-note'); - result.current.setNote('Test note'); - result.current.setSelectedDestinationType('call'); - }); - - // Reset - act(() => { - result.current.reset(); - }); - - expect(result.current.isOpen).toBe(false); - expect(result.current.currentStep).toBe('select-destination'); - expect(result.current.selectedCall).toBe(null); - expect(result.current.selectedStation).toBe(null); - expect(result.current.selectedPoi).toBe(null); - expect(result.current.selectedDestinationType).toBe('none'); - expect(result.current.selectedStatus).toBe(null); - expect(result.current.note).toBe(''); - }); -}); - -describe('StatusesStore', () => { - const mockActiveUnit = { - UnitId: 'unit1', - }; - - const mockSetActiveUnitWithFetch = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock the zustand store pattern - const mockStore = { - activeUnit: mockActiveUnit, - setActiveUnitWithFetch: mockSetActiveUnitWithFetch, - }; - - mockUseCoreStore.mockImplementation(() => mockStore); - - // Mock the getState() method as well - (mockUseCoreStore as any).getState = jest.fn(() => mockStore); - }); - - it('saves unit status successfully', async () => { - const mockResult = new UnitTypeStatusesResult(); - mockSaveUnitStatus.mockResolvedValueOnce(mockResult); - mockSetActiveUnitWithFetch.mockResolvedValueOnce(undefined); - - const { result } = renderHook(() => useStatusesStore()); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'Test note'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalledWith( - expect.objectContaining({ - Id: 'unit1', - Type: '1', - Note: 'Test note', - Timestamp: expect.any(String), - TimestampUtc: expect.any(String), - }) - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should queue unit status event when direct save fails', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id'); - mockUseCoreStore.mockReturnValue({ - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: jest.fn(), - } as any); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - input.Note = 'Test note'; - input.RespondingTo = 'call1'; - - const role = new SaveUnitStatusRoleInput(); - role.RoleId = 'role1'; - role.UserId = 'user1'; - input.Roles = [role]; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '1', - 'Test note', - 'call1', - null, - [{ roleId: 'role1', userId: 'user1' }], - undefined - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should handle successful save and refresh active unit', async () => { - const { result } = renderHook(() => useStatusesStore()); - - const mockSetActiveUnitWithFetch = jest.fn(); - const mockCoreStore = { - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: mockSetActiveUnitWithFetch, - }; - - mockSaveUnitStatus.mockResolvedValue({} as UnitTypeStatusesResult); - mockUseCoreStore.mockReturnValue(mockCoreStore as any); - - // Mock the getState method to return our mock store - (mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockSaveUnitStatus).toHaveBeenCalled(); - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('unit1'); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('should handle input without roles when queueing', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id'); - mockUseCoreStore.mockReturnValue({ - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: jest.fn(), - } as any); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - // Don't set Roles, Note, or RespondingTo to test their default values - - await act(async () => { - await result.current.saveUnitStatus(input); - }); - - expect(mockOfflineEventManager.queueUnitStatusEvent).toHaveBeenCalledWith( - 'unit1', - '1', - '', // Note defaults to empty string - '', // RespondingTo defaults to empty string - null, - [], // Roles defaults to empty array which maps to empty array - undefined - ); - }); - - it('should handle critical errors during processing', async () => { - const { result } = renderHook(() => useStatusesStore()); - - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); - mockOfflineEventManager.queueUnitStatusEvent.mockImplementation(() => { - throw new Error('Critical error'); - }); - mockUseCoreStore.mockReturnValue({ - activeUnit: { UnitId: 'unit1' }, - setActiveUnitWithFetch: jest.fn(), - } as any); - - const input = new SaveUnitStatusInput(); - input.Id = 'unit1'; - input.Type = '1'; - - await act(async () => { - try { - await result.current.saveUnitStatus(input); - } catch (error) { - // Expected to throw now since we re-throw critical errors - } - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe('Failed to save unit status'); - }); -}); diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts deleted file mode 100644 index dc31beb..0000000 --- a/src/stores/status/store.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { create } from 'zustand'; - -import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; -import { saveUnitStatus } from '@/api/units/unitStatuses'; -import { logger } from '@/lib/logging'; -import { type CallResultData } from '@/models/v4/calls/callResultData'; -import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; -import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; -import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; -import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; -import { type SaveUnitStatusInput, type SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { offlineEventManager } from '@/services/offline-event-manager.service'; - -import { useCoreStore } from '../app/core-store'; -import { useLocationStore } from '../app/location-store'; -import { useCallsStore } from '../calls/store'; - -type StatusStep = 'select-status' | 'select-destination' | 'add-note'; -type DestinationType = 'none' | 'call' | 'station' | 'poi'; - -type StatusType = CustomStatusResultData | StatusesResultData; - -const STORE_TTL_MS = 5 * 60 * 1000; - -interface StatusBottomSheetStore { - isOpen: boolean; - currentStep: StatusStep; - selectedCall: CallResultData | null; - selectedStation: GroupResultData | null; - selectedPoi: PoiResultData | null; - selectedDestinationType: DestinationType; - selectedStatus: StatusType | null; - cameFromStatusSelection: boolean; - note: string; - availableCalls: CallResultData[]; - availableStations: GroupResultData[]; - availablePois: PoiResultData[]; - availablePoiTypes: PoiTypeResultData[]; - lastFetchedAt: number; - isLoading: boolean; - error: string | null; - setIsOpen: (isOpen: boolean, status?: StatusType) => void; - setCurrentStep: (step: StatusStep) => void; - setSelectedCall: (call: CallResultData | null) => void; - setSelectedStation: (station: GroupResultData | null) => void; - setSelectedPoi: (poi: PoiResultData | null) => void; - setSelectedDestinationType: (type: DestinationType) => void; - setSelectedStatus: (status: StatusType | null) => void; - setNote: (note: string) => void; - fetchDestinationData: (unitId: string) => Promise; - reset: () => void; -} - -const hasFreshDestinationData = (lastFetchedAt: number): boolean => { - return lastFetchedAt > 0 && Date.now() - lastFetchedAt <= STORE_TTL_MS; -}; - -export const useStatusBottomSheetStore = create((set, get) => ({ - isOpen: false, - currentStep: 'select-destination', - selectedCall: null, - selectedStation: null, - selectedPoi: null, - selectedDestinationType: 'none', - selectedStatus: null, - cameFromStatusSelection: false, - note: '', - availableCalls: [], - availableStations: [], - availablePois: [], - availablePoiTypes: [], - lastFetchedAt: 0, - isLoading: false, - error: null, - setIsOpen: (isOpen, status) => { - if (!isOpen) { - set({ isOpen: false }); - return; - } - - if (!status) { - set({ - isOpen, - selectedStatus: null, - currentStep: 'select-status', - cameFromStatusSelection: true, - }); - return; - } - - set({ - isOpen, - selectedStatus: status, - currentStep: 'select-destination', - cameFromStatusSelection: false, - }); - }, - setCurrentStep: (step) => set({ currentStep: step }), - setSelectedCall: (call) => set({ selectedCall: call }), - setSelectedStation: (station) => set({ selectedStation: station }), - setSelectedPoi: (poi) => set({ selectedPoi: poi }), - setSelectedDestinationType: (type) => set({ selectedDestinationType: type }), - setSelectedStatus: (status) => set({ selectedStatus: status }), - setNote: (note) => set({ note }), - fetchDestinationData: async (unitId: string) => { - if (get().isLoading || hasFreshDestinationData(get().lastFetchedAt)) { - return; - } - - set({ isLoading: true, error: null }); - - try { - const response = await getSetUnitStatusData(unitId); - const data = response.Data; - const lastFetchedAt = Date.now(); - const availableCalls = data?.Calls ?? []; - - useCallsStore.setState({ - calls: availableCalls, - lastFetchedAt, - }); - - set({ - availableCalls, - availableStations: data?.Stations ?? [], - availablePois: data?.DestinationPois ?? [], - availablePoiTypes: data?.PoiTypes ?? [], - lastFetchedAt, - isLoading: false, - error: null, - }); - } catch { - set({ - error: 'Failed to fetch destination data', - isLoading: false, - }); - } - }, - reset: () => - set({ - isOpen: false, - currentStep: 'select-destination', - selectedCall: null, - selectedStation: null, - selectedPoi: null, - selectedDestinationType: 'none', - selectedStatus: null, - cameFromStatusSelection: false, - note: '', - availableCalls: [], - availableStations: [], - availablePois: [], - availablePoiTypes: [], - lastFetchedAt: 0, - isLoading: false, - error: null, - }), -})); - -interface StatusesState { - isLoading: boolean; - error: string | null; - saveUnitStatus: (input: SaveUnitStatusInput) => Promise; -} - -export const useStatusesStore = create((set) => ({ - isLoading: false, - error: null, - saveUnitStatus: async (input: SaveUnitStatusInput) => { - set({ isLoading: true, error: null }); - - try { - const date = new Date(); - input.Timestamp = date.toISOString(); - input.TimestampUtc = date.toUTCString().replace('UTC', 'GMT'); - - if ((!input.Latitude && !input.Longitude) || (input.Latitude === '' && input.Longitude === '')) { - const locationState = useLocationStore.getState(); - - if (locationState.latitude !== null && locationState.longitude !== null) { - input.Latitude = locationState.latitude.toString(); - input.Longitude = locationState.longitude.toString(); - input.Accuracy = locationState.accuracy?.toString() || ''; - input.Altitude = locationState.altitude?.toString() || ''; - input.AltitudeAccuracy = ''; - input.Speed = locationState.speed?.toString() || ''; - input.Heading = locationState.heading?.toString() || ''; - } else { - input.Latitude = ''; - input.Longitude = ''; - input.Accuracy = ''; - input.Altitude = ''; - input.AltitudeAccuracy = ''; - input.Speed = ''; - input.Heading = ''; - } - } - - try { - await saveUnitStatus(input); - - set({ isLoading: false }); - - logger.info({ - message: 'Unit status saved successfully', - context: { unitId: input.Id, statusType: input.Type }, - }); - - const activeUnit = useCoreStore.getState().activeUnit; - if (activeUnit) { - const refreshPromise = useCoreStore.getState().setActiveUnitWithFetch(activeUnit.UnitId); - if (refreshPromise && typeof refreshPromise.catch === 'function') { - refreshPromise.catch((error) => { - logger.error({ - message: 'Failed to refresh unit data after status save', - context: { unitId: activeUnit.UnitId, error }, - }); - }); - } - } - } catch (error) { - logger.warn({ - message: 'Direct unit status save failed, queuing for offline processing', - context: { unitId: input.Id, statusType: input.Type, error }, - }); - - const roles = - input.Roles?.map((role) => ({ - roleId: role.RoleId, - userId: role.UserId, - })) ?? []; - - let gpsData: - | { - latitude?: string; - longitude?: string; - accuracy?: string; - altitude?: string; - altitudeAccuracy?: string; - speed?: string; - heading?: string; - } - | undefined; - - if (input.Latitude && input.Longitude) { - gpsData = { - latitude: input.Latitude, - longitude: input.Longitude, - accuracy: input.Accuracy, - altitude: input.Altitude, - altitudeAccuracy: input.AltitudeAccuracy, - speed: input.Speed, - heading: input.Heading, - }; - } else { - const locationState = useLocationStore.getState(); - if (locationState.latitude !== null && locationState.longitude !== null) { - gpsData = { - latitude: locationState.latitude.toString(), - longitude: locationState.longitude.toString(), - accuracy: locationState.accuracy?.toString(), - altitude: locationState.altitude?.toString(), - altitudeAccuracy: undefined, - speed: locationState.speed?.toString(), - heading: locationState.heading?.toString(), - }; - } - } - - const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData); - - logger.info({ - message: 'Unit status queued for offline processing', - context: { unitId: input.Id, statusType: input.Type, eventId }, - }); - - set({ isLoading: false }); - } - } catch (error) { - logger.error({ - message: 'Failed to process unit status update', - context: { error }, - }); - set({ error: 'Failed to save unit status', isLoading: false }); - throw error; - } - }, -})); diff --git a/src/stores/units/store.ts b/src/stores/units/store.ts index e10142e..1b802cb 100644 --- a/src/stores/units/store.ts +++ b/src/stores/units/store.ts @@ -2,12 +2,16 @@ import { create } from 'zustand'; import { getAllUnitStatuses } from '@/api/satuses/statuses'; import { getUnits } from '@/api/units/units'; +import { getAllUnitStatuses as getUnitCurrentStatuses } from '@/api/units/unitStatuses'; import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; +import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; interface UnitsState { units: UnitResultData[]; unitStatuses: UnitTypeStatusResultData[]; + /** Live per-unit status snapshot (state text/color, destination, ETA). */ + unitCurrentStatuses: UnitStatusResultData[]; isLoading: boolean; error: string | null; fetchUnits: () => Promise; @@ -16,16 +20,16 @@ interface UnitsState { export const useUnitsStore = create((set) => ({ units: [], unitStatuses: [], + unitCurrentStatuses: [], isLoading: false, error: null, fetchUnits: async () => { set({ isLoading: true, error: null }); try { - const unitsResponse = await getUnits(); - const unitStatusesResponse = await getAllUnitStatuses(); - set({ units: unitsResponse.Data, unitStatuses: unitStatusesResponse.Data, isLoading: false }); + const [unitsResponse, unitStatusesResponse, currentStatusesResponse] = await Promise.all([getUnits(), getAllUnitStatuses(), getUnitCurrentStatuses()]); + set({ units: unitsResponse.Data, unitStatuses: unitStatusesResponse.Data, unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false }); } catch (error) { - set({ error: 'Failed to fetch calls', isLoading: false }); + set({ error: 'Failed to fetch units', isLoading: false }); } }, })); diff --git a/src/translations/ar.json b/src/translations/ar.json index 689277f..aa4b79f 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -184,10 +184,6 @@ "notes": "ملاحظات", "priority": "الأولوية", "reference_id": "معرف المرجع", - "set_active": "تعيين كنشط", - "set_active_error": "فشل في تعيين المكالمة كنشطة", - "set_active_success": "تم تعيين المكالمة كنشطة", - "setting_active": "جاري التعيين كنشط...", "status": "الحالة", "tabs": { "check_in": "تسجيل الحضور", @@ -345,6 +341,184 @@ "type_sector_rotation": "تناوب القطاع", "type_unit": "الوحدة" }, + "command": { + "accountability_section": "المساءلة", + "acknowledge": "تأكيد", + "active_badge": "قيادة نشطة", + "add": "إضافة", + "add_assignment": "تعيين دور ICS", + "add_channel": "إضافة قناة", + "add_entry": "إضافة سجل", + "add_lane": "إضافة مسار", + "add_objective": "إضافة هدف", + "add_resource": "إضافة مورد", + "add_timer": "إضافة مؤقّت", + "add_to_command": "إضافة إلى القيادة", + "added_to_command": "تمت الإضافة إلى لوحة القيادة", + "agency_label": "الشركة / الجهة (اختياري)", + "agency_placeholder": "مثال: أمن أكمي، الهلال الأحمر", + "assign": "تعيين", + "assign_resource": "تعيين مورد", + "assignment_label": "المهمة / الموقع", + "assignment_placeholder": "مثال: هجوم داخلي، القطاع أ", + "channel_connected": "متصل", + "channel_join": "انضمام", + "channel_leave": "مغادرة", + "channel_name_placeholder": "مثال: تكتيكي 1", + "channel_preset_command": "القيادة", + "channel_preset_tactical": "تكتيكي", + "close_channels": "إغلاق الكل", + "crew_placeholder": "اسم الشخص أو الفريق", + "current_commander": "قائد الحادث", + "drag_move_hint": "اضغط مطولاً على المورد لسحبه، أو اضغط عليه ثم اضغط على مسار.", + "empty_accountability": "لا توجد سجلات بعد — أضف الفرق لمتابعتها", + "empty_channels": "لا توجد قنوات تكتيكية — افتح واحدة لهذا الحادث", + "empty_description": "اختر حادثًا أو فعالية نشطة لبدء إنشاء لوحة القيادة. ستظهر هنا المسارات والأدوار والموارد والمساءلة.", + "empty_heading": "لوحة القيادة", + "empty_objectives": "لا توجد أهداف تكتيكية بعد", + "empty_resources": "لا توجد موارد بعد — أضف المركبات أو الفرق أو المعدات", + "empty_roles": "لم يتم تعيين أدوار ICS بعد", + "empty_structure": "لا توجد مسارات بعد — أضف أقسامًا أو مجموعات أو منطقة تجمع لبناء هيكل القيادة", + "empty_timeline": "لا توجد إدخالات في السجل بعد", + "empty_timers": "لا توجد مؤقّتات — أضف تذكير PAR", + "end_command": "إنهاء القيادة", + "evaluate": "إعادة التقييم", + "go_to_calls": "الانتقال إلى البلاغات", + "in_this_lane": "في هذا المسار", + "lane_color_label": "لون المسار (اختياري — يلوّن علاماته على الخريطة)", + "lane_limits_label": "حدود المسار (اختياري)", + "lane_name_label": "اسم المسار", + "lane_name_placeholder": "مثال: القسم أ، مجموعة السطح، منطقة التجمع", + "lane_suggestion_customer_service": "خدمة الجمهور", + "lane_suggestion_gate": "مراقبة البوابات", + "lane_suggestion_medical": "الإسعاف", + "lane_suggestion_operations": "العمليات", + "lane_suggestion_parking": "مواقف السيارات", + "lane_suggestion_patrol": "دورية", + "lane_suggestion_security_post": "نقطة أمنية", + "lane_suggestion_stage": "المسرح", + "lane_suggestion_staging": "منطقة التجمع", + "lane_suggestion_vendor": "منطقة الباعة", + "lane_type_label": "نوع المسار", + "lane_understaffed": "{{count}}/{{min}} وحدات — غير مكتمل", + "lane_unit_capacity": "{{count}}/{{max}} وحدات", + "last_check_in": "آخر تسجيل حضور", + "limit_max_riding": "الحد الأقصى للطاقم", + "limit_max_time": "تناوب بعد (دقيقة)", + "limit_max_units": "الحد الأقصى للوحدات", + "limit_min_riding": "الحد الأدنى للطاقم", + "limit_min_time": "الحد الأدنى للوقت (دقيقة)", + "limit_min_units": "الحد الأدنى للوحدات", + "limit_none": "بدون", + "limit_riding_help": "عدد الأفراد الذين يجب أن يكونوا على متن الوحدة لتناسب هذا المسار. يُفحص عند الإسناد؛ يُحظر أو يُحذَّر حسب فرض المتطلبات.", + "limit_time_help": "إرشادات التناوب بالدقائق. الخروج قبل الحد الأدنى يُعلِّم النقل؛ بعد الحد الأقصى يظهر شعار التناوب على المورد. لا يُزال شيء تلقائيًا.", + "limit_units_help": "عدد الوحدات التي يجب أن يضمها هذا المسار. تحت الحد الأدنى يظهر المسار غير مكتمل (لا يحظر أبدًا)؛ عند الحد الأقصى تُحظر إضافة وحدات أو يُحذَّر منها.", + "map_command_section": "لوحة القيادة", + "move": "نقل", + "move_conflict_message": "{{resource}} معيّن بالفعل إلى {{lane}}. هل تريد نقله إلى {{target}}؟", + "move_conflict_title": "المورد معيّن بالفعل", + "move_resource_to_lane": "نقل {{resource}} إلى {{lane}}", + "move_selected_hint": "تم تحديد {{resource}} — اضغط على مسار لنقله.", + "no_accountability": "لم يسجل أي فرد حضوره في هذا الحادث بعد", + "no_check_in_yet": "لا يوجد تسجيل حضور بعد", + "no_personnel_found": "لم يتم العثور على أفراد", + "no_resources_found": "لم يتم العثور على موارد", + "no_resources_in_lane": "لا توجد موارد معينة لهذا المسار", + "node_branch": "فرع", + "node_division": "قسم", + "node_group": "مجموعة", + "node_sector": "قطاع", + "node_staging": "منطقة التجمع", + "node_strike_team": "فريق الضربة", + "node_task_force": "قوة المهام", + "node_unified_command": "قيادة موحدة", + "not_on_board": "ليس على اللوحة", + "note_label": "ملاحظة (اختياري)", + "note_placeholder": "في الانتظار، في الطريق، مهمة...", + "objective_benchmark": "معلم", + "objective_completed": "تم", + "objective_general": "عام", + "objective_name_label": "الهدف", + "objective_name_placeholder": "مثال: اكتمال البحث الأولي، تأمين المرافق", + "objective_safety": "السلامة", + "objective_type_label": "النوع", + "objectives_section": "الأهداف التكتيكية", + "open_board": "فتح لوحة القيادة", + "par_critical": "حرج", + "par_green": "موافق", + "par_hint": "اضغط على شارة PAR للتبديل بين موافق وقيد الانتظار", + "par_ok": "PAR موافق", + "par_pending": "PAR قيد الانتظار", + "par_warning": "تحذير", + "person_label": "الاسم", + "person_name_placeholder": "اسم الشخص", + "person_placeholder": "الشخص المعين لهذا الدور", + "person_role_label": "الدور / المهمة", + "person_role_placeholder": "مثال: مسعف، دورية، موظف بوابة", + "provisional_badge": "غير متصل — بانتظار المزامنة", + "release_from_command": "تحرير من القيادة", + "requirements_warning": "المتطلبات", + "resource_kind_external": "خارجي", + "resource_kind_personnel": "الأفراد", + "resource_kind_units": "الوحدات", + "resource_name_label": "المورد", + "resource_name_placeholder": "مثال: مضخة 3، سلم 1، فريق الدعم", + "resource_person": "شخص خارجي", + "resource_unit": "وحدة / فريق", + "resources_section": "الموارد", + "role_finance": "المالية/الإدارة", + "role_ic": "قائد الحادث", + "role_label": "دور ICS", + "role_liaison": "الاتصال", + "role_logistics": "الخدمات اللوجستية", + "role_open": "شاغر", + "role_operations": "العمليات", + "role_pio": "الإعلام", + "role_placeholder": "اختر دورًا جاهزًا أو اكتب دورًا مخصصًا", + "role_planning": "التخطيط", + "role_rehab": "ضابط الاستشفاء", + "role_safety": "ضابط السلامة", + "role_staging": "مدير منطقة التجمع", + "role_transport": "ضابط النقل", + "role_triage": "ضابط الفرز", + "roles_section": "تعيينات أدوار ICS", + "rotation_due": "تناوب", + "search_personnel": "البحث عن أفراد...", + "search_resources": "البحث عن موارد...", + "selected": "محدد", + "show_more": "عرض المزيد", + "start_command": "بدء القيادة", + "start_command_description": "اختر قالبًا أو ابدأ بلوحة فارغة — يمكن للقوالب تمثيل هياكل ICS أو خدمات الأمن أو خطط التوظيف لفعالية كمهرجان أو حفل.", + "start_command_title": "بدء لوحة القيادة", + "start_error": "تعذر بدء القيادة لهذا البلاغ", + "start_success": "تم بدء القيادة لهذا البلاغ", + "start_timer": "بدء", + "starting": "جارٍ البدء...", + "structure_section": "هيكل القيادة", + "template_blank": "لوحة فارغة", + "template_blank_description": "ابدأ فارغًا وأنشئ المسارات تدريجيًا", + "template_lane_count": "{{count}} مسار(ات)", + "timeline_section": "سجل الحادث", + "timer_default_name": "فحص PAR", + "timer_due_in": "يستحق خلال {{time}}", + "timer_minutes": "{{count}} دقيقة", + "timer_name_placeholder": "مثال: فحص PAR، تناوب الاستراحة", + "timer_overdue": "متأخر", + "timer_overdue_by": "متأخر بمقدار {{time}}", + "timers_section": "المؤقّتات و PAR", + "transfer": "نقل", + "transfer_command": "نقل القيادة", + "transfer_command_hint": "سلّم قيادة الحادث إلى مستخدم آخر. تبقى اللوحة مشتركة.", + "transfer_error": "فشل نقل القيادة", + "transfer_success": "تم نقل القيادة", + "transmission_log": "سجل الإرسال", + "transmission_seconds": "{{count}} ث", + "unassigned": "غير معيّن", + "unit_eta": "الوصول المتوقع {{eta}}", + "unit_roles_count": "الأدوار {{filled}}/{{total}}", + "view_call": "عرض البلاغ", + "voice_section": "قنوات الصوت" + }, "common": { "add": "إضافة", "back": "رجوع", @@ -364,8 +538,6 @@ "loading_address": "جاري تحميل العنوان...", "my_location": "موقعي", "next": "التالي", - "noActiveUnit": "لم يتم تعيين وحدة نشطة", - "noActiveUnitDescription": "يرجى تعيين وحدة نشطة من صفحة الإعدادات للوصول إلى عناصر التحكم في الحالة", "noDataAvailable": "لا توجد بيانات متاحة", "no_address_found": "لم يتم العثور على عنوان", "no_location": "لا توجد بيانات موقع متاحة", @@ -548,7 +720,7 @@ "sso_signing_in": "جارٍ تسجيل الدخول عبر SSO...", "sso_subtitle": "أدخل اسم المستخدم الخاص بك للبحث عن تكوين SSO", "sso_title": "تسجيل الدخول عبر SSO", - "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid Unit", + "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid IC", "title": "تسجيل الدخول", "username": "اسم المستخدم", "username_not_found": "اسم المستخدم غير موجود", @@ -588,6 +760,8 @@ "layer_toggles": "التحكم بالطبقات", "layers_on": "{{active}} / {{total}} نشط", "loading": "جارٍ تحميل الخرائط...", + "map_layers": "طبقات الخريطة", + "no_layers": "لا توجد طبقات خريطة متاحة", "no_maps": "لا توجد خرائط", "no_maps_description": "لا توجد خرائط متاحة لقسمك.", "no_results": "لم يتم العثور على نتائج", @@ -619,8 +793,23 @@ "search": "البحث في الملاحظات...", "title": "الملاحظات" }, + "offline": { + "offline_message": "غير متصل — يتم عرض آخر البيانات المتزامنة", + "offline_with_pending": "غير متصل — ستتم مزامنة {{count}} تغيير(ات) عند استعادة الاتصال", + "pending_count": "{{count}} تغيير(ات) في انتظار المزامنة", + "sync_now": "المزامنة الآن", + "syncing": "جارٍ المزامنة..." + }, "onboarding": { - "message": "مرحبًا بك في تطبيق obytes" + "awarenessDescription": "تتبع الوحدات والأفراد مع تحديثات الحالة والموقع في الوقت الفعلي على خريطة الحادث", + "awarenessTitle": "وعي ظرفي مباشر", + "boardDescription": "لوحة قيادة الحوادث الرقمية الخاصة بك — أدر الحوادث وأدوار ICS والموارد من جهاز لوحي أو سطح المكتب", + "boardTitle": "Resgrid IC", + "coordinateDescription": "عيّن الأفراد للأدوار والمجموعات، وحافظ على المساءلة، وأبقِ طاقم القيادة متزامنًا مع تطور الحادث", + "coordinateTitle": "قُد ونسّق", + "getStarted": "لنبدأ", + "next": "التالي", + "skip": "تخطي" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "حول التطبيق", "account": "الحساب", "activating_unit": "جاري تفعيل الوحدة...", - "active_unit": "الوحدة النشطة", "app_info": "معلومات التطبيق", "app_name": "اسم التطبيق", "arabic": "عربي", @@ -859,7 +1047,6 @@ "logout_confirm_title": "تأكيد تسجيل الخروج", "more": "المزيد", "no_units_available": "لا توجد وحدات متاحة", - "none_selected": "لم يتم اختيار أي شيء", "notifications": "الإشعارات", "notifications_description": "تفعيل الإشعارات لتلقي التنبيهات والتحديثات", "notifications_enable": "تفعيل الإشعارات", @@ -873,7 +1060,6 @@ "server": "الخادم", "server_url": "عنوان URL للخادم", "server_url_note": "ملاحظة: هذا هو عنوان URL لواجهة برمجة تطبيقات Resgrid. يتم استخدامه للاتصال بخادم Resgrid. لا تقم بتضمين /api/v4 في عنوان URL أو شرطة مائلة في النهاية.", - "set_active_unit": "تعيين الوحدة النشطة", "share": "مشاركة", "spanish": "إسباني", "status_page": "حالة النظام", @@ -896,6 +1082,9 @@ "version": "الإصدار", "website": "الموقع الإلكتروني" }, + "sidebar": { + "menu": "القائمة" + }, "status": { "add_note": "إضافة ملاحظة", "both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "المكالمات", + "command_board": "لوحة القيادة", "contacts": "جهات الاتصال", "incidents": "Incidents", "map": "الخريطة", diff --git a/src/translations/de.json b/src/translations/de.json index c5f961b..ce2d073 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Puffern", @@ -184,10 +184,6 @@ "notes": "Notizen", "priority": "Priorität", "reference_id": "Referenz-ID", - "set_active": "Als aktiv setzen", - "set_active_error": "Anruf konnte nicht als aktiv gesetzt werden", - "set_active_success": "Anruf als aktiv gesetzt", - "setting_active": "Wird als aktiv gesetzt...", "status": "Status", "tabs": { "check_in": "Check-In", @@ -345,6 +341,184 @@ "type_sector_rotation": "Sektorwechsel", "type_unit": "Einheit" }, + "command": { + "accountability_section": "Personalübersicht", + "acknowledge": "Bestätigen", + "active_badge": "Aktive Führung", + "add": "Hinzufügen", + "add_assignment": "ICS-Rolle zuweisen", + "add_channel": "Kanal hinzufügen", + "add_entry": "Eintrag hinzufügen", + "add_lane": "Abschnitt hinzufügen", + "add_objective": "Ziel hinzufügen", + "add_resource": "Ressource hinzufügen", + "add_timer": "Timer hinzufügen", + "add_to_command": "Zur Führung hinzufügen", + "added_to_command": "Zum Führungsboard hinzugefügt", + "agency_label": "Firma / Organisation (optional)", + "agency_placeholder": "z. B. Acme Security, Rotes Kreuz", + "assign": "Zuweisen", + "assign_resource": "Ressource zuweisen", + "assignment_label": "Auftrag / Standort", + "assignment_placeholder": "z. B. Innenangriff, Abschnitt A", + "channel_connected": "Verbunden", + "channel_join": "Beitreten", + "channel_leave": "Verlassen", + "channel_name_placeholder": "z. B. Taktik 1", + "channel_preset_command": "Führung", + "channel_preset_tactical": "Taktik", + "close_channels": "Alle schließen", + "crew_placeholder": "Name der Person oder des Trupps", + "current_commander": "EL", + "drag_move_hint": "Halten Sie eine Ressource gedrückt, um sie zu ziehen, oder tippen Sie sie und anschließend eine Spur an.", + "empty_accountability": "Noch keine Einträge — füge Trupps hinzu, um sie zu verfolgen", + "empty_channels": "Keine Taktikkanäle — öffne einen für diesen Einsatz", + "empty_description": "Wähle einen aktiven Einsatz oder ein Event, um deine Führungstafel aufzubauen. Abschnitte, Rollen, Ressourcen und Übersicht erscheinen hier.", + "empty_heading": "Führungstafel", + "empty_objectives": "Noch keine taktischen Ziele", + "empty_resources": "Noch keine Ressourcen — füge Fahrzeuge, Trupps oder Geräte hinzu", + "empty_roles": "Noch keine ICS-Rollen zugewiesen", + "empty_structure": "Noch keine Abschnitte — füge Einsatzabschnitte, Gruppen oder Bereitstellungsraum hinzu", + "empty_timeline": "Noch keine Protokolleinträge", + "empty_timers": "Keine Timer — PAR-Erinnerung hinzufügen", + "end_command": "Führung beenden", + "evaluate": "Neu bewerten", + "go_to_calls": "Zu den Einsätzen", + "in_this_lane": "In dieser Spur", + "lane_color_label": "Spurfarbe (optional — färbt die Marker dieser Spur auf der Karte)", + "lane_limits_label": "Spur-Limits (optional)", + "lane_name_label": "Abschnittsname", + "lane_name_placeholder": "z. B. Abschnitt A, Gruppe Dach, Bereitstellungsraum", + "lane_suggestion_customer_service": "Besucherservice", + "lane_suggestion_gate": "Einlasskontrolle", + "lane_suggestion_medical": "Sanitätsdienst", + "lane_suggestion_operations": "Einsatzleitung", + "lane_suggestion_parking": "Parkplätze", + "lane_suggestion_patrol": "Streife", + "lane_suggestion_security_post": "Sicherheitsposten", + "lane_suggestion_stage": "Bühne", + "lane_suggestion_staging": "Bereitstellungsraum", + "lane_suggestion_vendor": "Händlerbereich", + "lane_type_label": "Abschnittstyp", + "lane_understaffed": "{{count}}/{{min}} Einheiten — unterbesetzt", + "lane_unit_capacity": "{{count}}/{{max}} Einheiten", + "last_check_in": "Letzte Rückmeldung", + "limit_max_riding": "Max. Besatzung", + "limit_max_time": "Ablösen nach (Min)", + "limit_max_units": "Max. Einheiten", + "limit_min_riding": "Min. Besatzung", + "limit_min_time": "Min. Zeit (Min)", + "limit_min_units": "Min. Einheiten", + "limit_none": "Keine", + "limit_riding_help": "Besatzung, die auf einer Einheit sein muss, damit sie in diese Spur passt. Wird bei Zuweisung geprüft; blockiert oder warnt je nach Anforderungen erzwingen.", + "limit_time_help": "Rotationsrichtwerte in Minuten. Verlassen vor dem Minimum markiert den Wechsel; nach dem Maximum zeigt die Ressource das Ablösen-Badge. Nichts wird automatisch entfernt.", + "limit_units_help": "Wie viele Einheiten diese Spur haben sollte. Unter dem Minimum gilt sie als unterbesetzt (blockiert nie); am Maximum werden weitere Einheiten blockiert oder gewarnt.", + "map_command_section": "Führungsboard", + "move": "Verschieben", + "move_conflict_message": "{{resource}} ist bereits {{lane}} zugewiesen. Nach {{target}} verschieben?", + "move_conflict_title": "Ressource bereits zugewiesen", + "move_resource_to_lane": "{{resource}} nach {{lane}} verschieben", + "move_selected_hint": "{{resource}} ausgewählt — tippen Sie auf eine Spur, um die Ressource zu verschieben.", + "no_accountability": "Noch kein Personal bei diesem Einsatz gemeldet", + "no_check_in_yet": "Noch keine Rückmeldung", + "no_personnel_found": "Kein Personal gefunden", + "no_resources_found": "Keine Ressourcen gefunden", + "no_resources_in_lane": "Diesem Abschnitt sind keine Ressourcen zugewiesen", + "node_branch": "Unterabschnitt", + "node_division": "Einsatzabschnitt", + "node_group": "Gruppe", + "node_sector": "Sektor", + "node_staging": "Bereitstellungsraum", + "node_strike_team": "Einsatztrupp", + "node_task_force": "Task Force", + "node_unified_command": "Gemeinsame Führung", + "not_on_board": "Nicht auf dem Board", + "note_label": "Notiz (optional)", + "note_placeholder": "Bereitstellung, auf Anfahrt, Auftrag...", + "objective_benchmark": "Meilenstein", + "objective_completed": "Erledigt", + "objective_general": "Allgemein", + "objective_name_label": "Ziel", + "objective_name_placeholder": "z. B. Primärsuche abgeschlossen, Versorgung gesichert", + "objective_safety": "Sicherheit", + "objective_type_label": "Typ", + "objectives_section": "Taktische Ziele", + "open_board": "Führungstafel öffnen", + "par_critical": "Kritisch", + "par_green": "OK", + "par_hint": "Tippe auf ein PAR-Abzeichen, um zwischen OK und Ausstehend zu wechseln", + "par_ok": "PAR OK", + "par_pending": "PAR ausstehend", + "par_warning": "Warnung", + "person_label": "Name", + "person_name_placeholder": "Name der Person", + "person_placeholder": "Person für diese Rolle", + "person_role_label": "Rolle / Aufgabe", + "person_role_placeholder": "z. B. Sanitäter, Streife, Einlasspersonal", + "provisional_badge": "Offline — Synchronisierung ausstehend", + "release_from_command": "Aus der Führung entlassen", + "requirements_warning": "Anforderungen", + "resource_kind_external": "Extern", + "resource_kind_personnel": "Personal", + "resource_kind_units": "Einheiten", + "resource_name_label": "Ressource", + "resource_name_placeholder": "z. B. LF 3, DLK 1, Überlandhilfe", + "resource_person": "Externe Person", + "resource_unit": "Einheit / Trupp", + "resources_section": "Ressourcen", + "role_finance": "Finanzen/Verwaltung", + "role_ic": "Einsatzleiter", + "role_label": "ICS-Rolle", + "role_liaison": "Verbindung", + "role_logistics": "Logistik", + "role_open": "Offen", + "role_operations": "Einsatzabwicklung", + "role_pio": "Öffentlichkeitsarbeit", + "role_placeholder": "Vorlage wählen oder eigene Rolle eingeben", + "role_planning": "Planung", + "role_rehab": "Rehab-Beauftragter", + "role_safety": "Sicherheitsbeauftragter", + "role_staging": "Bereitstellungsraum-Leiter", + "role_transport": "Transport-Beauftragter", + "role_triage": "Triage-Beauftragter", + "roles_section": "ICS-Rollenzuweisungen", + "rotation_due": "Ablösen", + "search_personnel": "Personal suchen...", + "search_resources": "Ressourcen suchen...", + "selected": "Ausgewählt", + "show_more": "Mehr anzeigen", + "start_command": "Führung starten", + "start_command_description": "Wähle eine Vorlage oder starte leer — Vorlagen können ICS-Strukturen, Sicherheitsdienste oder Personalpläne für Feste und Konzerte abbilden.", + "start_command_title": "Führungstafel starten", + "start_error": "Führung für diesen Einsatz konnte nicht gestartet werden", + "start_success": "Führung für diesen Einsatz gestartet", + "start_timer": "Starten", + "starting": "Wird gestartet...", + "structure_section": "Führungsstruktur", + "template_blank": "Leere Tafel", + "template_blank_description": "Leer beginnen und Abschnitte nach Bedarf anlegen", + "template_lane_count": "{{count}} Abschnitt(e)", + "timeline_section": "Einsatzprotokoll", + "timer_default_name": "PAR-Kontrolle", + "timer_due_in": "Fällig in {{time}}", + "timer_minutes": "{{count}} Min", + "timer_name_placeholder": "z. B. PAR-Kontrolle, Rehab-Rotation", + "timer_overdue": "Überfällig", + "timer_overdue_by": "Überfällig seit {{time}}", + "timers_section": "Timer & PAR", + "transfer": "Übergeben", + "transfer_command": "Führung übergeben", + "transfer_command_hint": "Übergib die Einsatzleitung an einen anderen Benutzer. Das Board bleibt geteilt.", + "transfer_error": "Führungsübergabe fehlgeschlagen", + "transfer_success": "Führung übergeben", + "transmission_log": "Sendeprotokoll", + "transmission_seconds": "{{count}}s", + "unassigned": "Nicht zugewiesen", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Rollen {{filled}}/{{total}}", + "view_call": "Einsatz anzeigen", + "voice_section": "Sprachkanäle" + }, "common": { "add": "Hinzufügen", "back": "Zurück", @@ -364,8 +538,6 @@ "loading_address": "Adresse wird geladen...", "my_location": "Mein Standort", "next": "Weiter", - "noActiveUnit": "Keine aktive Einheit festgelegt", - "noActiveUnitDescription": "Bitte eine aktive Einheit auf der Einstellungsseite festlegen, um auf Statussteuerungen zuzugreifen", "noDataAvailable": "Keine Daten verfügbar", "no_address_found": "Keine Adresse gefunden", "no_location": "Keine Standortdaten verfügbar", @@ -548,7 +720,7 @@ "sso_signing_in": "Anmeldung mit SSO...", "sso_subtitle": "Benutzernamen eingeben, um die SSO-Konfiguration nachzuschlagen", "sso_title": "Mit SSO anmelden", - "subtitle": "Anmeldedaten eingeben, um auf Resgrid Unit zuzugreifen", + "subtitle": "Anmeldedaten eingeben, um auf Resgrid IC zuzugreifen", "title": "Anmelden", "username": "Benutzername", "username_not_found": "Benutzername nicht gefunden", @@ -588,6 +760,8 @@ "layer_toggles": "Ebenenumschalter", "layers_on": "{{active}} / {{total}} aktiv", "loading": "Karten werden geladen...", + "map_layers": "Kartenebenen", + "no_layers": "Keine Kartenebenen verfügbar", "no_maps": "Keine Karten", "no_maps_description": "Für Ihre Abteilung sind keine Karten verfügbar.", "no_results": "Keine Ergebnisse gefunden", @@ -619,8 +793,23 @@ "search": "Notizen suchen...", "title": "Notizen" }, + "offline": { + "offline_message": "Offline — letzte synchronisierte Daten werden angezeigt", + "offline_with_pending": "Offline — {{count}} Änderung(en) werden bei Verbindung synchronisiert", + "pending_count": "{{count}} Änderung(en) warten auf Synchronisierung", + "sync_now": "Jetzt synchronisieren", + "syncing": "Synchronisiere..." + }, "onboarding": { - "message": "Willkommen bei obytes app site" + "awarenessDescription": "Verfolge Einheiten und Personal mit Status- und Standortaktualisierungen in Echtzeit auf der Einsatzkarte", + "awarenessTitle": "Live-Lagebewusstsein", + "boardDescription": "Deine digitale Einsatzleitungstafel – verwalte Einsätze, ICS-Rollen und Ressourcen vom Tablet oder Desktop", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Weise Personal Rollen und Gruppen zu, behalte die Übersicht und halte deinen Führungsstab synchron, während sich der Einsatz entwickelt", + "coordinateTitle": "Führen und koordinieren", + "getStarted": "Los geht's", + "next": "Weiter", + "skip": "Überspringen" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "Über", "account": "Konto", "activating_unit": "Einheit wird aktiviert...", - "active_unit": "Aktive Einheit", "app_info": "App-Info", "app_name": "App-Name", "arabic": "Arabisch", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Abmeldung bestätigen", "more": "Mehr", "no_units_available": "Keine Einheiten verfügbar", - "none_selected": "Keine ausgewählt", "notifications": "Push-Benachrichtigungen", "notifications_description": "Benachrichtigungen aktivieren, um Warnungen und Updates zu erhalten", "notifications_enable": "Benachrichtigungen aktivieren", @@ -873,7 +1060,6 @@ "server": "Server", "server_url": "Server-URL", "server_url_note": "Hinweis: Dies ist die URL der Resgrid API. Sie wird verwendet, um eine Verbindung zum Resgrid-Server herzustellen. Schließen Sie /api/v4 nicht in die URL ein und fügen Sie keinen abschließenden Schrägstrich hinzu.", - "set_active_unit": "Aktive Einheit festlegen", "share": "Teilen", "spanish": "Spanisch", "status_page": "Systemstatus", @@ -896,6 +1082,9 @@ "version": "Version", "website": "Website" }, + "sidebar": { + "menu": "Menü" + }, "status": { "add_note": "Notiz hinzufügen", "both_destinations_enabled": "Kann auf Anrufe oder Stationen reagieren", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Anrufe", + "command_board": "Führungstafel", "contacts": "Kontakte", "incidents": "Incidents", "map": "Karte", diff --git a/src/translations/en.json b/src/translations/en.json index 5a19da7..e462722 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Buffering", @@ -184,10 +184,6 @@ "notes": "Notes", "priority": "Priority", "reference_id": "Reference ID", - "set_active": "Set Active", - "set_active_error": "Failed to set call as active", - "set_active_success": "Call set as active", - "setting_active": "Setting Active...", "status": "Status", "tabs": { "check_in": "Check-In", @@ -345,6 +341,184 @@ "type_sector_rotation": "Sector Rotation", "type_unit": "Unit" }, + "command": { + "accountability_section": "Accountability", + "acknowledge": "Acknowledge", + "active_badge": "Active Command", + "add": "Add", + "add_assignment": "Assign ICS Role", + "add_channel": "Add Channel", + "add_entry": "Add Accountability Entry", + "add_lane": "Add Lane", + "add_objective": "Add Objective", + "add_resource": "Add Resource", + "add_timer": "Add Timer", + "add_to_command": "Add to Command", + "added_to_command": "Added to the command board", + "agency_label": "Company / Agency (optional)", + "agency_placeholder": "e.g. Acme Security, Red Cross", + "assign": "Assign", + "assign_resource": "Assign Resource", + "assignment_label": "Assignment / Location", + "assignment_placeholder": "e.g. Interior attack, Division A", + "channel_connected": "Connected", + "channel_join": "Join", + "channel_leave": "Leave", + "channel_name_placeholder": "e.g. Tactical 1", + "channel_preset_command": "Command", + "channel_preset_tactical": "Tactical", + "close_channels": "Close All", + "crew_placeholder": "Person or crew name", + "current_commander": "IC", + "drag_move_hint": "Press and hold a resource to drag it, or tap it and then tap a lane.", + "empty_accountability": "No accountability entries yet — add crews to track them", + "empty_channels": "No tactical channels — open one for this incident", + "empty_description": "Select an active incident or event to start building your command board. Lanes, role assignments, resources, and accountability will appear here.", + "empty_heading": "Command Board", + "empty_objectives": "No tactical objectives yet", + "empty_resources": "No resources tracked yet — add apparatus, crews, or equipment", + "empty_roles": "No ICS roles assigned yet", + "empty_structure": "No lanes yet — add Divisions, Groups, or Staging to build the command structure", + "empty_timeline": "No log entries yet", + "empty_timers": "No timers running — add a PAR check reminder", + "end_command": "End Command", + "evaluate": "Re-evaluate", + "go_to_calls": "Go to Calls", + "in_this_lane": "In this lane", + "lane_color_label": "Lane Color (optional — colors this lane’s markers on the map)", + "lane_limits_label": "Lane Limits (optional)", + "lane_name_label": "Lane Name", + "lane_name_placeholder": "e.g. Division A, Roof Group, Staging", + "lane_suggestion_customer_service": "Customer Service", + "lane_suggestion_gate": "Gate Control", + "lane_suggestion_medical": "Medical", + "lane_suggestion_operations": "Operations", + "lane_suggestion_parking": "Parking", + "lane_suggestion_patrol": "Patrol", + "lane_suggestion_security_post": "Security Post", + "lane_suggestion_stage": "Stage", + "lane_suggestion_staging": "Staging", + "lane_suggestion_vendor": "Vendor Area", + "lane_type_label": "Lane Type", + "lane_understaffed": "{{count}}/{{min}} units — under-filled", + "lane_unit_capacity": "{{count}}/{{max}} units", + "last_check_in": "Last check-in", + "limit_max_riding": "Max riding", + "limit_max_time": "Rotate after (min)", + "limit_max_units": "Max units", + "limit_min_riding": "Min riding", + "limit_min_time": "Min time (min)", + "limit_min_units": "Min units", + "limit_none": "None", + "limit_riding_help": "Personnel that must be riding a unit for it to fit this lane. Checked when a unit is assigned; blocked or warned per Force Requirements.", + "limit_time_help": "Rotation guidance in minutes. Leaving before the minimum flags the move; past the maximum the resource shows a Rotate badge. Nothing is removed automatically.", + "limit_units_help": "How many units this lane should have. Below the minimum the lane shows as under-filled (never blocks); at the maximum, adding more units is blocked or warned.", + "map_command_section": "Command Board", + "move": "Move", + "move_conflict_message": "{{resource}} is already assigned to {{lane}}. Move it to {{target}}?", + "move_conflict_title": "Resource Already Assigned", + "move_resource_to_lane": "Move {{resource}} to {{lane}}", + "move_selected_hint": "{{resource}} selected — tap a lane to move it.", + "no_accountability": "No personnel checked in to this incident yet", + "no_check_in_yet": "No check-in yet", + "no_personnel_found": "No personnel found", + "no_resources_found": "No resources found", + "no_resources_in_lane": "No resources assigned to this lane", + "node_branch": "Branch", + "node_division": "Division", + "node_group": "Group", + "node_sector": "Sector", + "node_staging": "Staging", + "node_strike_team": "Strike Team", + "node_task_force": "Task Force", + "node_unified_command": "Unified Command", + "not_on_board": "Not on the board", + "note_label": "Note (optional)", + "note_placeholder": "Staging, en route, assignment...", + "objective_benchmark": "Benchmark", + "objective_completed": "Done", + "objective_general": "General", + "objective_name_label": "Objective", + "objective_name_placeholder": "e.g. Primary search complete, Utilities secured", + "objective_safety": "Safety", + "objective_type_label": "Type", + "objectives_section": "Tactical Objectives", + "open_board": "Open Command Board", + "par_critical": "Critical", + "par_green": "OK", + "par_hint": "Tap a PAR badge to toggle between OK and Pending", + "par_ok": "PAR OK", + "par_pending": "PAR Pending", + "par_warning": "Warning", + "person_label": "Name", + "person_name_placeholder": "Person's name", + "person_placeholder": "Person assigned to this role", + "person_role_label": "Role / Assignment", + "person_role_placeholder": "e.g. Medic, Patrol, Gate staff", + "provisional_badge": "Offline — pending sync", + "release_from_command": "Release from Command", + "requirements_warning": "Requirements", + "resource_kind_external": "External", + "resource_kind_personnel": "Personnel", + "resource_kind_units": "Units", + "resource_name_label": "Resource", + "resource_name_placeholder": "e.g. Engine 3, Ladder 1, Mutual aid crew", + "resource_person": "External Person", + "resource_unit": "Unit / Crew", + "resources_section": "Resources", + "role_finance": "Finance/Admin", + "role_ic": "Incident Commander", + "role_label": "ICS Role", + "role_liaison": "Liaison", + "role_logistics": "Logistics", + "role_open": "Open", + "role_operations": "Operations", + "role_pio": "Public Information", + "role_placeholder": "Pick a preset or type a custom role", + "role_planning": "Planning", + "role_rehab": "Rehab Officer", + "role_safety": "Safety Officer", + "role_staging": "Staging Area Manager", + "role_transport": "Transport Officer", + "role_triage": "Triage Officer", + "roles_section": "ICS Role Assignments", + "rotation_due": "Rotate", + "search_personnel": "Search personnel...", + "search_resources": "Search resources...", + "selected": "Selected", + "show_more": "Show more", + "start_command": "Start Command", + "start_command_description": "Pick a template or start blank — templates can model ICS structures, security details, or event staffing plans like a fair or concert.", + "start_command_title": "Start Command Board", + "start_error": "Failed to start command for this call", + "start_success": "Command started for this call", + "start_timer": "Start", + "starting": "Starting...", + "structure_section": "Command Structure", + "template_blank": "Blank Board", + "template_blank_description": "Start empty and build lanes as you go", + "template_lane_count": "{{count}} lane(s)", + "timeline_section": "Incident Log", + "timer_default_name": "PAR Check", + "timer_due_in": "Due in {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "e.g. PAR Check, Rehab rotation", + "timer_overdue": "Overdue", + "timer_overdue_by": "Overdue {{time}}", + "timers_section": "Timers & PAR", + "transfer": "Transfer", + "transfer_command": "Transfer Command", + "transfer_command_hint": "Hand incident command to another user. The board stays shared.", + "transfer_error": "Failed to transfer command", + "transfer_success": "Command transferred", + "transmission_log": "Transmission Log", + "transmission_seconds": "{{count}}s", + "unassigned": "Unassigned", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Roles {{filled}}/{{total}}", + "view_call": "View Call", + "voice_section": "Voice Channels" + }, "common": { "add": "Add", "back": "Back", @@ -364,8 +538,6 @@ "loading_address": "Loading address...", "my_location": "My Location", "next": "Next", - "noActiveUnit": "No Active Unit Set", - "noActiveUnitDescription": "Please set an active unit from the settings page to access status controls", "noDataAvailable": "No data available", "no_address_found": "No address found", "no_location": "No location data available", @@ -548,7 +720,7 @@ "sso_signing_in": "Signing in with SSO...", "sso_subtitle": "Enter your username to look up your SSO configuration", "sso_title": "Sign In with SSO", - "subtitle": "Enter your credentials to access Resgrid Unit", + "subtitle": "Enter your credentials to access Resgrid IC", "title": "Sign In", "username": "Username", "username_not_found": "Username not found", @@ -588,6 +760,8 @@ "layer_toggles": "Layer Toggles", "layers_on": "{{active}} / {{total}} active", "loading": "Loading maps...", + "map_layers": "Map Layers", + "no_layers": "No map layers available", "no_maps": "No Maps", "no_maps_description": "No maps are available for your department.", "no_results": "No results found", @@ -619,8 +793,23 @@ "search": "Search notes...", "title": "Notes" }, + "offline": { + "offline_message": "Offline — showing last synced data", + "offline_with_pending": "Offline — {{count}} change(s) will sync when reconnected", + "pending_count": "{{count}} change(s) waiting to sync", + "sync_now": "Sync Now", + "syncing": "Syncing..." + }, "onboarding": { - "message": "Welcome to obytes app site" + "awarenessDescription": "Track units and personnel with real-time status and location updates on the incident map", + "awarenessTitle": "Live Situational Awareness", + "boardDescription": "Your digital incident command board — manage incidents, ICS roles, and resources from a tablet or desktop", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Assign personnel to roles and groups, maintain accountability, and keep your command staff in sync as the incident evolves", + "coordinateTitle": "Command and Coordinate", + "getStarted": "Let's Get Started", + "next": "Next", + "skip": "Skip" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "About", "account": "Account", "activating_unit": "Activating unit...", - "active_unit": "Active Unit", "app_info": "App Info", "app_name": "App Name", "arabic": "Arabic", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Confirm Logout", "more": "More", "no_units_available": "No units available", - "none_selected": "None Selected", "notifications": "Push Notifications", "notifications_description": "Enable notifications to receive alerts and updates", "notifications_enable": "Enable Notifications", @@ -873,7 +1060,6 @@ "server": "Server", "server_url": "Server URL", "server_url_note": "Note: This is the URL of the Resgrid API. It is used to connect to the Resgrid server. Do not include /api/v4 in the URL or a trailing slash.", - "set_active_unit": "Set Active Unit", "share": "Share", "spanish": "Spanish", "status_page": "System Status", @@ -896,6 +1082,9 @@ "version": "Version", "website": "Website" }, + "sidebar": { + "menu": "Menu" + }, "status": { "add_note": "Add Note", "both_destinations_enabled": "Can respond to calls or stations", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Calls", + "command_board": "Command Board", "contacts": "Contacts", "incidents": "Incidents", "map": "Map", diff --git a/src/translations/es.json b/src/translations/es.json index 0c5073a..9415cf0 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Almacenando en búfer", @@ -184,10 +184,6 @@ "notes": "Notas", "priority": "Prioridad", "reference_id": "ID de referencia", - "set_active": "Establecer como activa", - "set_active_error": "Error al establecer la llamada como activa", - "set_active_success": "Llamada establecida como activa", - "setting_active": "Estableciendo como activa...", "status": "Estado", "tabs": { "check_in": "Registro", @@ -345,6 +341,184 @@ "type_sector_rotation": "Rotación de sector", "type_unit": "Unidad" }, + "command": { + "accountability_section": "Rendición de cuentas", + "acknowledge": "Confirmar", + "active_badge": "Comando activo", + "add": "Agregar", + "add_assignment": "Asignar rol ICS", + "add_channel": "Añadir canal", + "add_entry": "Agregar entrada de personal", + "add_lane": "Agregar carril", + "add_objective": "Agregar objetivo", + "add_resource": "Agregar recurso", + "add_timer": "Añadir temporizador", + "add_to_command": "Añadir al mando", + "added_to_command": "Añadido al tablero de mando", + "agency_label": "Empresa / Agencia (opcional)", + "agency_placeholder": "p. ej. Seguridad Acme, Cruz Roja", + "assign": "Asignar", + "assign_resource": "Asignar recurso", + "assignment_label": "Asignación / Ubicación", + "assignment_placeholder": "p. ej. Ataque interior, División A", + "channel_connected": "Conectado", + "channel_join": "Unirse", + "channel_leave": "Salir", + "channel_name_placeholder": "p. ej. Táctico 1", + "channel_preset_command": "Mando", + "channel_preset_tactical": "Táctico", + "close_channels": "Cerrar todo", + "crew_placeholder": "Nombre de la persona o del equipo", + "current_commander": "CI", + "drag_move_hint": "Mantén pulsado un recurso para arrastrarlo, o tócalo y luego toca un carril.", + "empty_accountability": "Aún no hay entradas de personal: agrega equipos para seguirlos", + "empty_channels": "Sin canales tácticos — abre uno para este incidente", + "empty_description": "Selecciona un incidente o evento activo para comenzar a construir tu panel de comando. Carriles, roles, recursos y rendición de cuentas aparecerán aquí.", + "empty_heading": "Panel de comando", + "empty_objectives": "Aún no hay objetivos tácticos", + "empty_resources": "Aún no hay recursos: agrega vehículos, equipos o material", + "empty_roles": "Aún no hay roles ICS asignados", + "empty_structure": "Aún no hay carriles: agrega Divisiones, Grupos o Zona de espera para armar la estructura de comando", + "empty_timeline": "Aún no hay entradas en el registro", + "empty_timers": "Sin temporizadores — añade un recordatorio de PAR", + "end_command": "Finalizar comando", + "evaluate": "Reevaluar", + "go_to_calls": "Ir a llamadas", + "in_this_lane": "En este carril", + "lane_color_label": "Color del carril (opcional — colorea sus marcadores en el mapa)", + "lane_limits_label": "Límites del carril (opcional)", + "lane_name_label": "Nombre del carril", + "lane_name_placeholder": "p. ej. División A, Grupo de techo, Zona de espera", + "lane_suggestion_customer_service": "Atención al público", + "lane_suggestion_gate": "Control de accesos", + "lane_suggestion_medical": "Médico", + "lane_suggestion_operations": "Operaciones", + "lane_suggestion_parking": "Estacionamiento", + "lane_suggestion_patrol": "Patrulla", + "lane_suggestion_security_post": "Puesto de seguridad", + "lane_suggestion_stage": "Escenario", + "lane_suggestion_staging": "Zona de espera", + "lane_suggestion_vendor": "Zona de vendedores", + "lane_type_label": "Tipo de carril", + "lane_understaffed": "{{count}}/{{min}} unidades — incompleto", + "lane_unit_capacity": "{{count}}/{{max}} unidades", + "last_check_in": "Último registro", + "limit_max_riding": "Máx. a bordo", + "limit_max_time": "Rotar tras (min)", + "limit_max_units": "Máx. unidades", + "limit_min_riding": "Mín. a bordo", + "limit_min_time": "Tiempo mín. (min)", + "limit_min_units": "Mín. unidades", + "limit_none": "Ninguno", + "limit_riding_help": "Personal que debe ir a bordo de una unidad para que encaje en este carril. Se comprueba al asignar; bloquea o avisa según Forzar requisitos.", + "limit_time_help": "Guía de rotación en minutos. Salir antes del mínimo marca el movimiento; superado el máximo el recurso muestra la insignia Rotar. Nada se elimina automáticamente.", + "limit_units_help": "Cuántas unidades debería tener este carril. Por debajo del mínimo se muestra incompleto (nunca bloquea); en el máximo, añadir más unidades se bloquea o se avisa.", + "map_command_section": "Tablero de mando", + "move": "Mover", + "move_conflict_message": "{{resource}} ya está asignado a {{lane}}. ¿Moverlo a {{target}}?", + "move_conflict_title": "Recurso ya asignado", + "move_resource_to_lane": "Mover {{resource}} a {{lane}}", + "move_selected_hint": "{{resource}} seleccionado — toca un carril para moverlo.", + "no_accountability": "Aún no hay personal registrado en este incidente", + "no_check_in_yet": "Sin registro todavía", + "no_personnel_found": "No se encontró personal", + "no_resources_found": "No se encontraron recursos", + "no_resources_in_lane": "No hay recursos asignados a este carril", + "node_branch": "Rama", + "node_division": "División", + "node_group": "Grupo", + "node_sector": "Sector", + "node_staging": "Zona de espera", + "node_strike_team": "Equipo de ataque", + "node_task_force": "Fuerza de tarea", + "node_unified_command": "Comando unificado", + "not_on_board": "No está en el tablero", + "note_label": "Nota (opcional)", + "note_placeholder": "En espera, en camino, asignación...", + "objective_benchmark": "Hito", + "objective_completed": "Hecho", + "objective_general": "General", + "objective_name_label": "Objetivo", + "objective_name_placeholder": "p. ej. Búsqueda primaria completa, Servicios asegurados", + "objective_safety": "Seguridad", + "objective_type_label": "Tipo", + "objectives_section": "Objetivos tácticos", + "open_board": "Abrir panel de comando", + "par_critical": "Crítico", + "par_green": "OK", + "par_hint": "Toca una insignia PAR para alternar entre OK y Pendiente", + "par_ok": "PAR OK", + "par_pending": "PAR pendiente", + "par_warning": "Advertencia", + "person_label": "Nombre", + "person_name_placeholder": "Nombre de la persona", + "person_placeholder": "Persona asignada a este rol", + "person_role_label": "Rol / Asignación", + "person_role_placeholder": "p. ej. Paramédico, Patrulla, Personal de acceso", + "provisional_badge": "Sin conexión — pendiente de sincronizar", + "release_from_command": "Liberar del mando", + "requirements_warning": "Requisitos", + "resource_kind_external": "Externos", + "resource_kind_personnel": "Personal", + "resource_kind_units": "Unidades", + "resource_name_label": "Recurso", + "resource_name_placeholder": "p. ej. Bomba 3, Escalera 1, Equipo de ayuda mutua", + "resource_person": "Persona externa", + "resource_unit": "Unidad / Equipo", + "resources_section": "Recursos", + "role_finance": "Finanzas/Administración", + "role_ic": "Comandante del incidente", + "role_label": "Rol ICS", + "role_liaison": "Enlace", + "role_logistics": "Logística", + "role_open": "Vacante", + "role_operations": "Operaciones", + "role_pio": "Información pública", + "role_placeholder": "Elige un rol o escribe uno personalizado", + "role_planning": "Planificación", + "role_rehab": "Oficial de rehabilitación", + "role_safety": "Oficial de seguridad", + "role_staging": "Encargado del área de espera", + "role_transport": "Oficial de transporte", + "role_triage": "Oficial de triaje", + "roles_section": "Asignaciones de roles ICS", + "rotation_due": "Rotar", + "search_personnel": "Buscar personal...", + "search_resources": "Buscar recursos...", + "selected": "Seleccionado", + "show_more": "Mostrar más", + "start_command": "Iniciar comando", + "start_command_description": "Elige una plantilla o comienza en blanco: las plantillas pueden modelar estructuras ICS, dispositivos de seguridad o dotaciones para eventos como ferias o conciertos.", + "start_command_title": "Iniciar panel de comando", + "start_error": "No se pudo iniciar el comando para esta llamada", + "start_success": "Comando iniciado para esta llamada", + "start_timer": "Iniciar", + "starting": "Iniciando...", + "structure_section": "Estructura de comando", + "template_blank": "Panel en blanco", + "template_blank_description": "Comienza vacío y crea carriles sobre la marcha", + "template_lane_count": "{{count}} carril(es)", + "timeline_section": "Registro del incidente", + "timer_default_name": "Control PAR", + "timer_due_in": "Vence en {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "p. ej. Control PAR, Rotación de rehab", + "timer_overdue": "Vencido", + "timer_overdue_by": "Vencido {{time}}", + "timers_section": "Temporizadores y PAR", + "transfer": "Transferir", + "transfer_command": "Transferir el mando", + "transfer_command_hint": "Entrega el mando del incidente a otro usuario. El tablero sigue compartido.", + "transfer_error": "No se pudo transferir el mando", + "transfer_success": "Mando transferido", + "transmission_log": "Registro de transmisiones", + "transmission_seconds": "{{count}}s", + "unassigned": "Sin asignar", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Roles {{filled}}/{{total}}", + "view_call": "Ver llamada", + "voice_section": "Canales de voz" + }, "common": { "add": "Añadir", "back": "Atrás", @@ -364,8 +538,6 @@ "loading_address": "Cargando dirección...", "my_location": "Mi ubicación", "next": "Siguiente", - "noActiveUnit": "No hay unidad activa establecida", - "noActiveUnitDescription": "Por favor establezca una unidad activa desde la página de configuración para acceder a los controles de estado", "noDataAvailable": "No hay datos disponibles", "no_address_found": "No se encontró dirección", "no_location": "No hay datos de ubicación disponibles", @@ -548,7 +720,7 @@ "sso_signing_in": "Iniciando sesión con SSO...", "sso_subtitle": "Ingresa tu nombre de usuario para buscar tu configuración de SSO", "sso_title": "Iniciar sesión con SSO", - "subtitle": "Introduce tus credenciales para acceder a Resgrid Unit", + "subtitle": "Introduce tus credenciales para acceder a Resgrid IC", "title": "Iniciar sesión", "username": "Nombre de usuario", "username_not_found": "Usuario no encontrado", @@ -588,6 +760,8 @@ "layer_toggles": "Controles de Capas", "layers_on": "{{active}} / {{total}} activos", "loading": "Cargando mapas...", + "map_layers": "Capas del mapa", + "no_layers": "No hay capas de mapa disponibles", "no_maps": "Sin Mapas", "no_maps_description": "No hay mapas disponibles para su departamento.", "no_results": "No se encontraron resultados", @@ -619,8 +793,23 @@ "search": "Buscar notas...", "title": "Notas" }, + "offline": { + "offline_message": "Sin conexión — mostrando los últimos datos sincronizados", + "offline_with_pending": "Sin conexión — {{count}} cambio(s) se sincronizarán al reconectar", + "pending_count": "{{count}} cambio(s) pendientes de sincronizar", + "sync_now": "Sincronizar ahora", + "syncing": "Sincronizando..." + }, "onboarding": { - "message": "Bienvenido al sitio de la aplicación obytes" + "awarenessDescription": "Sigue unidades y personal con actualizaciones de estado y ubicación en tiempo real en el mapa del incidente", + "awarenessTitle": "Conciencia situacional en vivo", + "boardDescription": "Tu panel digital de comando de incidentes: gestiona incidentes, roles ICS y recursos desde una tableta o escritorio", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Asigna personal a roles y grupos, mantén la rendición de cuentas y sincroniza a tu personal de mando a medida que evoluciona el incidente", + "coordinateTitle": "Comanda y coordina", + "getStarted": "Comencemos", + "next": "Siguiente", + "skip": "Omitir" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "Acerca de", "account": "Cuenta", "activating_unit": "Activando unidad...", - "active_unit": "Unidad activa", "app_info": "Información de la aplicación", "app_name": "Nombre de la aplicación", "arabic": "Árabe", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Confirmar cierre de sesión", "more": "Más", "no_units_available": "No hay unidades disponibles", - "none_selected": "Ninguna seleccionada", "notifications": "Notificaciones push", "notifications_description": "Activa las notificaciones para recibir alertas y actualizaciones", "notifications_enable": "Activar notificaciones", @@ -873,7 +1060,6 @@ "server": "Servidor", "server_url": "URL del servidor", "server_url_note": "Nota: Esta es la URL de la API de Resgrid. Se utiliza para conectarse al servidor de Resgrid. No incluyas /api/v4 en la URL ni una barra diagonal al final.", - "set_active_unit": "Establecer unidad activa", "share": "Compartir", "spanish": "Español", "status_page": "Estado del sistema", @@ -896,6 +1082,9 @@ "version": "Versión", "website": "Sitio web" }, + "sidebar": { + "menu": "Menú" + }, "status": { "add_note": "Añadir Nota", "both_destinations_enabled": "Puede responder a llamadas o estaciones", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Llamadas", + "command_board": "Panel de comando", "contacts": "Contactos", "incidents": "Incidents", "map": "Mapa", diff --git a/src/translations/fr.json b/src/translations/fr.json index a08828d..c5f1618 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Mise en mémoire tampon", @@ -184,10 +184,6 @@ "notes": "Notes", "priority": "Priorité", "reference_id": "ID de référence", - "set_active": "Définir comme actif", - "set_active_error": "Échec de la définition de l'appel comme actif", - "set_active_success": "Appel défini comme actif", - "setting_active": "Définition comme actif...", "status": "Statut", "tabs": { "check_in": "Pointage", @@ -345,6 +341,184 @@ "type_sector_rotation": "Rotation de secteur", "type_unit": "Unité" }, + "command": { + "accountability_section": "Contrôle des effectifs", + "acknowledge": "Acquitter", + "active_badge": "Commandement actif", + "add": "Ajouter", + "add_assignment": "Affecter un rôle ICS", + "add_channel": "Ajouter un canal", + "add_entry": "Ajouter une entrée", + "add_lane": "Ajouter une voie", + "add_objective": "Ajouter un objectif", + "add_resource": "Ajouter une ressource", + "add_timer": "Ajouter un minuteur", + "add_to_command": "Ajouter au commandement", + "added_to_command": "Ajouté au tableau de commandement", + "agency_label": "Société / Organisme (facultatif)", + "agency_placeholder": "ex. Sécurité Acme, Croix-Rouge", + "assign": "Affecter", + "assign_resource": "Affecter une ressource", + "assignment_label": "Affectation / Emplacement", + "assignment_placeholder": "ex. Attaque intérieure, Division A", + "channel_connected": "Connecté", + "channel_join": "Rejoindre", + "channel_leave": "Quitter", + "channel_name_placeholder": "ex. Tactique 1", + "channel_preset_command": "Commandement", + "channel_preset_tactical": "Tactique", + "close_channels": "Tout fermer", + "crew_placeholder": "Nom de la personne ou de l'équipe", + "current_commander": "CI", + "drag_move_hint": "Appuyez longuement sur une ressource pour la faire glisser, ou touchez-la puis touchez une voie.", + "empty_accountability": "Aucune entrée pour le moment — ajoutez des équipes pour les suivre", + "empty_channels": "Aucun canal tactique — ouvrez-en un pour cet incident", + "empty_description": "Sélectionnez un incident ou un événement actif pour construire votre tableau de commandement. Voies, rôles, ressources et suivi des effectifs apparaîtront ici.", + "empty_heading": "Tableau de commandement", + "empty_objectives": "Aucun objectif tactique pour le moment", + "empty_resources": "Aucune ressource pour le moment — ajoutez des engins, équipes ou matériels", + "empty_roles": "Aucun rôle ICS affecté pour le moment", + "empty_structure": "Aucune voie pour le moment — ajoutez des Divisions, Groupes ou Zone d'attente pour bâtir la structure de commandement", + "empty_timeline": "Aucune entrée de journal pour le moment", + "empty_timers": "Aucun minuteur — ajoutez un rappel de PAR", + "end_command": "Terminer le commandement", + "evaluate": "Réévaluer", + "go_to_calls": "Aller aux appels", + "in_this_lane": "Dans cette voie", + "lane_color_label": "Couleur de la voie (optionnel — colore ses marqueurs sur la carte)", + "lane_limits_label": "Limites de la voie (optionnel)", + "lane_name_label": "Nom de la voie", + "lane_name_placeholder": "ex. Division A, Groupe toiture, Zone d'attente", + "lane_suggestion_customer_service": "Service client", + "lane_suggestion_gate": "Contrôle des accès", + "lane_suggestion_medical": "Médical", + "lane_suggestion_operations": "Opérations", + "lane_suggestion_parking": "Stationnement", + "lane_suggestion_patrol": "Patrouille", + "lane_suggestion_security_post": "Poste de sécurité", + "lane_suggestion_stage": "Scène", + "lane_suggestion_staging": "Zone d'attente", + "lane_suggestion_vendor": "Zone des exposants", + "lane_type_label": "Type de voie", + "lane_understaffed": "{{count}}/{{min}} unités — incomplet", + "lane_unit_capacity": "{{count}}/{{max}} unités", + "last_check_in": "Dernier pointage", + "limit_max_riding": "Max à bord", + "limit_max_time": "Relève après (min)", + "limit_max_units": "Max unités", + "limit_min_riding": "Min à bord", + "limit_min_time": "Temps min (min)", + "limit_min_units": "Min unités", + "limit_none": "Aucun", + "limit_riding_help": "Personnel devant être à bord d’une unité pour qu’elle convienne à cette voie. Vérifié à l’affectation ; bloqué ou averti selon Forcer les exigences.", + "limit_time_help": "Guide de rotation en minutes. Partir avant le minimum signale le mouvement ; au-delà du maximum la ressource affiche le badge Relève. Rien n’est retiré automatiquement.", + "limit_units_help": "Nombre d’unités souhaité pour cette voie. Sous le minimum la voie apparaît incomplète (ne bloque jamais) ; au maximum, l’ajout d’unités est bloqué ou averti.", + "map_command_section": "Tableau de commandement", + "move": "Déplacer", + "move_conflict_message": "{{resource}} est déjà affecté à {{lane}}. Le déplacer vers {{target}} ?", + "move_conflict_title": "Ressource déjà affectée", + "move_resource_to_lane": "Déplacer {{resource}} vers {{lane}}", + "move_selected_hint": "{{resource}} sélectionné — touchez une voie pour le déplacer.", + "no_accountability": "Aucun personnel n'est encore pointé sur cet incident", + "no_check_in_yet": "Aucun pointage pour le moment", + "no_personnel_found": "Aucun personnel trouvé", + "no_resources_found": "Aucune ressource trouvée", + "no_resources_in_lane": "Aucune ressource affectée à cette voie", + "node_branch": "Branche", + "node_division": "Division", + "node_group": "Groupe", + "node_sector": "Secteur", + "node_staging": "Zone d'attente", + "node_strike_team": "Équipe d'intervention", + "node_task_force": "Force opérationnelle", + "node_unified_command": "Commandement unifié", + "not_on_board": "Absent du tableau", + "note_label": "Note (facultatif)", + "note_placeholder": "En attente, en route, affectation...", + "objective_benchmark": "Jalon", + "objective_completed": "Fait", + "objective_general": "Général", + "objective_name_label": "Objectif", + "objective_name_placeholder": "ex. Recherche primaire terminée, Réseaux sécurisés", + "objective_safety": "Sécurité", + "objective_type_label": "Type", + "objectives_section": "Objectifs tactiques", + "open_board": "Ouvrir le tableau de commandement", + "par_critical": "Critique", + "par_green": "OK", + "par_hint": "Touchez un badge PAR pour basculer entre OK et En attente", + "par_ok": "PAR OK", + "par_pending": "PAR en attente", + "par_warning": "Alerte", + "person_label": "Nom", + "person_name_placeholder": "Nom de la personne", + "person_placeholder": "Personne affectée à ce rôle", + "person_role_label": "Rôle / Affectation", + "person_role_placeholder": "ex. Secouriste, Patrouille, Agent de porte", + "provisional_badge": "Hors ligne — en attente de synchronisation", + "release_from_command": "Libérer du commandement", + "requirements_warning": "Exigences", + "resource_kind_external": "Externes", + "resource_kind_personnel": "Personnel", + "resource_kind_units": "Unités", + "resource_name_label": "Ressource", + "resource_name_placeholder": "ex. Engin 3, Échelle 1, Équipe d'entraide", + "resource_person": "Personne externe", + "resource_unit": "Unité / Équipe", + "resources_section": "Ressources", + "role_finance": "Finances/Administration", + "role_ic": "Commandant de l'incident", + "role_label": "Rôle ICS", + "role_liaison": "Liaison", + "role_logistics": "Logistique", + "role_open": "Vacant", + "role_operations": "Opérations", + "role_pio": "Information publique", + "role_placeholder": "Choisissez un rôle ou saisissez-en un personnalisé", + "role_planning": "Planification", + "role_rehab": "Officier réhabilitation", + "role_safety": "Officier de sécurité", + "role_staging": "Responsable de la zone d'attente", + "role_transport": "Officier transport", + "role_triage": "Officier triage", + "roles_section": "Affectations des rôles ICS", + "rotation_due": "Relève", + "search_personnel": "Rechercher du personnel...", + "search_resources": "Rechercher des ressources...", + "selected": "Sélectionné", + "show_more": "Afficher plus", + "start_command": "Démarrer le commandement", + "start_command_description": "Choisissez un modèle ou partez de zéro — les modèles peuvent représenter des structures ICS, des dispositifs de sécurité ou des plans d'effectifs pour une foire ou un concert.", + "start_command_title": "Démarrer le tableau de commandement", + "start_error": "Échec du démarrage du commandement pour cet appel", + "start_success": "Commandement démarré pour cet appel", + "start_timer": "Démarrer", + "starting": "Démarrage...", + "structure_section": "Structure de commandement", + "template_blank": "Tableau vierge", + "template_blank_description": "Commencez vide et ajoutez des voies au fur et à mesure", + "template_lane_count": "{{count}} voie(s)", + "timeline_section": "Journal de l’incident", + "timer_default_name": "Contrôle PAR", + "timer_due_in": "Échéance dans {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "ex. Contrôle PAR, Rotation réhab", + "timer_overdue": "En retard", + "timer_overdue_by": "En retard de {{time}}", + "timers_section": "Minuteurs et PAR", + "transfer": "Transférer", + "transfer_command": "Transférer le commandement", + "transfer_command_hint": "Confiez le commandement de l’incident à un autre utilisateur. Le tableau reste partagé.", + "transfer_error": "Échec du transfert du commandement", + "transfer_success": "Commandement transféré", + "transmission_log": "Journal des transmissions", + "transmission_seconds": "{{count}}s", + "unassigned": "Non affecté", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Rôles {{filled}}/{{total}}", + "view_call": "Voir l'appel", + "voice_section": "Canaux vocaux" + }, "common": { "add": "Ajouter", "back": "Retour", @@ -364,8 +538,6 @@ "loading_address": "Chargement de l'adresse...", "my_location": "Ma localisation", "next": "Suivant", - "noActiveUnit": "Aucune unité active définie", - "noActiveUnitDescription": "Veuillez définir une unité active depuis la page des paramètres pour accéder aux contrôles de statut", "noDataAvailable": "Aucune donnée disponible", "no_address_found": "Aucune adresse trouvée", "no_location": "Aucune donnée de localisation disponible", @@ -548,7 +720,7 @@ "sso_signing_in": "Connexion avec SSO en cours...", "sso_subtitle": "Saisir votre nom d'utilisateur pour rechercher votre configuration SSO", "sso_title": "Se connecter avec SSO", - "subtitle": "Saisir vos identifiants pour accéder à Resgrid Unit", + "subtitle": "Saisir vos identifiants pour accéder à Resgrid IC", "title": "Connexion", "username": "Nom d'utilisateur", "username_not_found": "Nom d'utilisateur introuvable", @@ -588,6 +760,8 @@ "layer_toggles": "Commutateurs de couches", "layers_on": "{{active}} / {{total}} actifs", "loading": "Chargement des cartes...", + "map_layers": "Couches de carte", + "no_layers": "Aucune couche de carte disponible", "no_maps": "Aucune carte", "no_maps_description": "Aucune carte disponible pour votre département.", "no_results": "Aucun résultat trouvé", @@ -619,8 +793,23 @@ "search": "Rechercher des notes...", "title": "Notes" }, + "offline": { + "offline_message": "Hors ligne — affichage des dernières données synchronisées", + "offline_with_pending": "Hors ligne — {{count}} modification(s) seront synchronisées à la reconnexion", + "pending_count": "{{count}} modification(s) en attente de synchronisation", + "sync_now": "Synchroniser", + "syncing": "Synchronisation..." + }, "onboarding": { - "message": "Bienvenue sur l'application obytes" + "awarenessDescription": "Suivez les unités et le personnel avec des mises à jour de statut et de position en temps réel sur la carte de l'incident", + "awarenessTitle": "Connaissance situationnelle en direct", + "boardDescription": "Votre tableau de commandement numérique : gérez les incidents, les rôles ICS et les ressources depuis une tablette ou un ordinateur", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Affectez le personnel aux rôles et aux groupes, assurez le suivi et gardez votre état-major synchronisé à mesure que l'incident évolue", + "coordinateTitle": "Commandez et coordonnez", + "getStarted": "Commençons", + "next": "Suivant", + "skip": "Passer" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "À propos", "account": "Compte", "activating_unit": "Activation de l'unité...", - "active_unit": "Unité active", "app_info": "Infos de l'application", "app_name": "Nom de l'application", "arabic": "Arabe", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Confirmer la déconnexion", "more": "Plus", "no_units_available": "Aucune unité disponible", - "none_selected": "Aucune sélectionnée", "notifications": "Notifications push", "notifications_description": "Activez les notifications pour recevoir des alertes et des mises à jour", "notifications_enable": "Activer les notifications", @@ -873,7 +1060,6 @@ "server": "Serveur", "server_url": "URL du serveur", "server_url_note": "Remarque : Il s'agit de l'URL de l'API Resgrid. Elle est utilisée pour se connecter au serveur Resgrid. N'incluez pas /api/v4 dans l'URL ni de barre oblique finale.", - "set_active_unit": "Définir l'unité active", "share": "Partager", "spanish": "Espagnol", "status_page": "État du système", @@ -896,6 +1082,9 @@ "version": "Version", "website": "Site web" }, + "sidebar": { + "menu": "Menu" + }, "status": { "add_note": "Ajouter une note", "both_destinations_enabled": "Peut répondre aux appels ou aux stations", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Appels", + "command_board": "Tableau de commandement", "contacts": "Contacts", "incidents": "Incidents", "map": "Carte", diff --git a/src/translations/it.json b/src/translations/it.json index 1d01d91..3bb6012 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Buffering", @@ -184,10 +184,6 @@ "notes": "Note", "priority": "Priorità", "reference_id": "ID riferimento", - "set_active": "Imposta attivo", - "set_active_error": "Impossibile impostare la chiamata come attiva", - "set_active_success": "Chiamata impostata come attiva", - "setting_active": "Impostazione attiva...", "status": "Stato", "tabs": { "check_in": "Check-In", @@ -345,6 +341,184 @@ "type_sector_rotation": "Rotazione settore", "type_unit": "Unità" }, + "command": { + "accountability_section": "Controllo del personale", + "acknowledge": "Conferma", + "active_badge": "Comando attivo", + "add": "Aggiungi", + "add_assignment": "Assegna ruolo ICS", + "add_channel": "Aggiungi canale", + "add_entry": "Aggiungi voce", + "add_lane": "Aggiungi corsia", + "add_objective": "Aggiungi obiettivo", + "add_resource": "Aggiungi risorsa", + "add_timer": "Aggiungi timer", + "add_to_command": "Aggiungi al comando", + "added_to_command": "Aggiunto alla board di comando", + "agency_label": "Azienda / Ente (facoltativo)", + "agency_placeholder": "es. Acme Security, Croce Rossa", + "assign": "Assegna", + "assign_resource": "Assegna risorsa", + "assignment_label": "Incarico / Posizione", + "assignment_placeholder": "es. Attacco interno, Divisione A", + "channel_connected": "Connesso", + "channel_join": "Entra", + "channel_leave": "Esci", + "channel_name_placeholder": "es. Tattico 1", + "channel_preset_command": "Comando", + "channel_preset_tactical": "Tattico", + "close_channels": "Chiudi tutto", + "crew_placeholder": "Nome della persona o della squadra", + "current_commander": "CI", + "drag_move_hint": "Tieni premuta una risorsa per trascinarla, oppure toccala e poi tocca una corsia.", + "empty_accountability": "Nessuna voce al momento — aggiungi squadre per monitorarle", + "empty_channels": "Nessun canale tattico — aprine uno per questo incidente", + "empty_description": "Seleziona un incidente o evento attivo per costruire la tua plancia di comando. Corsie, ruoli, risorse e responsabilità appariranno qui.", + "empty_heading": "Plancia di comando", + "empty_objectives": "Nessun obiettivo tattico", + "empty_resources": "Nessuna risorsa al momento — aggiungi mezzi, squadre o attrezzature", + "empty_roles": "Nessun ruolo ICS assegnato", + "empty_structure": "Nessuna corsia — aggiungi Divisioni, Gruppi o Area di attesa per costruire la struttura di comando", + "empty_timeline": "Nessuna voce di registro", + "empty_timers": "Nessun timer attivo — aggiungi un promemoria PAR", + "end_command": "Termina comando", + "evaluate": "Rivaluta", + "go_to_calls": "Vai alle chiamate", + "in_this_lane": "In questa corsia", + "lane_color_label": "Colore corsia (opzionale — colora i suoi marker sulla mappa)", + "lane_limits_label": "Limiti corsia (opzionale)", + "lane_name_label": "Nome corsia", + "lane_name_placeholder": "es. Divisione A, Gruppo tetto, Area di attesa", + "lane_suggestion_customer_service": "Servizio clienti", + "lane_suggestion_gate": "Controllo accessi", + "lane_suggestion_medical": "Sanitario", + "lane_suggestion_operations": "Operazioni", + "lane_suggestion_parking": "Parcheggio", + "lane_suggestion_patrol": "Pattuglia", + "lane_suggestion_security_post": "Postazione sicurezza", + "lane_suggestion_stage": "Palco", + "lane_suggestion_staging": "Area di attesa", + "lane_suggestion_vendor": "Area espositori", + "lane_type_label": "Tipo corsia", + "lane_understaffed": "{{count}}/{{min}} unità — incompleta", + "lane_unit_capacity": "{{count}}/{{max}} unità", + "last_check_in": "Ultimo check-in", + "limit_max_riding": "Max a bordo", + "limit_max_time": "Ruota dopo (min)", + "limit_max_units": "Max unità", + "limit_min_riding": "Min a bordo", + "limit_min_time": "Tempo min (min)", + "limit_min_units": "Min unità", + "limit_none": "Nessuno", + "limit_riding_help": "Personale che deve essere a bordo di un’unità perché sia adatta a questa corsia. Verificato all’assegnazione; bloccato o segnalato secondo Forza requisiti.", + "limit_time_help": "Guida di rotazione in minuti. Uscire prima del minimo segnala lo spostamento; oltre il massimo la risorsa mostra il badge Ruotare. Nulla viene rimosso automaticamente.", + "limit_units_help": "Quante unità dovrebbe avere questa corsia. Sotto il minimo appare incompleta (mai bloccante); al massimo, aggiungere unità viene bloccato o segnalato.", + "map_command_section": "Board di comando", + "move": "Sposta", + "move_conflict_message": "{{resource}} è già assegnato a {{lane}}. Spostarlo in {{target}}?", + "move_conflict_title": "Risorsa già assegnata", + "move_resource_to_lane": "Sposta {{resource}} in {{lane}}", + "move_selected_hint": "{{resource}} selezionato — tocca una corsia per spostarlo.", + "no_accountability": "Nessun membro del personale ha ancora effettuato il check-in", + "no_check_in_yet": "Nessun check-in", + "no_personnel_found": "Nessun membro del personale trovato", + "no_resources_found": "Nessuna risorsa trovata", + "no_resources_in_lane": "Nessuna risorsa assegnata a questa corsia", + "node_branch": "Ramo", + "node_division": "Divisione", + "node_group": "Gruppo", + "node_sector": "Settore", + "node_staging": "Area di attesa", + "node_strike_team": "Squadra d'attacco", + "node_task_force": "Task force", + "node_unified_command": "Comando unificato", + "not_on_board": "Non sulla board", + "note_label": "Nota (facoltativa)", + "note_placeholder": "In attesa, in arrivo, incarico...", + "objective_benchmark": "Traguardo", + "objective_completed": "Fatto", + "objective_general": "Generale", + "objective_name_label": "Obiettivo", + "objective_name_placeholder": "es. Ricerca primaria completata, Utenze in sicurezza", + "objective_safety": "Sicurezza", + "objective_type_label": "Tipo", + "objectives_section": "Obiettivi tattici", + "open_board": "Apri plancia di comando", + "par_critical": "Critico", + "par_green": "OK", + "par_hint": "Tocca un badge PAR per alternare tra OK e In attesa", + "par_ok": "PAR OK", + "par_pending": "PAR in attesa", + "par_warning": "Avviso", + "person_label": "Nome", + "person_name_placeholder": "Nome della persona", + "person_placeholder": "Persona assegnata a questo ruolo", + "person_role_label": "Ruolo / Incarico", + "person_role_placeholder": "es. Soccorritore, Pattuglia, Addetto ai varchi", + "provisional_badge": "Offline — in attesa di sincronizzazione", + "release_from_command": "Rilascia dal comando", + "requirements_warning": "Requisiti", + "resource_kind_external": "Esterni", + "resource_kind_personnel": "Personale", + "resource_kind_units": "Unità", + "resource_name_label": "Risorsa", + "resource_name_placeholder": "es. APS 3, Autoscala 1, Squadra di supporto", + "resource_person": "Persona esterna", + "resource_unit": "Unità / Squadra", + "resources_section": "Risorse", + "role_finance": "Finanze/Amministrazione", + "role_ic": "Comandante dell'incidente", + "role_label": "Ruolo ICS", + "role_liaison": "Collegamento", + "role_logistics": "Logistica", + "role_open": "Vacante", + "role_operations": "Operazioni", + "role_pio": "Informazione pubblica", + "role_placeholder": "Scegli un ruolo o digitane uno personalizzato", + "role_planning": "Pianificazione", + "role_rehab": "Addetto alla riabilitazione", + "role_safety": "Responsabile sicurezza", + "role_staging": "Responsabile dell'area di attesa", + "role_transport": "Addetto ai trasporti", + "role_triage": "Addetto al triage", + "roles_section": "Assegnazioni ruoli ICS", + "rotation_due": "Ruotare", + "search_personnel": "Cerca personale...", + "search_resources": "Cerca risorse...", + "selected": "Selezionato", + "show_more": "Mostra altro", + "start_command": "Avvia comando", + "start_command_description": "Scegli un modello o parti da zero: i modelli possono rappresentare strutture ICS, servizi di sicurezza o piani del personale per fiere e concerti.", + "start_command_title": "Avvia plancia di comando", + "start_error": "Impossibile avviare il comando per questa chiamata", + "start_success": "Comando avviato per questa chiamata", + "start_timer": "Avvia", + "starting": "Avvio in corso...", + "structure_section": "Struttura di comando", + "template_blank": "Plancia vuota", + "template_blank_description": "Parti vuoto e crea corsie man mano", + "template_lane_count": "{{count}} corsia/e", + "timeline_section": "Registro incidente", + "timer_default_name": "Controllo PAR", + "timer_due_in": "Scade tra {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "es. Controllo PAR, Rotazione riabilitazione", + "timer_overdue": "Scaduto", + "timer_overdue_by": "Scaduto da {{time}}", + "timers_section": "Timer e PAR", + "transfer": "Trasferisci", + "transfer_command": "Trasferisci comando", + "transfer_command_hint": "Passa il comando dell’incidente a un altro utente. La board resta condivisa.", + "transfer_error": "Trasferimento del comando non riuscito", + "transfer_success": "Comando trasferito", + "transmission_log": "Registro trasmissioni", + "transmission_seconds": "{{count}}s", + "unassigned": "Non assegnato", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Ruoli {{filled}}/{{total}}", + "view_call": "Vedi chiamata", + "voice_section": "Canali vocali" + }, "common": { "add": "Aggiungi", "back": "Indietro", @@ -364,8 +538,6 @@ "loading_address": "Caricamento indirizzo...", "my_location": "La mia posizione", "next": "Avanti", - "noActiveUnit": "Nessuna unità attiva impostata", - "noActiveUnitDescription": "Imposta un'unità attiva dalla pagina delle impostazioni per accedere ai controlli di stato", "noDataAvailable": "Nessun dato disponibile", "no_address_found": "Nessun indirizzo trovato", "no_location": "Nessun dato di posizione disponibile", @@ -548,7 +720,7 @@ "sso_signing_in": "Accesso con SSO in corso...", "sso_subtitle": "Inserisci il nome utente per trovare la configurazione SSO", "sso_title": "Accedi con SSO", - "subtitle": "Inserisci le credenziali per accedere a Resgrid Unit", + "subtitle": "Inserisci le credenziali per accedere a Resgrid IC", "title": "Accedi", "username": "Nome utente", "username_not_found": "Nome utente non trovato", @@ -588,6 +760,8 @@ "layer_toggles": "Interruttori livelli", "layers_on": "{{active}} / {{total}} attivi", "loading": "Caricamento mappe...", + "map_layers": "Livelli mappa", + "no_layers": "Nessun livello mappa disponibile", "no_maps": "Nessuna mappa", "no_maps_description": "Nessuna mappa disponibile per il tuo dipartimento.", "no_results": "Nessun risultato trovato", @@ -619,8 +793,23 @@ "search": "Cerca note...", "title": "Note" }, + "offline": { + "offline_message": "Offline — visualizzazione degli ultimi dati sincronizzati", + "offline_with_pending": "Offline — {{count}} modifiche verranno sincronizzate alla riconnessione", + "pending_count": "{{count}} modifiche in attesa di sincronizzazione", + "sync_now": "Sincronizza ora", + "syncing": "Sincronizzazione..." + }, "onboarding": { - "message": "Benvenuto nell'app obytes" + "awarenessDescription": "Monitora unità e personale con aggiornamenti di stato e posizione in tempo reale sulla mappa dell'incidente", + "awarenessTitle": "Consapevolezza situazionale in tempo reale", + "boardDescription": "La tua plancia digitale di comando incidenti: gestisci incidenti, ruoli ICS e risorse da tablet o desktop", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Assegna il personale a ruoli e gruppi, mantieni la responsabilità e tieni sincronizzato il tuo staff di comando man mano che l'incidente evolve", + "coordinateTitle": "Comanda e coordina", + "getStarted": "Iniziamo", + "next": "Avanti", + "skip": "Salta" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "Informazioni", "account": "Account", "activating_unit": "Attivazione unità...", - "active_unit": "Unità attiva", "app_info": "Info app", "app_name": "Nome app", "arabic": "Arabo", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Conferma uscita", "more": "Altro", "no_units_available": "Nessuna unità disponibile", - "none_selected": "Nessuno selezionato", "notifications": "Notifiche push", "notifications_description": "Abilita le notifiche per ricevere avvisi e aggiornamenti", "notifications_enable": "Abilita notifiche", @@ -873,7 +1060,6 @@ "server": "Server", "server_url": "URL server", "server_url_note": "Nota: Questo è l'URL dell'API Resgrid. Viene utilizzato per connettersi al server Resgrid. Non includere /api/v4 nell'URL né uno slash finale.", - "set_active_unit": "Imposta unità attiva", "share": "Condividi", "spanish": "Spagnolo", "status_page": "Stato sistema", @@ -896,6 +1082,9 @@ "version": "Versione", "website": "Sito web" }, + "sidebar": { + "menu": "Menu" + }, "status": { "add_note": "Aggiungi nota", "both_destinations_enabled": "Può rispondere a chiamate o stazioni", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Chiamate", + "command_board": "Plancia di comando", "contacts": "Contatti", "incidents": "Incidents", "map": "Mappa", diff --git a/src/translations/pl.json b/src/translations/pl.json index 2143f61..38a9264 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Buforowanie", @@ -184,10 +184,6 @@ "notes": "Notatki", "priority": "Priorytet", "reference_id": "ID referencyjny", - "set_active": "Ustaw jako aktywne", - "set_active_error": "Nie udało się ustawić zgłoszenia jako aktywne", - "set_active_success": "Zgłoszenie ustawione jako aktywne", - "setting_active": "Ustawianie jako aktywne...", "status": "Status", "tabs": { "check_in": "Meldunek", @@ -345,6 +341,184 @@ "type_sector_rotation": "Rotacja sektorów", "type_unit": "Jednostka" }, + "command": { + "accountability_section": "Rozliczalność", + "acknowledge": "Potwierdź", + "active_badge": "Aktywne dowodzenie", + "add": "Dodaj", + "add_assignment": "Przydziel rolę ICS", + "add_channel": "Dodaj kanał", + "add_entry": "Dodaj wpis", + "add_lane": "Dodaj odcinek", + "add_objective": "Dodaj cel", + "add_resource": "Dodaj zasób", + "add_timer": "Dodaj licznik", + "add_to_command": "Dodaj do dowodzenia", + "added_to_command": "Dodano do tablicy dowodzenia", + "agency_label": "Firma / Organizacja (opcjonalnie)", + "agency_placeholder": "np. Acme Security, Czerwony Krzyż", + "assign": "Przydziel", + "assign_resource": "Przydziel zasób", + "assignment_label": "Przydział / Lokalizacja", + "assignment_placeholder": "np. Natarcie wewnętrzne, Odcinek A", + "channel_connected": "Połączono", + "channel_join": "Dołącz", + "channel_leave": "Opuść", + "channel_name_placeholder": "np. Taktyczny 1", + "channel_preset_command": "Dowodzenie", + "channel_preset_tactical": "Taktyczny", + "close_channels": "Zamknij wszystkie", + "crew_placeholder": "Nazwa osoby lub zastępu", + "current_commander": "KD", + "drag_move_hint": "Naciśnij i przytrzymaj zasób, aby go przeciągnąć, albo dotknij go, a następnie dotknij pasa.", + "empty_accountability": "Brak wpisów — dodaj zastępy, aby je śledzić", + "empty_channels": "Brak kanałów taktycznych — otwórz jeden dla tego zdarzenia", + "empty_description": "Wybierz aktywne zdarzenie lub wydarzenie, aby zbudować tablicę dowodzenia. Odcinki, role, zasoby i rozliczalność pojawią się tutaj.", + "empty_heading": "Tablica dowodzenia", + "empty_objectives": "Brak celów taktycznych", + "empty_resources": "Brak zasobów — dodaj pojazdy, zastępy lub sprzęt", + "empty_roles": "Brak przydzielonych ról ICS", + "empty_structure": "Brak odcinków — dodaj Odcinki, Grupy lub Strefę koncentracji, aby zbudować strukturę dowodzenia", + "empty_timeline": "Brak wpisów w dzienniku", + "empty_timers": "Brak liczników — dodaj przypomnienie PAR", + "end_command": "Zakończ dowodzenie", + "evaluate": "Oceń ponownie", + "go_to_calls": "Przejdź do zgłoszeń", + "in_this_lane": "W tym pasie", + "lane_color_label": "Kolor pasa (opcjonalnie — koloruje jego znaczniki na mapie)", + "lane_limits_label": "Limity pasa (opcjonalne)", + "lane_name_label": "Nazwa odcinka", + "lane_name_placeholder": "np. Odcinek A, Grupa dachowa, Strefa koncentracji", + "lane_suggestion_customer_service": "Obsługa uczestników", + "lane_suggestion_gate": "Kontrola wejść", + "lane_suggestion_medical": "Medyczny", + "lane_suggestion_operations": "Operacje", + "lane_suggestion_parking": "Parking", + "lane_suggestion_patrol": "Patrol", + "lane_suggestion_security_post": "Posterunek ochrony", + "lane_suggestion_stage": "Scena", + "lane_suggestion_staging": "Strefa koncentracji", + "lane_suggestion_vendor": "Strefa wystawców", + "lane_type_label": "Typ odcinka", + "lane_understaffed": "{{count}}/{{min}} jednostek — niepełny", + "lane_unit_capacity": "{{count}}/{{max}} jednostek", + "last_check_in": "Ostatnie zgłoszenie", + "limit_max_riding": "Maks. załoga", + "limit_max_time": "Zmiana po (min)", + "limit_max_units": "Maks. jednostek", + "limit_min_riding": "Min. załoga", + "limit_min_time": "Min. czas (min)", + "limit_min_units": "Min. jednostek", + "limit_none": "Brak", + "limit_riding_help": "Załoga wymagana na jednostce, aby pasowała do tego pasa. Sprawdzane przy przydziale; blokada lub ostrzeżenie zależnie od Wymuś wymagania.", + "limit_time_help": "Wytyczne rotacji w minutach. Wyjście przed minimum oznacza ruch; po maksimum zasób pokazuje odznakę Zmiana. Nic nie jest usuwane automatycznie.", + "limit_units_help": "Ile jednostek powinien mieć ten pas. Poniżej minimum pas jest niepełny (nigdy nie blokuje); przy maksimum dodanie jednostek jest blokowane lub ostrzegane.", + "map_command_section": "Tablica dowodzenia", + "move": "Przenieś", + "move_conflict_message": "{{resource}} jest już przypisany do {{lane}}. Przenieść do {{target}}?", + "move_conflict_title": "Zasób już przypisany", + "move_resource_to_lane": "Przenieś {{resource}} do {{lane}}", + "move_selected_hint": "Wybrano {{resource}} — dotknij pasa, aby przenieść zasób.", + "no_accountability": "Żaden personel nie zgłosił się jeszcze do tego zdarzenia", + "no_check_in_yet": "Brak zgłoszenia", + "no_personnel_found": "Nie znaleziono personelu", + "no_resources_found": "Nie znaleziono zasobów", + "no_resources_in_lane": "Brak zasobów przydzielonych do tego odcinka", + "node_branch": "Pododcinek", + "node_division": "Odcinek bojowy", + "node_group": "Grupa", + "node_sector": "Sektor", + "node_staging": "Strefa koncentracji", + "node_strike_team": "Zespół uderzeniowy", + "node_task_force": "Grupa zadaniowa", + "node_unified_command": "Dowodzenie wspólne", + "not_on_board": "Nie ma na tablicy", + "note_label": "Notatka (opcjonalnie)", + "note_placeholder": "Odwód, w drodze, przydział...", + "objective_benchmark": "Punkt kontrolny", + "objective_completed": "Gotowe", + "objective_general": "Ogólny", + "objective_name_label": "Cel", + "objective_name_placeholder": "np. Przeszukanie pierwotne zakończone, Media zabezpieczone", + "objective_safety": "Bezpieczeństwo", + "objective_type_label": "Typ", + "objectives_section": "Cele taktyczne", + "open_board": "Otwórz tablicę dowodzenia", + "par_critical": "Krytyczny", + "par_green": "OK", + "par_hint": "Dotknij odznaki PAR, aby przełączyć między OK a Oczekuje", + "par_ok": "PAR OK", + "par_pending": "PAR oczekuje", + "par_warning": "Ostrzeżenie", + "person_label": "Imię i nazwisko", + "person_name_placeholder": "Imię i nazwisko osoby", + "person_placeholder": "Osoba przydzielona do tej roli", + "person_role_label": "Rola / Przydział", + "person_role_placeholder": "np. Ratownik, Patrol, Obsługa bramy", + "provisional_badge": "Offline — oczekuje na synchronizację", + "release_from_command": "Zwolnij z dowodzenia", + "requirements_warning": "Wymagania", + "resource_kind_external": "Zewnętrzne", + "resource_kind_personnel": "Personel", + "resource_kind_units": "Jednostki", + "resource_name_label": "Zasób", + "resource_name_placeholder": "np. GBA 3, Drabina 1, Zastęp wsparcia", + "resource_person": "Osoba zewnętrzna", + "resource_unit": "Jednostka / Zastęp", + "resources_section": "Zasoby", + "role_finance": "Finanse/Administracja", + "role_ic": "Kierujący działaniem ratowniczym", + "role_label": "Rola ICS", + "role_liaison": "Łącznik", + "role_logistics": "Logistyka", + "role_open": "Wolne", + "role_operations": "Operacje", + "role_pio": "Informacja publiczna", + "role_placeholder": "Wybierz rolę lub wpisz własną", + "role_planning": "Planowanie", + "role_rehab": "Oficer ds. odpoczynku", + "role_safety": "Oficer bezpieczeństwa", + "role_staging": "Kierownik strefy koncentracji", + "role_transport": "Oficer transportu", + "role_triage": "Oficer segregacji", + "roles_section": "Przydziały ról ICS", + "rotation_due": "Zmiana", + "search_personnel": "Szukaj personelu...", + "search_resources": "Szukaj zasobów...", + "selected": "Wybrano", + "show_more": "Pokaż więcej", + "start_command": "Rozpocznij dowodzenie", + "start_command_description": "Wybierz szablon lub zacznij od pustej tablicy — szablony mogą odwzorowywać struktury ICS, ochronę obiektu lub obsadę wydarzeń takich jak festyn czy koncert.", + "start_command_title": "Uruchom tablicę dowodzenia", + "start_error": "Nie udało się rozpocząć dowodzenia dla tego zgłoszenia", + "start_success": "Rozpoczęto dowodzenie dla tego zgłoszenia", + "start_timer": "Start", + "starting": "Rozpoczynanie...", + "structure_section": "Struktura dowodzenia", + "template_blank": "Pusta tablica", + "template_blank_description": "Zacznij od zera i dodawaj odcinki na bieżąco", + "template_lane_count": "{{count}} odcinek/odcinki", + "timeline_section": "Dziennik zdarzenia", + "timer_default_name": "Kontrola PAR", + "timer_due_in": "Termin za {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "np. Kontrola PAR, rotacja rehab", + "timer_overdue": "Po terminie", + "timer_overdue_by": "Po terminie o {{time}}", + "timers_section": "Liczniki i PAR", + "transfer": "Przekaż", + "transfer_command": "Przekaż dowodzenie", + "transfer_command_hint": "Przekaż dowodzenie zdarzeniem innemu użytkownikowi. Tablica pozostaje wspólna.", + "transfer_error": "Nie udało się przekazać dowodzenia", + "transfer_success": "Dowodzenie przekazane", + "transmission_log": "Dziennik transmisji", + "transmission_seconds": "{{count}}s", + "unassigned": "Nieprzypisane", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Role {{filled}}/{{total}}", + "view_call": "Zobacz zgłoszenie", + "voice_section": "Kanały głosowe" + }, "common": { "add": "Dodaj", "back": "Wstecz", @@ -364,8 +538,6 @@ "loading_address": "Ładowanie adresu...", "my_location": "Moja lokalizacja", "next": "Dalej", - "noActiveUnit": "Brak aktywnej jednostki", - "noActiveUnitDescription": "Ustaw aktywną jednostkę na stronie ustawień, aby uzyskać dostęp do kontroli statusu", "noDataAvailable": "Brak dostępnych danych", "no_address_found": "Nie znaleziono adresu", "no_location": "Brak danych lokalizacji", @@ -548,7 +720,7 @@ "sso_signing_in": "Logowanie przez SSO...", "sso_subtitle": "Wpisz nazwę użytkownika, aby znaleźć konfigurację SSO", "sso_title": "Zaloguj się przez SSO", - "subtitle": "Wpisz dane logowania, aby uzyskać dostęp do Resgrid Unit", + "subtitle": "Wpisz dane logowania, aby uzyskać dostęp do Resgrid IC", "title": "Zaloguj się", "username": "Nazwa użytkownika", "username_not_found": "Nazwa użytkownika nie znaleziona", @@ -588,6 +760,8 @@ "layer_toggles": "Przełączniki warstw", "layers_on": "{{active}} / {{total}} aktywne", "loading": "Ładowanie map...", + "map_layers": "Warstwy mapy", + "no_layers": "Brak dostępnych warstw mapy", "no_maps": "Brak map", "no_maps_description": "Brak map dostępnych dla Twojego działu.", "no_results": "Nie znaleziono wyników", @@ -619,8 +793,23 @@ "search": "Szukaj notatek...", "title": "Notatki" }, + "offline": { + "offline_message": "Offline — wyświetlane są ostatnio zsynchronizowane dane", + "offline_with_pending": "Offline — {{count}} zmian(y) zsynchronizują się po połączeniu", + "pending_count": "{{count}} zmian(y) oczekują na synchronizację", + "sync_now": "Synchronizuj teraz", + "syncing": "Synchronizowanie..." + }, "onboarding": { - "message": "Witamy w aplikacji obytes" + "awarenessDescription": "Śledź jednostki i personel dzięki aktualizacjom statusu i lokalizacji w czasie rzeczywistym na mapie zdarzenia", + "awarenessTitle": "Bieżąca świadomość sytuacyjna", + "boardDescription": "Twoja cyfrowa tablica dowodzenia — zarządzaj zdarzeniami, rolami ICS i zasobami z tabletu lub komputera", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Przydzielaj personel do ról i grup, dbaj o rozliczalność i utrzymuj synchronizację sztabu dowodzenia w miarę rozwoju zdarzenia", + "coordinateTitle": "Dowodź i koordynuj", + "getStarted": "Zaczynajmy", + "next": "Dalej", + "skip": "Pomiń" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "O aplikacji", "account": "Konto", "activating_unit": "Aktywowanie jednostki...", - "active_unit": "Aktywna jednostka", "app_info": "Informacje o aplikacji", "app_name": "Nazwa aplikacji", "arabic": "Arabski", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Potwierdź wylogowanie", "more": "Więcej", "no_units_available": "Brak dostępnych jednostek", - "none_selected": "Nic nie wybrano", "notifications": "Powiadomienia push", "notifications_description": "Włącz powiadomienia, aby otrzymywać alerty i aktualizacje", "notifications_enable": "Włącz powiadomienia", @@ -873,7 +1060,6 @@ "server": "Serwer", "server_url": "URL serwera", "server_url_note": "Uwaga: To jest URL API Resgrid. Służy do połączenia z serwerem Resgrid. Nie dołączaj /api/v4 do URL ani końcowego ukośnika.", - "set_active_unit": "Ustaw aktywną jednostkę", "share": "Udostępnij", "spanish": "Hiszpański", "status_page": "Stan systemu", @@ -896,6 +1082,9 @@ "version": "Wersja", "website": "Strona internetowa" }, + "sidebar": { + "menu": "Menu" + }, "status": { "add_note": "Dodaj notatkę", "both_destinations_enabled": "Może reagować na zgłoszenia lub stacje", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Zgłoszenia", + "command_board": "Tablica dowodzenia", "contacts": "Kontakty", "incidents": "Incidents", "map": "Mapa", diff --git a/src/translations/sv.json b/src/translations/sv.json index 8005792..c0f08b6 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Buffrar", @@ -184,10 +184,6 @@ "notes": "Anteckningar", "priority": "Prioritet", "reference_id": "Referens-ID", - "set_active": "Ange aktiv", - "set_active_error": "Det gick inte att ange samtalet som aktivt", - "set_active_success": "Samtalet angavs som aktivt", - "setting_active": "Anger aktiv...", "status": "Status", "tabs": { "check_in": "Incheckning", @@ -345,6 +341,184 @@ "type_sector_rotation": "Sektorsrotation", "type_unit": "Enhet" }, + "command": { + "accountability_section": "Personalkontroll", + "acknowledge": "Kvittera", + "active_badge": "Aktiv ledning", + "add": "Lägg till", + "add_assignment": "Tilldela ICS-roll", + "add_channel": "Lägg till kanal", + "add_entry": "Lägg till post", + "add_lane": "Lägg till sektor", + "add_objective": "Lägg till mål", + "add_resource": "Lägg till resurs", + "add_timer": "Lägg till timer", + "add_to_command": "Lägg till i ledning", + "added_to_command": "Tillagd på ledningstavlan", + "agency_label": "Företag / Organisation (valfritt)", + "agency_placeholder": "t.ex. Acme Security, Röda Korset", + "assign": "Tilldela", + "assign_resource": "Tilldela resurs", + "assignment_label": "Uppgift / Plats", + "assignment_placeholder": "t.ex. Invändig släckning, Sektor A", + "channel_connected": "Ansluten", + "channel_join": "Gå med", + "channel_leave": "Lämna", + "channel_name_placeholder": "t.ex. Taktisk 1", + "channel_preset_command": "Ledning", + "channel_preset_tactical": "Taktisk", + "close_channels": "Stäng alla", + "crew_placeholder": "Namn på person eller styrka", + "current_commander": "IL", + "drag_move_hint": "Tryck och håll på en resurs för att dra den, eller tryck på den och sedan på en bana.", + "empty_accountability": "Inga poster ännu — lägg till styrkor för att följa dem", + "empty_channels": "Inga taktiska kanaler — öppna en för denna insats", + "empty_description": "Välj en aktiv insats eller ett evenemang för att bygga din ledningstavla. Sektorer, roller, resurser och personalkontroll visas här.", + "empty_heading": "Ledningstavla", + "empty_objectives": "Inga taktiska mål ännu", + "empty_resources": "Inga resurser ännu — lägg till fordon, styrkor eller utrustning", + "empty_roles": "Inga ICS-roller tilldelade ännu", + "empty_structure": "Inga sektorer ännu — lägg till sektorer, grupper eller uppställningsplats för att bygga ledningsstrukturen", + "empty_timeline": "Inga logginlägg ännu", + "empty_timers": "Inga timers — lägg till en PAR-påminnelse", + "end_command": "Avsluta ledning", + "evaluate": "Omvärdera", + "go_to_calls": "Gå till larm", + "in_this_lane": "I denna fil", + "lane_color_label": "Filfärg (valfritt — färgar filens markörer på kartan)", + "lane_limits_label": "Filgränser (valfritt)", + "lane_name_label": "Sektorns namn", + "lane_name_placeholder": "t.ex. Sektor A, Takgrupp, Uppställningsplats", + "lane_suggestion_customer_service": "Kundservice", + "lane_suggestion_gate": "Entrékontroll", + "lane_suggestion_medical": "Sjukvård", + "lane_suggestion_operations": "Operativ ledning", + "lane_suggestion_parking": "Parkering", + "lane_suggestion_patrol": "Patrull", + "lane_suggestion_security_post": "Säkerhetspost", + "lane_suggestion_stage": "Scen", + "lane_suggestion_staging": "Uppställningsplats", + "lane_suggestion_vendor": "Utställarområde", + "lane_type_label": "Sektortyp", + "lane_understaffed": "{{count}}/{{min}} enheter — underbemannad", + "lane_unit_capacity": "{{count}}/{{max}} enheter", + "last_check_in": "Senaste incheckning", + "limit_max_riding": "Max ombord", + "limit_max_time": "Avlös efter (min)", + "limit_max_units": "Max enheter", + "limit_min_riding": "Min ombord", + "limit_min_time": "Min tid (min)", + "limit_min_units": "Min enheter", + "limit_none": "Ingen", + "limit_riding_help": "Personal som måste vara ombord på en enhet för att den ska passa filen. Kontrolleras vid tilldelning; blockeras eller varnas enligt Tvinga krav.", + "limit_time_help": "Rotationsguide i minuter. Att lämna före minimum flaggar flytten; efter maximum visar resursen Avlösning-märket. Inget tas bort automatiskt.", + "limit_units_help": "Hur många enheter filen bör ha. Under minimum visas den som underbemannad (blockerar aldrig); vid maximum blockeras eller varnas fler enheter.", + "map_command_section": "Ledningstavla", + "move": "Flytta", + "move_conflict_message": "{{resource}} är redan tilldelad {{lane}}. Flytta till {{target}}?", + "move_conflict_title": "Resursen är redan tilldelad", + "move_resource_to_lane": "Flytta {{resource}} till {{lane}}", + "move_selected_hint": "{{resource}} har valts — tryck på en bana för att flytta resursen.", + "no_accountability": "Ingen personal har checkat in på denna insats ännu", + "no_check_in_yet": "Ingen incheckning ännu", + "no_personnel_found": "Ingen personal hittades", + "no_resources_found": "Inga resurser hittades", + "no_resources_in_lane": "Inga resurser tilldelade denna sektor", + "node_branch": "Gren", + "node_division": "Sektor", + "node_group": "Grupp", + "node_sector": "Delsektor", + "node_staging": "Uppställningsplats", + "node_strike_team": "Insatsstyrka", + "node_task_force": "Specialstyrka", + "node_unified_command": "Samordnad ledning", + "not_on_board": "Inte på tavlan", + "note_label": "Anteckning (valfritt)", + "note_placeholder": "Uppställning, på väg, uppgift...", + "objective_benchmark": "Kontrollpunkt", + "objective_completed": "Klart", + "objective_general": "Allmänt", + "objective_name_label": "Mål", + "objective_name_placeholder": "t.ex. Primärsökning klar, El och gas säkrade", + "objective_safety": "Säkerhet", + "objective_type_label": "Typ", + "objectives_section": "Taktiska mål", + "open_board": "Öppna ledningstavlan", + "par_critical": "Kritisk", + "par_green": "OK", + "par_hint": "Tryck på en PAR-bricka för att växla mellan OK och Väntar", + "par_ok": "PAR OK", + "par_pending": "PAR väntar", + "par_warning": "Varning", + "person_label": "Namn", + "person_name_placeholder": "Personens namn", + "person_placeholder": "Person som tilldelats rollen", + "person_role_label": "Roll / Uppgift", + "person_role_placeholder": "t.ex. Sjukvårdare, Patrull, Entrépersonal", + "provisional_badge": "Offline — väntar på synkronisering", + "release_from_command": "Släpp från ledning", + "requirements_warning": "Krav", + "resource_kind_external": "Externa", + "resource_kind_personnel": "Personal", + "resource_kind_units": "Enheter", + "resource_name_label": "Resurs", + "resource_name_placeholder": "t.ex. Släckbil 3, Stegbil 1, Förstärkningsstyrka", + "resource_person": "Extern person", + "resource_unit": "Enhet / Styrka", + "resources_section": "Resurser", + "role_finance": "Ekonomi/Administration", + "role_ic": "Insatschef", + "role_label": "ICS-roll", + "role_liaison": "Sambandsbefäl", + "role_logistics": "Logistik", + "role_open": "Ledig", + "role_operations": "Operativ ledning", + "role_pio": "Presskontakt", + "role_placeholder": "Välj en roll eller skriv en egen", + "role_planning": "Planering", + "role_rehab": "Rehab-befäl", + "role_safety": "Säkerhetsbefäl", + "role_staging": "Uppställningsplatsansvarig", + "role_transport": "Transportbefäl", + "role_triage": "Triagebefäl", + "roles_section": "ICS-rolltilldelningar", + "rotation_due": "Avlösning", + "search_personnel": "Sök personal...", + "search_resources": "Sök resurser...", + "selected": "Vald", + "show_more": "Visa fler", + "start_command": "Starta ledning", + "start_command_description": "Välj en mall eller börja tomt — mallar kan beskriva ICS-strukturer, bevakningsuppdrag eller bemanningsplaner för evenemang som mässor och konserter.", + "start_command_title": "Starta ledningstavla", + "start_error": "Det gick inte att starta ledning för detta larm", + "start_success": "Ledning startad för detta larm", + "start_timer": "Starta", + "starting": "Startar...", + "structure_section": "Ledningsstruktur", + "template_blank": "Tom tavla", + "template_blank_description": "Börja tomt och bygg sektorer efter hand", + "template_lane_count": "{{count}} sektor(er)", + "timeline_section": "Insatslogg", + "timer_default_name": "PAR-kontroll", + "timer_due_in": "Förfaller om {{time}}", + "timer_minutes": "{{count}} min", + "timer_name_placeholder": "t.ex. PAR-kontroll, rehab-rotation", + "timer_overdue": "Försenad", + "timer_overdue_by": "Försenad {{time}}", + "timers_section": "Timers och PAR", + "transfer": "Överför", + "transfer_command": "Överför befäl", + "transfer_command_hint": "Lämna över insatsbefälet till en annan användare. Tavlan förblir delad.", + "transfer_error": "Kunde inte överföra befälet", + "transfer_success": "Befälet överfört", + "transmission_log": "Sändningslogg", + "transmission_seconds": "{{count}}s", + "unassigned": "Ej tilldelad", + "unit_eta": "ETA {{eta}}", + "unit_roles_count": "Roller {{filled}}/{{total}}", + "view_call": "Visa larm", + "voice_section": "Röstkanaler" + }, "common": { "add": "Lägg till", "back": "Tillbaka", @@ -364,8 +538,6 @@ "loading_address": "Laddar adress...", "my_location": "Min plats", "next": "Nästa", - "noActiveUnit": "Ingen aktiv enhet angiven", - "noActiveUnitDescription": "Ange en aktiv enhet från inställningssidan för att komma åt statuskontroller", "noDataAvailable": "Ingen data tillgänglig", "no_address_found": "Ingen adress hittades", "no_location": "Ingen platsdata tillgänglig", @@ -548,7 +720,7 @@ "sso_signing_in": "Loggar in med SSO...", "sso_subtitle": "Ange ditt användarnamn för att söka upp din SSO-konfiguration", "sso_title": "Logga in med SSO", - "subtitle": "Ange dina uppgifter för att komma åt Resgrid Unit", + "subtitle": "Ange dina uppgifter för att komma åt Resgrid IC", "title": "Logga in", "username": "Användarnamn", "username_not_found": "Användarnamnet hittades inte", @@ -588,6 +760,8 @@ "layer_toggles": "Lagerväxling", "layers_on": "{{active}} / {{total}} aktiva", "loading": "Laddar kartor...", + "map_layers": "Kartlager", + "no_layers": "Inga kartlager tillgängliga", "no_maps": "Inga kartor", "no_maps_description": "Inga kartor är tillgängliga för din avdelning.", "no_results": "Inga resultat hittades", @@ -619,8 +793,23 @@ "search": "Sök anteckningar...", "title": "Anteckningar" }, + "offline": { + "offline_message": "Offline — visar senast synkroniserade data", + "offline_with_pending": "Offline — {{count}} ändring(ar) synkroniseras vid återanslutning", + "pending_count": "{{count}} ändring(ar) väntar på synkronisering", + "sync_now": "Synkronisera nu", + "syncing": "Synkroniserar..." + }, "onboarding": { - "message": "Välkommen till obytes app site" + "awarenessDescription": "Följ enheter och personal med status- och positionsuppdateringar i realtid på insatskartan", + "awarenessTitle": "Lägesbild i realtid", + "boardDescription": "Din digitala ledningstavla – hantera insatser, ICS-roller och resurser från surfplatta eller dator", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Tilldela personal roller och grupper, håll koll på ansvar och håll din ledningsstab synkroniserad när insatsen utvecklas", + "coordinateTitle": "Led och samordna", + "getStarted": "Nu kör vi", + "next": "Nästa", + "skip": "Hoppa över" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "Om", "account": "Konto", "activating_unit": "Aktiverar enhet...", - "active_unit": "Aktiv enhet", "app_info": "Appinfo", "app_name": "Appnamn", "arabic": "Arabiska", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Bekräfta utloggning", "more": "Mer", "no_units_available": "Inga enheter tillgängliga", - "none_selected": "Ingen vald", "notifications": "Push-aviseringar", "notifications_description": "Aktivera aviseringar för att ta emot varningar och uppdateringar", "notifications_enable": "Aktivera aviseringar", @@ -873,7 +1060,6 @@ "server": "Server", "server_url": "Server-URL", "server_url_note": "Obs: Detta är URL:en för Resgrid API. Den används för att ansluta till Resgrid-servern. Inkludera inte /api/v4 i URL:en eller ett avslutande snedstreck.", - "set_active_unit": "Ange aktiv enhet", "share": "Dela", "spanish": "Spanska", "status_page": "Systemstatus", @@ -896,6 +1082,9 @@ "version": "Version", "website": "Webbplats" }, + "sidebar": { + "menu": "Meny" + }, "status": { "add_note": "Lägg till anteckning", "both_destinations_enabled": "Kan svara på samtal eller stationer", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Samtal", + "command_board": "Ledningstavla", "contacts": "Kontakter", "incidents": "Incidents", "map": "Karta", diff --git a/src/translations/uk.json b/src/translations/uk.json index b081bb1..f6835ec 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -8,7 +8,7 @@ } }, "app": { - "title": "Resgrid Unit" + "title": "Resgrid IC" }, "audio_streams": { "buffering": "Буферизація", @@ -184,10 +184,6 @@ "notes": "Примітки", "priority": "Пріоритет", "reference_id": "Референс ID", - "set_active": "Встановити активним", - "set_active_error": "Не вдалося встановити виклик як активний", - "set_active_success": "Виклик встановлено як активний", - "setting_active": "Встановлення активним...", "status": "Статус", "tabs": { "check_in": "Реєстрація", @@ -345,6 +341,184 @@ "type_sector_rotation": "Ротація секторів", "type_unit": "Підрозділ" }, + "command": { + "accountability_section": "Облік персоналу", + "acknowledge": "Підтвердити", + "active_badge": "Активне управління", + "add": "Додати", + "add_assignment": "Призначити роль ICS", + "add_channel": "Додати канал", + "add_entry": "Додати запис", + "add_lane": "Додати ділянку", + "add_objective": "Додати ціль", + "add_resource": "Додати ресурс", + "add_timer": "Додати таймер", + "add_to_command": "Додати до командування", + "added_to_command": "Додано до дошки командування", + "agency_label": "Компанія / Організація (необовʼязково)", + "agency_placeholder": "напр. Acme Security, Червоний Хрест", + "assign": "Призначити", + "assign_resource": "Призначити ресурс", + "assignment_label": "Завдання / Розташування", + "assignment_placeholder": "напр. Внутрішня атака, Ділянка А", + "channel_connected": "Підключено", + "channel_join": "Приєднатися", + "channel_leave": "Вийти", + "channel_name_placeholder": "напр. Тактичний 1", + "channel_preset_command": "Командування", + "channel_preset_tactical": "Тактичний", + "close_channels": "Закрити всі", + "crew_placeholder": "Ім'я особи або назва ланки", + "current_commander": "КР", + "drag_move_hint": "Натисніть і утримуйте ресурс, щоб перетягнути його, або торкніться ресурсу, а потім смуги.", + "empty_accountability": "Ще немає записів — додайте ланки, щоб відстежувати їх", + "empty_channels": "Немає тактичних каналів — відкрийте один для цього інциденту", + "empty_description": "Виберіть активний інцидент або подію, щоб побудувати дошку управління. Ділянки, ролі, ресурси та облік зʼявляться тут.", + "empty_heading": "Дошка управління", + "empty_objectives": "Тактичних цілей ще немає", + "empty_resources": "Ще немає ресурсів — додайте техніку, ланки чи обладнання", + "empty_roles": "Ролі ICS ще не призначені", + "empty_structure": "Ділянок ще немає — додайте Ділянки, Групи чи Зону очікування, щоб побудувати структуру управління", + "empty_timeline": "Записів у журналі ще немає", + "empty_timers": "Таймерів немає — додайте нагадування PAR", + "end_command": "Завершити управління", + "evaluate": "Переоцінити", + "go_to_calls": "Перейти до викликів", + "in_this_lane": "У цій смузі", + "lane_color_label": "Колір смуги (необов’язково — фарбує її маркери на мапі)", + "lane_limits_label": "Ліміти смуги (необов’язково)", + "lane_name_label": "Назва ділянки", + "lane_name_placeholder": "напр. Ділянка А, Група на даху, Зона очікування", + "lane_suggestion_customer_service": "Обслуговування відвідувачів", + "lane_suggestion_gate": "Контроль входу", + "lane_suggestion_medical": "Медичний пункт", + "lane_suggestion_operations": "Оперативний відділ", + "lane_suggestion_parking": "Парковка", + "lane_suggestion_patrol": "Патруль", + "lane_suggestion_security_post": "Пост охорони", + "lane_suggestion_stage": "Сцена", + "lane_suggestion_staging": "Зона очікування", + "lane_suggestion_vendor": "Зона продавців", + "lane_type_label": "Тип ділянки", + "lane_understaffed": "{{count}}/{{min}} підрозділів — неукомплектовано", + "lane_unit_capacity": "{{count}}/{{max}} підрозділів", + "last_check_in": "Остання відмітка", + "limit_max_riding": "Макс. екіпаж", + "limit_max_time": "Ротація після (хв)", + "limit_max_units": "Макс. підрозділів", + "limit_min_riding": "Мін. екіпаж", + "limit_min_time": "Мін. час (хв)", + "limit_min_units": "Мін. підрозділів", + "limit_none": "Немає", + "limit_riding_help": "Скільки людей має бути на підрозділі, щоб він підходив цій смузі. Перевіряється при призначенні; блокує або попереджає залежно від Примусових вимог.", + "limit_time_help": "Орієнтири ротації у хвилинах. Вихід раніше мінімуму позначає переміщення; після максимуму ресурс показує значок Ротація. Нічого не видаляється автоматично.", + "limit_units_help": "Скільки підрозділів має бути в цій смузі. Нижче мінімуму смуга неукомплектована (ніколи не блокує); на максимумі додавання підрозділів блокується або попереджається.", + "map_command_section": "Дошка командування", + "move": "Перемістити", + "move_conflict_message": "{{resource}} уже призначено до {{lane}}. Перемістити до {{target}}?", + "move_conflict_title": "Ресурс уже призначено", + "move_resource_to_lane": "Перемістити {{resource}} до {{lane}}", + "move_selected_hint": "Вибрано {{resource}} — торкніться смуги, щоб перемістити ресурс.", + "no_accountability": "Персонал ще не відмітився на цьому інциденті", + "no_check_in_yet": "Ще немає відмітки", + "no_personnel_found": "Персонал не знайдено", + "no_resources_found": "Ресурсів не знайдено", + "no_resources_in_lane": "На цю ділянку не призначено ресурсів", + "node_branch": "Напрямок", + "node_division": "Ділянка", + "node_group": "Група", + "node_sector": "Сектор", + "node_staging": "Зона очікування", + "node_strike_team": "Ударна група", + "node_task_force": "Оперативна група", + "node_unified_command": "Обʼєднане управління", + "not_on_board": "Немає на дошці", + "note_label": "Нотатка (необовʼязково)", + "note_placeholder": "Резерв, у дорозі, завдання...", + "objective_benchmark": "Контрольна точка", + "objective_completed": "Виконано", + "objective_general": "Загальна", + "objective_name_label": "Ціль", + "objective_name_placeholder": "напр. Первинний пошук завершено, Комунікації знеструмлено", + "objective_safety": "Безпека", + "objective_type_label": "Тип", + "objectives_section": "Тактичні цілі", + "open_board": "Відкрити дошку управління", + "par_critical": "Критично", + "par_green": "OK", + "par_hint": "Торкніться значка PAR, щоб перемкнути між OK та Очікує", + "par_ok": "PAR OK", + "par_pending": "PAR очікує", + "par_warning": "Попередження", + "person_label": "Ім'я", + "person_name_placeholder": "Імʼя особи", + "person_placeholder": "Особа, призначена на цю роль", + "person_role_label": "Роль / Завдання", + "person_role_placeholder": "напр. Медик, Патруль, Персонал на вході", + "provisional_badge": "Офлайн — очікує синхронізації", + "release_from_command": "Звільнити з командування", + "requirements_warning": "Вимоги", + "resource_kind_external": "Зовнішні", + "resource_kind_personnel": "Персонал", + "resource_kind_units": "Підрозділи", + "resource_name_label": "Ресурс", + "resource_name_placeholder": "напр. Автоцистерна 3, Драбина 1, Ланка підтримки", + "resource_person": "Зовнішня особа", + "resource_unit": "Підрозділ / Ланка", + "resources_section": "Ресурси", + "role_finance": "Фінанси/Адміністрація", + "role_ic": "Керівник гасіння", + "role_label": "Роль ICS", + "role_liaison": "Звʼязок", + "role_logistics": "Логістика", + "role_open": "Вільно", + "role_operations": "Оперативний відділ", + "role_pio": "Звʼязки з громадськістю", + "role_placeholder": "Виберіть готову роль або введіть власну", + "role_planning": "Планування", + "role_rehab": "Офіцер з відновлення", + "role_safety": "Офіцер безпеки", + "role_staging": "Керівник зони очікування", + "role_transport": "Офіцер з транспортування", + "role_triage": "Офіцер із сортування", + "roles_section": "Призначення ролей ICS", + "rotation_due": "Ротація", + "search_personnel": "Пошук персоналу...", + "search_resources": "Пошук ресурсів...", + "selected": "Вибрано", + "show_more": "Показати більше", + "start_command": "Розпочати управління", + "start_command_description": "Виберіть шаблон або почніть з чистої дошки — шаблони можуть описувати структури ICS, охорону обʼєкта чи штат для ярмарку або концерту.", + "start_command_title": "Запустити дошку управління", + "start_error": "Не вдалося розпочати управління для цього виклику", + "start_success": "Управління розпочато для цього виклику", + "start_timer": "Запустити", + "starting": "Запуск...", + "structure_section": "Структура управління", + "template_blank": "Чиста дошка", + "template_blank_description": "Почніть з нуля та додавайте ділянки поступово", + "template_lane_count": "{{count}} ділянок(ки)", + "timeline_section": "Журнал інциденту", + "timer_default_name": "Перевірка PAR", + "timer_due_in": "Спливає через {{time}}", + "timer_minutes": "{{count}} хв", + "timer_name_placeholder": "напр. Перевірка PAR, ротація відпочинку", + "timer_overdue": "Прострочено", + "timer_overdue_by": "Прострочено на {{time}}", + "timers_section": "Таймери та PAR", + "transfer": "Передати", + "transfer_command": "Передати командування", + "transfer_command_hint": "Передайте командування інцидентом іншому користувачу. Дошка залишається спільною.", + "transfer_error": "Не вдалося передати командування", + "transfer_success": "Командування передано", + "transmission_log": "Журнал передач", + "transmission_seconds": "{{count}}с", + "unassigned": "Не призначено", + "unit_eta": "Прибуття {{eta}}", + "unit_roles_count": "Ролі {{filled}}/{{total}}", + "view_call": "Переглянути виклик", + "voice_section": "Голосові канали" + }, "common": { "add": "Додати", "back": "Назад", @@ -364,8 +538,6 @@ "loading_address": "Завантаження адреси...", "my_location": "Моє місцезнаходження", "next": "Далі", - "noActiveUnit": "Немає активного підрозділу", - "noActiveUnitDescription": "Встановіть активний підрозділ на сторінці налаштувань для доступу до керування статусом", "noDataAvailable": "Немає доступних даних", "no_address_found": "Адресу не знайдено", "no_location": "Немає даних про місцезнаходження", @@ -548,7 +720,7 @@ "sso_signing_in": "Вхід через SSO...", "sso_subtitle": "Введіть ім'я користувача для пошуку конфігурації SSO", "sso_title": "Увійти через SSO", - "subtitle": "Введіть облікові дані для доступу до Resgrid Unit", + "subtitle": "Введіть облікові дані для доступу до Resgrid IC", "title": "Увійти", "username": "Ім'я користувача", "username_not_found": "Ім'я користувача не знайдено", @@ -588,6 +760,8 @@ "layer_toggles": "Перемикачі шарів", "layers_on": "{{active}} / {{total}} активних", "loading": "Завантаження карт...", + "map_layers": "Шари карти", + "no_layers": "Немає доступних шарів карти", "no_maps": "Немає карт", "no_maps_description": "Для вашого підрозділу немає доступних карт.", "no_results": "Результатів не знайдено", @@ -619,8 +793,23 @@ "search": "Пошук приміток...", "title": "Примітки" }, + "offline": { + "offline_message": "Офлайн — показано останні синхронізовані дані", + "offline_with_pending": "Офлайн — {{count}} змін(и) синхронізуються після відновлення зв’язку", + "pending_count": "{{count}} змін(и) очікують на синхронізацію", + "sync_now": "Синхронізувати зараз", + "syncing": "Синхронізація..." + }, "onboarding": { - "message": "Ласкаво просимо до додатку obytes" + "awarenessDescription": "Відстежуйте підрозділи та персонал з оновленнями статусу й місцезнаходження в реальному часі на карті інциденту", + "awarenessTitle": "Ситуаційна обізнаність у реальному часі", + "boardDescription": "Ваша цифрова дошка управління інцидентами — керуйте інцидентами, ролями ICS та ресурсами з планшета чи комп'ютера", + "boardTitle": "Resgrid IC", + "coordinateDescription": "Призначайте персонал на ролі та в групи, підтримуйте облік і тримайте штаб управління синхронізованим у міру розвитку інциденту", + "coordinateTitle": "Керуйте та координуйте", + "getStarted": "Розпочнімо", + "next": "Далі", + "skip": "Пропустити" }, "protocols": { "details": { @@ -811,7 +1000,6 @@ "about": "Про додаток", "account": "Обліковий запис", "activating_unit": "Активація підрозділу...", - "active_unit": "Активний підрозділ", "app_info": "Інформація про додаток", "app_name": "Назва додатку", "arabic": "Арабська", @@ -859,7 +1047,6 @@ "logout_confirm_title": "Підтвердити вихід", "more": "Більше", "no_units_available": "Немає доступних підрозділів", - "none_selected": "Нічого не вибрано", "notifications": "Push-сповіщення", "notifications_description": "Увімкніть сповіщення для отримання попереджень та оновлень", "notifications_enable": "Увімкнути сповіщення", @@ -873,7 +1060,6 @@ "server": "Сервер", "server_url": "URL сервера", "server_url_note": "Примітка: Це URL API Resgrid. Використовується для підключення до сервера Resgrid. Не включайте /api/v4 у URL та не додавайте кінцевий слеш.", - "set_active_unit": "Встановити активний підрозділ", "share": "Поділитися", "spanish": "Іспанська", "status_page": "Стан системи", @@ -896,6 +1082,9 @@ "version": "Версія", "website": "Веб-сайт" }, + "sidebar": { + "menu": "Меню" + }, "status": { "add_note": "Додати примітку", "both_destinations_enabled": "Може реагувати на виклики або станції", @@ -930,6 +1119,7 @@ }, "tabs": { "calls": "Виклики", + "command_board": "Дошка управління", "contacts": "Контакти", "incidents": "Incidents", "map": "Карта",

S(= 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 T9KEJa00000NkvXXu0mjfjxCI; literal 19870 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 diff --git a/assets/icon.png b/assets/icon.png index f4f691014f473e2f9f03ef5baee54e6c141b86b6..ec2672a5958851c22b4151f4e3043c9872d6d821 100644 GIT binary patch literal 49157 zcmY(rdpuO%_dkA6R4Pg@iZtETi<)jykx?q$q`MF!Dz{LnD9oYDOOd2fNt#qrN$%u& z(1l2&gfW95BFwlu#+*54fA`V*^Y}e}fAxC2&Y6AIUYF;3uC>>8WA7e|DU-A(AqX;M z$M!Az5o8?v&vD2EMfgu2n*S3)u&+C|Y&z&CkdXfO;#~qoH>fARz4qCKuk0RZzFO3M zQg!0EtWDcPY?PfW9DUlyDLy&D&E||^T6pTLZQ+Q{j&r-VP0u~2nz_8DtEcPT8+(87jghxJ4<9Zt;Sr_} zcrGVDT}X7VduQMKd(gj0G&1s#k>9JcUomNjn?xBTCU4q%`zu<7>L?Sj4QP6~=e-ei-&40Erii0)fVYR2UZGXGG zQVWT{Vzs}Uh!^l#xOa!j%mr6~cU|vFe*`I_NAu!5;E&G=& zyx!elIrQ94tj~`2LMj^@5 zhr41{N+B3T1L zQlQ$)v?QDz`m6QBgw~gzqLvcFix~3 zVwSHa?GqpDq-qsZ!Xd@(nB01u`V*6h5fN272@#7>f_LG*9C|wr~A*<08zD2I?S$&RREFTB$5SLU?_|Df?b~y$7CqXYjz16vbS*thFTX1P%{Y!}S zm29Ker!1*ZC5J5=8>Sx;W19%>J}9_hJ@&eWHhG!vN&T?2jHfz{)FtbK zsbHHJwWXIl244N$EBa9t++~-~OwXvY7606)>Buv79)nc;|0z-s62V`_+h4Z*GVnH_ZYZBzvKwS?W)kb zSvaGi1Wm`8=1S&j23?zX)#W+oO>hQI8^_%wt+UVti!1h`wBMvcr}#I2mZ9YSdj1cu zWb~rBER^)Ofhx)%{`x&clNwYCR>dApOn=thNPQj(Sb>XnL)GXw+m5}|)thTLp0FyT zM#_WY_jtrLV)=26bMRgZh1>5*o%jx@<`ndEpcA+yX5*I?cIDP|v$eY>lu)}OkAXvC z(eck2w)`<2D`b#=#AwQ>Cm%aOhz=!RNIdVi$j-Kiici%;mI|?zFR#JiL2#>TRHKXg zf{eH@F3EGo!)aH^uqZubq7eI%@^@FDU6ZOA7`z=MMw1u(soI~Fb~|~R5}~DIh1|(R zHL1T|?na~<^`w{k#(avkP=FG1fy)U#${~!Fi5Up;>ktZ^H~C@ zR3)`>C#t`tfLYkV|41t$_@)d>FXp~PRiZ=Zv)kTGy-`h#D38-U+SH_v5Zoc3RbsS6 zZH<#{^zpx1<)h8Ch%Y95qDJsZN6Mxy)2hx{BWAG`Za*SjK4+}j^#AE2T@qA#3S40t zCcQoHT5ph%iZ0DkGjqbdZU4IkRcl5<*k@H;1K*j3pWhnn%!_|unRP1dEp)3(fnxT_OeFpU$bd?Nkl9>^1<4-x&n~g#O7U!?xcKB{fDNQ=qNe(Q4{bx0sJHaLf{F$1Zfd zzc*NIq!?CC-H=0G1XB``+UJc5gVUIyWW^B!zkr04KVs27h3057ou@U1&n!>Y{ciQ} za>_R>C~DI4V8v8)TIH`SxT6n6(ABn|Yj8PJ@qu!aK{ppM2+>8|^!)eU+0B1oq=qYm z^Qc);7;gr0l>}%{OK_ekk0`unBfh@!(!~Wk39ZCsX0(eTxc9{!>qfL!LYagCla6WH zp1e2@KK8dl0@6`s2#+vig%KW=CDey(&8yLeAie@ceM>p{4p-{9H`BSO2M zF2hBSN$=`!X-Z)SVs@5fp!zVuLd^w{)kEwLSxWIcCw-z4W49zNH8e}X*#!{9U_8LI z%j(MF-jkrVQ`FN)Ye-8_xadUhhJ?!68MHq0LLN_L*tw59P1?U%tqutmqEQerp%2~O zqD9r*fl#!D^k31Z*IR7zO5x*GFAxvW9qK43iz_P)yw9B;3N3p`^epuF>ea=;d^G3p zuZt*W8>zK1`zl%L4bq6&j$bZLdFq9&|L|9;M^t=y2_{`D|<9w}&@VNh*<<(Pk{ONtm!TJWeeP}6wxhYZvgJ{$VKXC*A!>Un*=zu)sVv-#y| zQJ(;V3i&s0RO77?FsCmtEE;(B%00Uyhpf1#hZNRh z4>f6X-XxAUtKq%XCD_jRVAh#>kd?7~*-rhz21-v8c~TD?|L8#_sRBw(UJU({hxK{u z@7-5$HS0*o%)3bj-*|7=x`Wogu3;v$ifuLhhe+tezFb2qytj4KA(le0U7}O_nKQc=&!C&Z4!Xf3e8+{_0Sk`n~&|$qz)ZrPS(e{H%Y5Y>#Mz@ z#VQ_Fibge!aKSQbGq(?3MhE1LyH(tvGijPJ;8l@gjVE*R-7rOcWxl}PaH#%!dGZBB!Kf@ zR^Lx5ypa8i5wr--FCYYraXKu5RR*#?(yS85-gx|mv@xTW?LSuLZ{7Gl<6O={N24Kj=e9p3fC z$<%88p^}RVD;lZjzi;5&hWA#VsJq!f605M`WuyB?N>>Zn&>gtyf4hdOe-2#i3jB?g zW@uk^_CDwGAMN66*7sDO^q2&>2-ax6CnRFo`I#A(-zb&4D=L3%Ch^|4$U`^a^pJpF zV;~oLTBaU@>DUq&GUe9HxDS~?^7`qrYOk78z-tl`J7{V{(UHXpJ;R-^hej`8!63NpcY zKcRZJ2f*lrG^?`JG)y;+kFLhC#t9i!uS=$+B5J)rWL1ld3SYP0cF#lx`#b z>G&<$j?gl+Squ>adQERj39HpK6=i5r7UF=5--m~n`M870V+v*vE)6caN@xmLB4%%S z5oOt=S=6L*XMF}bt=ef6>h6cmh+8_Ovk9G|o_R(mXpDl+vm3tF#a-;<3f6!zSh9@& zCl$t(>N0tv(V}w$I2Nny;A))r(XG9V`ZS@QO&cNRsFb-nCnT7)hBV5B#2_qYO&`VL zw11@j8XGeCFenLJ#=}48FlD!lyXmA`@inqzIWkGW`nyId8lwujo=q*LuDwM4p-maF z(T6?!y&V&AZZfjO9f;+SFVLIz7e=h5sS*T&=HkQW*(pm=`PW=>*XIEif?PEodm^eY z!TuqbheI5}FBcj*zRCB5{319!JN7ruI z6I>hQUK+ZX-n}R5d<1b2LTPf~7&s|YZK&R&ZI`vwM|_^rKlzuDZm!1kT21Y1=JByA+_}@yRBWdn9lahaHc#l z{*EGTi-ceG=WGYzD^1x^^=be6Mk%$xjo8PcjNh+O@Lj${`Jy?r=J|wq&oovde3c8!KHt*ZgiIm+K>>XyE+BcPvB*Q zAhuG^6PkZlgABgQM#|JMHDE28L2KFF^6Pu6#01_|_*%Q?i5dy_H%gn`+?_}rgovy+ zp!4lLZ$Q*#({D1MZnmp1>1)Zva@rbnc+&~sL79MaP%hMW?wuEOA52LP#d&`p^RaOo zdJfQ3JELIL$A^3JLdou*u|b0%mzJ2OetJUu3gjc1TvPKM$g+`btLXGUb7bkBCj&G? z&C?#~r1LAIywmw+XU0w;5Gy)TVL#|!8~jZ^$6K&V6T`{6^D?-8xz3kzOznS`yV#Jx zh~6q@MZro_IQ5`^4qw9FvS#)E7s^$yjTN*4(m(HoUH~cld0|BC*ZDL|gu?=(Qy7p1 zfWjoMmt!$mABQjA5(M2vW&`AY$$3ad%Qs4r}okTO-tx zBnijAS8YEzjYyD$oZ-HM5b^boNtH=&{OQ$d)hybttTb;7Saj}v!hb!x41PrqCNKBDw0rI1+D_WR|U ze6u8C(ZSsb2maWtqPv~yqwlR^tVZeOC3DVBmdABs0y~`m^SnjxU^fRE`NvUd@{_|K z;WyOI&=!WZ@AZ|{?ZJEG?`}$&v~v~SA|@N8Unfgz@#nDW5sSh}HP_4m)OwqorUT%r zfb;X47R`6HOm1W}1+#9{W7pkW2J_BmxT>m;RVlD;n3}Cy^gqziu5y^={@QPCI*h;m z$w6Np(ryGXhv^0L&*y8$=mgBh%a{1fuwp)`+-%(8n>mJX5s*eEbg~{@L+Lm4U;@6m zvEqe@ON7s&_0;D#1`+5QPZkY2_5y&vD@rn9Ev*77_!_&2iWIx&D+@SWrLu*GmX5j#8Rm`e{ z*6dOjvt5bQb1CBwn7`E*)YxHa%o?$w>{3B~va_wtEzPGplC9u3pmmoBI5Q#N z*(If++@Nnmx-%lN`QJmhH>Le!#!%bC7;c$dzG~%3LFaADz(6|mpY~_Whk|RtH{eDt zwXh57^*>~!m*@n~q8}IC$aGEmo39!ph;x7GB=&2Q(EXo=2{&oBZYGSlmLR7IL1pX~ zKDyHqv_3ri^3x>bf574iW1^J0asakSi>qB^E2_RMGy4ixhF!G0EJo(wR%k@Fs06c> zt%dZ_$g+Og?u3mB&5M;`(ZCFh4x6cfvpW# z0kDLJcU>YQy}e*b00qMaV&_1am8>Ba?<7RG6kKnueERphy~J2KW|0Cvo(Igccf%)t z(;IAKz51DbV-*z5*j8F+J5_L1!aj#icHF68Mz2cmLg%Z26O2Ir_tx9qLq7e({kL%9 zO!y2s;#l6a1aUo4asJ6GvI<#;kv{r7!~_Zf`3sGPLaRB$TGOW84fj&1JxM|MTLIGv z6!Qq&ODgzfT7b>6DzEK?A>!JIN zb8+UXHSHE#@r?>*u>3802kSpRhQU_1Gni%|?|_}m*{9j1s}MSi1vNrgU=Sm|Eo{Bn z30ar3Xye0ju$|=^;#1@~y@>WFxYahJ&BLXXhL?uGMedH6!dZP$%wbZ*)%}WJXy>#fy<|Y zW=%>bgC;5k)M`kvC#!iksUs4)ao>olzMpx_a* z-kXZLlNFsN!C(IGV`DX5BXz=CU45Au;@@yy-W>O-w)>sKw8-o^taWm3QyKu{kR`>s z7~b1n;q1LD?fw^mjsN!yu>agR)#xpKonWXU|o?RV?!3j*r;&Akh#=JO$%UU`?I)y5%@e~9uQ zcXwxH9b79*%Occb&K|s~_;m8FU6T0tI8Mm(um$Xt+}ty8;cKdmH8tV+!wAlM&qHAD ze-R}yRoMG(>g^eIx(@HVrNxZA-(~itYIBad_6H+DQc-Mt!(00^Lw|mKkA34980aw^ z6|Tk3m;b7-fTupHV44sU@1_oVu2z~cy`{>jyC9oATxk66csv79{CYCz=ivM1%(_gr zf1|>*i_}-a)txX3Bzmrd*2?JbO&1i3ma45Be4c%@AO1$BW5h1DP=MK|tu3=o*H3a@ zB`ZtDH{F8AV-}Dy>^u=QCg7EyQPT_C`2%;Y z@@3*`Er{rvRCNkCycnL%+;A%6c{lhkkWg?ZSANc!Rwy}dk-R=@Kw{xAr*X?`MfuOJL6=&nAW_U2ZZ-Dv;o1de^Y%LU4iC^P>H_** zzrH2S!@dsZhsxtit-kFVPlKkjD#*>#nz_tuZtyMHXnZysh7$Duxu)@oQ+2xnh!*SR zNBNTvkf}f_LCvsh|Y|q4!YJ7Ay&b0Zg ziKx%S&h&;eatOnKsR%N+FiWYCGDLEApD=+}o%3W5YtwS(90v zsAcuUVIx+!v0Ty?4fosB#{0hJ7WatYap=ja>U{21TiFZy_Z9pYtTp#JCTlWs2962J zjtzRa=qh;t3F?*m8hQ;8MbCLpOwup?y3xB)`cXAtfRAQcueudq^*&>(b2|HCkHx-$ zJ;~3xKFe@k3G3i%h2>9rdxVU+Inb>{TCk6@mOz61@`ZIgs@ZT4`tjOuk5Yum;3o@i zU~{C)Xi;@0n)qN71l3aN*nICof`0`NBehKL??b(PNyk9WJMURmNYMe>#tj&NZh>Rby7 zbY!o))URPxcv_@Ig_<(pSDEsC9)%3u3{q!4);!R2!LLPy)7<>Un+x+R;r5mK0)?L~ z5A=)*{CT454Jb7w{`omZk}s1+HL#p9UwCvR(8A%Oo8(%$9khbzx(uo=K`vICL%^_~ zm*r2g$PPW2ciH{@PS7)h@Lu{r!u6I=4p z>(z-^Uneuxp`ffZ40hNT4Rl*qLn|+o>>;MG{3Pb=z;~JFR?vyyLpD0?%R04UeNL-) zli%sM6aC{^gB=M?1fak%u<11m$Ceco^RbnD^a~8(gz~65hyF(%#s$M+EwC35zd(oZ z{zffDikO_C`})vVpYlD3(NDa9&Q?@vXnI7Gf<4`dpQ$UifB`Z8T zMSOS9?7Ql{#Li034a`91I#^P4w0VE0SW;uQV=g4PuR297MU2>5sI2NCwdpnss&suD z)6Vxt*%(41yhn>Vkcm!0)RWV&ti|B*hICo6Xtdg2xHG|$|%jbm6U`5sS39JZnO zXrTYgOMMR&XZw)L>gJeUa&(^(L=r;$dF}lZp#`@Dvhzz2GZPyMG^^P=S5mBNFo|y! zRBMFFYmNI7z8>J{^0D6`j7>t;_J(k??9j5S+BA7#@&3sW|8oZ#p!q=)ryxq)3%!&X zAlr4SXII;UGG?UcwXcQ79??{sx#LA}axNYkXlOS2OpE3{ipatj-??ZvuJ1WauI>xD z1_{|3(r%9QIiIzzO{M8vYcpD(E8W>Uu+_Nq74!hsctl$1?~Rq0cgqJ{u+1G1#P_#p z5o%7&5!?JYL4Kp^W(V7kU%mpHI`J!tDcz$F;HG~4p|IXCkyAT-`v`p7YTkhU7S1u# zQtyHbBaZqiq;iRe+^7IU+rKfJ^?^?j7>VPqKVI+G#c0!Zt zxJ7)OHTnq#*{dT;4VNT+Ao{s7Y#rE1>&n^gy;i#w5X*tw#0QBy3Jh_%3#Ge+YLV>Z zztrCRlcJ*oG*&_#{(P;z`Xt2ljv7^!8k&6i()sXqczi%>`Fq~;8+6Z&JH0|hGy8dw zk2|7FRSkK!Y}|_ktHe zK#rB7Rwkn*SFG}j^magP!M27S0kb|I5We)LZWo603{xPpzqNWlmXE6P~$QwB8Dg6Dd?OCU3 zl93<~?tKFF@e}y$eN#!H&HHc?S_Q0FfVula?Wv`nmi`I;w%Pv~*;|V|FEFFIUi0V& z9DRudo%Hj_M2a8+`%nXm84GCBZsB-b+}1l&%s4KcSM<;Plb=@#3Xa4<%O3EQF-CbX zs^^(1Nat68i2_vJ60BNAd){3ynU*qT<}j6g@pR9zeJ_<$413b$-$ZzuK!3<=wyuX%OG!q)3qJ$+aeX+?b$^&_z3Zmc%RnnDIkcKm_25nM2N}+u>di!Vn z7=pc-TwHXj8dds?+~I2k<mx|w5(OH_B!u3vt_7#!RM+e0$)54W zx`fJ(Gn#uT=95D(!#>F-`MNg_x7qK9DqgAhYQJg~4Mwx3Lj;c50(~iDp&N5Xch!@l z@a5M=Bu_SJ5;EbYhEMvGkmx@=sQh5=%$!vd$@{z`Ds}-;yu!co|N$dGKg9lVo zb@I_Rr5BypMTp(-Sk&*ri5`cel?7I(m%*xoa0ewCi0joWs=rqwyrg&i3QnNXi0sbD z;%2}o@wQxY?fPwnGM}734o50i>SVkdD-RPL4yU3m6OoUI72+zhnmeGMxs9XG$Atbm zIOYM~F`5QfsOgkLioSL43j*A?n0BI zdk`7b4wFd&jIQIJxe;HSfzceB{C2aB&kJ_VACNiUOJojy8jPM6#~fm!&z*0PEU|Kc$Rp5p&J zbE0-MeUtIC>Om;rGul^MikCaCA$eJ#j$wDP)G|bLruv`dO;WfTYv@B=z@L!hQCVjg zivLVhi!D6T-O-Rw_jxl&vn4o7`s`w~BNsA)yT*2D_VBsm=VeLc{=FBI`{U{b98uBbF2}A z(h{vBhH|FN$Hq6^bj$U&*tmg}f}TDO?s~wkQurC}4A`=ZA+e6E=d98H60J8lh+R3Q zhWglWs}WgRY7HtEFfFw;x3txDm7tY)M6`J`&a#E>z9Wl-b+;LR%!&+oS-;Gm(>|b) zty|E_FHe{gz$t$YqYG;JeVF_k$nE)5bL$qacnrLTmHF|<4oHJ@NgA!?(FE|oZes>o zA3bBpj>V}q_9`s#grQYRDG-c}X@!v4b3}* zyve3#M?6f=@;qk3o+K|+u6V@QHB2&?nVBUJ9npVI+2(gGIrPR6?2)it6%-vWE`9nE zL^P1v7D|+TAM}uQRc_?BD?D}=L~uH6rH&x4)ZW)KbE7{R^hb-*$fCoEZWV-8NEA6_ zX(6+BFjR7AWs*B^X5Mzm@jPq2?-9--if5{hs27smRX#!Igq#e)1NS0487i2M1;X$? z!Cu&0hZ%kq4^CFbypzIT{8P$k)-}?lau|h+lghkjE1_>qt|71BoY2Td)5rcZ zB5ILPvh)bsO z?xFkP(CvH-9>{e^B*{Oj%?=+{^C?aJJ(AyZ-NKXgPp>CE_Mm=)tZ73@Q)gyAf?bZ;VgJ1Mh{J+Fxht7|rmTl*8*2RuPQ1>%O+)C)CxcQR>XI-1EMx9-L7@e{|D)DwG zp`6``X)h54N8qrP6H+PNn5}Es(mQl3CfScn{bxRA06Q^`*%Av$)XYz`-DVRBbQiwB zRVMnqG24EYsErXVm~ie;>Z3RJApM_`EV0nLf99yD=ukD7e6|LOniC5d+ZD2-$^Ab? zB&aNziF3aVV5bs-t?@aEGce2vyk0}B7f8%_-6q-U;8SMGz2UVYR}n?{bJ5_B+< zz2odC+F@ZeKGfSFIoSY0j-uy#I=%=#Y1|JvqDe5-Dh)`un1KuL_&O)<^%mW=;u^icAMqS%Uq8 zuwMyl?_wVp56{eHcb2_%1i@n{HJ}7v9@X2u11Qnu~7Cq!6EQWlJNkyFcAOI z49M*HFx$i$vHPD0Z#e`Ojhg-NZCVo#wR{(9PIe{iC2+^FWaU3_QCJed4R-ZjBa_di zWxr=4RBLM@Xpelb{5-L~`zq^Fz_4C#z6@INyv+4imr|@wgn?qsdNy#j&t}R23k=nx{gvq##i-OsnjMiZ*FguwuVRJm z5VCOrWCFP&lFxDk=PfuLg?oy%i_*_<4|m}M72ft-vXPBKJ02Ntk!OZn}XqRs%OBize)2?5u+HQadyKHtM&ZZI%H)Q z`6_6H4vez3b5tblCb+|T3aYa!4>&*Zuac^MC6;jD{zywkNUk<Fiby}*GlyOu#+=gHT)f%#`!BNtg<^;lqA{N)P#pN;ebP80fWNgwJ)-Z(=%# zIipJnHHE_)>dQC3{7t#K39v10z(g$wTJdBpVmSql0)-WCTsZ3 zU7pR>Eps*mZkp|@P_qFNhvXdEli(;RdrH;U5-i5TWsp5@JE8St?7Ov8^7^VX{dT73 zWX~25THiySCsmeRJKP@SVw5@G)*sI#0Kpf?E;#WxXO}^ z2kguhPQMoNR!XrKq#9NFCH=u$OyV&$!#rI6#&2a`!mrX*M`Gv%oQ&_us{OYPaifXw z$a+aUjetN|lZqV}$~m8av&v?!TY_xZ!qMN#si8|Q)|wOGzNK}Xr*iFzYD619ZWNpt%xlH=I(m7{eJ=mD-pg-SN zbSWwC$Fpa0=E>ZLZo;yzQOR1_hcKLLZd#Dr@{`i7@$hXtEtN*8CO{4>_t4jE zqIHqVIygG;wQIB@M(@eGFgUGz+fNv=``b7zXmpTE^Z&W0hFSUfr+an0-gne`^uF); zBdOCJ?yD0vGz9cV`S7rFJtcxqA0%ZSwo+{7>CU=?(#O?UNDlC{HMWhtIMBK z@6wsImtLl)oEwfbFO9IzOiTOK!XEwFCAebscw_&^dKIo;%aFbYEVv+MrD~gz>jL2@ z<|HI&uLLOhHeTwDtrHRd>B!}umfPnJ#kzcZ%mQ6!J%|yLhN)f8-!)P$r=Q0E$P1u; zS9GvV{d4q^uKhOl%IztOTtLZll8rAllfI2|D$h$Z@_@^?Mv$q;8mT(a!Suf53D0Ge zaP}@$kz9N3%WJ@FY|kqQxaYo>Y!Nl+jkQ+ox5z)*i+1I7umd{dgGbaTku2wAe5-(B zqi>_5q`&WP?;X&vGWjm4D389R|d&+-3p>Yd>qGVf^MQ=iJMa=jkL}-UUm7u|&xR~`H04}rYLpgtgEw=)1NW}a`{Ok7>#RX}ZEFOp z$7j}QTJ0m;In>t&j9p_DGOPp+ZfN_88+mjFULK)K6(1UB)r(4i9!KWM!DS z-miyr2J&qPxRzjXWMrTY4A@0%9L)&nmGsy}1X7Irjwa(|t5XlycEbDP3r3Z??B?~YLO=qNJ=3I{QRki3+d7; zIJeSjkn#0X&4WVOFxJq}FeE9^we6A*f&mGGQ`&u9F3a3CH>D!^C!SG$y{tCghpenv zlz99TC6Nci^KO$&U?m?CntUZ3a?f=Zd&{Fb9dKZdR`g%-)n&QX(WrHi=V?KLMy(ek z!X^Kg81jy%%eVYNplXInM6cMO0CM$Wg6-yqULL1avdv-bp+et>6A*-iQO zN>>g0EX*4h%>Gf3>iV1UGz-7Y2rRAk2$~?|96UP`TYJu)CXxx(fBeM2E}?L+pqlsJ zu*#9n;~3>^wEHmL5GiUo`>el{yS4LOfy`RO$*=3actlAq5OLCn4YRI`v9C2sDX5h) zb@N)U9(b^WPH{cYwh)Ekib&D(_&nox#$5>{sQFS_P)U4ihXt>Od(eKGlur$jPCR8_ zc={KUfu|@-bF#4>CGUbyo^3{sgvxa|Pg(qqZ+OvF!`Nn)o9tq zhX0=&RgtOxC@lM7Q{r~qQZA$?0c7fLSiIc)Ojul)L?n8%s4K~srHQIiK!0S%9+NY* zY{IBAlZ4dOx*FUxDmd?!V%2x{pek?}3nDGgJPdEj3-F+F&UNlTy&8Q{Rvkn~X10zj zv~_nMbmfiIBWH(#M7F=ku4%-IdUXe$9tV*jr+~j<(9W!s-NJ}?EP0RrG}|CVI}5a0 zs1<^Db|2FVIAGxW0lB^N?`W4NaPd3V-ko^&t*?(J+=JICh>+tQ+e@9m74#e3JYv5X z@4i1zV?1K^VSzD4<%!ya$wQSE3{}@`=IvkpS1-;~mi~zlbvD9zFvdrs@p5Y#`X!V8 zA*Tfb ztr!b{o!X8pn7Af%Kl(?$9BaLI%3nRClEn)c$(#LR>FG1Wi$vPNrhT|`-cRepTugi$yK0#DA2zF|{mt#k=*5BLf=euP z@LRXV$he1D4gB40);jKRvV30qOE4=LJ3m@8Y8aGshWm*da@B*T2$lk%Tc^8`GZe(a z`0ec{1AJJ=l*7`7A&mMD-)~|O^UUrlp|kDQ!F&N;+&!~rN>xJ%wujQw@jTQ+v%aS* z2`z9RV8i^o^56rluX|-t;~*7q9!u7kSjR!`{yOmThf~D9RMWl?s5eNnBK^GzY7>8> z={cW8>kKtlZzK!Se;v@ejnbo<69iPaCE=?v8XHp-y%%yHwmYrwr@>{(H@}&({xtyR z)h49a$Dx3mSISUa2g#J)Kp1IW)oNOa8ws_g2gG~>FR;p4<3k**^OH}~+niz`SFHeZ zIUC2lKizVf)!T8a)nY-Y{Qlkz?LnKzDWL{JP)NT#R;pL>!CVC~k2(6&hd20OPqf7o zzgn)54;iH*_mpLyXkVmBO(|W9Gxk=a!DqPR*ESxg&t2ocT%FE!&8cKqSzJ!*RFAe# z4;+3)#I8~;s3>sY-8D*Y)D9s#-1jSrSy`|O)OvCU79x5Oibrtf=LVl{50lZ2O#9r7 zhF`qc4Whx5bN;ma9xujHbQEyLzBXUVwD`H9Sx~EK1r`KHQsHGX=0LYZmo(-*Zn2tb z70cNaa8dE}$2gKVZE7jb!=Q(a%gZTLXJNqp!+ z4^H_sAwShehw(ZN74I`Nt0+iSt1boY6>#>VPbDr*Xj87--EN`Xk~R;0z%LU(eS0gv@#}Q74L2 z(H;H$a~%ERTBn;2o8U!V!ys$es;I{%EHf(_zvdVrYG`Pi8+6P>d)$l!QWg3*JbD-9 zUA8x6&bM)i&f@0uUe$wjqMkdLpO%_hdKk-&ML}~=Wzw8Tk?h?Z4bi{2+LJeE@#EDh}Rg(>iE9W0|@i$Q4uT^6Q*hhlWwl;*wU2YAP4(YVQc*!ah)+|C>?S`ijI zd8{88QAGKA1hwo=fiV|SSo!OTXuYWetch@LPYHHhf+4*j&5Z28%g^zTMOl%#OZMD2D8EfMUy!}g|u z++2(&uf6qcu~>Bdnp@6>Co0>8bOA*Rk^<&itHE}3+pTQQ^HGR(?@I4a*jG*z$ZGCQrBpkz^j&g)Dl~}u zOP!!w(mzG!xmL;3lb?|~hkNGT7edBcL#Y5~W3|2Sl>1y-u*+j|if)oR?h}zS`EY(q z31>Vag`P65D_CWsli@l=-A|}S>pr|5UgCsvNI@U$q6F$+UP-XyQ`95LFnC3sR=pH^ ze1kj?YPKX&q%OuzPf>qBpZd`!ODYJ#9N3T<^S(BNIjaiS@Tvw)zLPQGYUv)Wu&IjXkRLaaEto zuLrO$fJ3*eF$r+)E7Iy6-Sp-T9s1w)m%=-z@JDb$T={y21mbT{76YE(?1D|yxV6Jv zDLb?&|4}Bm94R_#7<6B_z+mCuhM9sZv2=j(_(`MEuu&*!E>#6B^`LqDPRTUQhqTJz z@B|;T!Xso#o~&yE&RYR?uYQ4n3H{@zos2Ql5>~05=x5_E_DbXEL+qkO{}3ugR0pY2 z=VC-hniVb71ZNtPV3h;C30Es_dUKSI3L-^0%oPR76VB7X|Ney&KsME5f|wcN2+~_P zjsC4K&VtXI{?CgPIdiID@7GpTGmcQ{6>>s#-b7zmrF;V1bq|_JmuxBMPbgG!BRq2q zeq4HXw3Iuh)1&D7yV~lSqU-Fix#RJPxCgyuTH3j{9=@os=aApe zPl8%2SRYtkhnVuQ|2f-2FIWr0g>Di*3^Y9urWWN3ZYUyq;iI53Ca!klHN11ofc|`BN^|!A z!BZ9NSu3f6{flA3wtA}|*!4s>c7y@%RyzS@+C5yz<>lkYZPt-K{}j<5nZ;N}ZRT(* z3M#Z@jpuoP^@pxj1#bso2l%3BVwc()Qe~iFw+nClz^DItL*rR$pj|1#)c4ZxFUXwz zc}aC=+b%mh-(teAD&frH*s{CsNCfQ`uEgs)O2bZyfjB)DuEK5{#palDhKjsVX{_&2 z-+Rwz(p#OL2K-_um;AdBo2K18uj#QLz)5#yWIA(E3J|~p=eD6PfeLH*(_NRt>+zyZ z&rNW(FJp5d>Uw9rN%>ckpqgvwGR^N#=_%6tW!?igtGVRT18rosf!w3%C0Ie9)9+@v z&KjAue^zqA@F3aqpPnac268)BmY0ndQwgNCJ93(jAFybvd(V(6Il{KJs@!G4=W<9+ zQ7H=^b!n$Tpm2mNH@Sv-tx)7yjjH49_Thp{ehTyrgLHnWagMSpJ&%GA-sO~`4Z(P) zFel*G_^G=l=nZ(x=H~w=DJ~KqH%|&Vv!}=>R#SR)NH|T{9l`Pi7$4+oH=BbBB{(5u zprR-B0-qg0)eD)%=wVT2_dt)m;-q4K09)oKfM&Q;n^`XFJ=QFzx9(%0F#tV>0aBuFo1!|U&Z zAFqXj&bImxUed1pcrPUWk!(141q!qsIdh7F=>Upk>!^bD)uDW_kN`#YaOUbRMtrR3 zE-=;Gx|PcAedpjc;C$0yKz|Kse&Ev1w(P7kT>nVXZ0y@(?IJjW0x3@k>h8%(u&#sJ zR+CI^x(MvNMl(kWf5TezIFA1My(=+hB=nB-aGa6sdXqA<2k_m~>@$r(cC+J)V)}Cxt^T zC}~iII%kwU_D4jLl~>%! z)Us#F{}LN-pRUmwHBMKsi6J~6X3kGw9zYhZn7s1g8LsBrguI;L0oCO8vo1+PJ|m{y zN%D(u=!Y!nwQP!tkc;JSE5<=0}R$aMn?!uT7Cg zb0Mb`#guuEYPR0#xd~xy{Q}Ik&ruhw`MF;(gi5x*oxjX`ZOwvoVZ7k|r9TUg7job=A$YT3oo2@tc#gncfUSmqm_h4RUdyllz1^z> z7d;7{yd^#2eNTOT%S?3yFaL|6$HFk!EzugS9Q7e|v`z$Yo68vnkYXJbqJfd3X+1f^ z0`pzEbiNuAvpa<7Fx^Fm-!jeujoOX_K0%XuufhU!xQM%Ww9N{eLe~%*iR5^RMK?q0 zd`f_I2h&4JIwLj}MciLFD^vN^&>ooXrdy*LzCvDs_xlzN*6>;}V}Aw+u7Z`%Wckw6 zKDK9r%c#*^9;M6N0}m8e4J5uiH$FdEx#|sf{CFg#k8!aYsPyE%^W!}&M6;mh00^ThNdKi{9YU>ALvX!gc)ixd6LPIs2IkB^w(##Wp5r%Es zlI&0LfGi|Cg5UI>_fvwhT|%ChF_q9aMx;k@FiTU-R*bdVa}qgb|6UtWd3g30O%EA6 z+Oqq#ixR)ly_?)yA;{WwxmA~1BjfwTqL%kod3x`C;7tOn&5`V7emAW+)1994bctm8 zgsSrX8-;CFvuRY92rz72l7fkt0MRkGmS?{Ic*yB>6j_(;kU6`m^iO`L=%?;zW#&Hz z-_m3HUm*kN{Xy-jK{WQv?zwgQLYvVn!q-+w8ZFyc_+zve&S=^t+qP6OOW{AGy$+~Y zx>IlFu&)QW!04&b8BJQ(b08)SO?3lXI0rn48+DZL0OB#~9`r?Ts!p(iDsYnw8m<_Q zAd9UJJRD7LDCe}le9rQz{@#0(-#q*Fb@LhD7GO}j&m%=IB#@)2Yf<(>(3}K^AGR#? zU}|Qf@$^lgVtdZAS|o0_e4)nd;n8pyfxXY*X;CRva1f9djbF9RT#l;1>WqlUmz3I1 zDawbD>rwq64+H$(z#)s#D|#h2FjFE9@L+=Bqfhi~A+d4*tSTB zVVE!UWqMcwr<;2Mr-@mo2<}0puh}qNZr=^*l{BU&%00Yg4{ZFePH_(! z|D}#9oiUfL%F!u)u*IO}Cd96!8}+`o+CB%)0B+6(EjF)$g`mf@I5#tLwcK0*`9k07 zUkeYvVIVBN2dCX0QHfs9Ob)%y>}u79bfK@ZVA!ycrE^X|JRLv5Rp$MXGW_T;7rqX; z4m}YhX3B*rpcqsOk9gd8i2o92Z>Be-(dE7S^hJ7E_}>3x>doU~T>t;^sZ>Wv_AF({ z(jkN-q{gx3oFqgYMH3-Pjf9Zq&XQy)aqKeILQ-j$nnVjpXkVC8NtqTcQ`5}c_xE{? z_vi8Z{o!%Wqp5qY`?_A&Ykj_+FV#58P#BMLXs8UzV6G=h6{F$x(Vi?)Q6IX5(ikiaxdVOM|KIabln>3>WLLT@ga)cAb)l+H_&bablbA+WM< z_n3uW)3#;iw~AaE95=|ZJ1%!Zid4uM=Fvu1RiL$IT8{=C?T^qo`a_8CsxGDu>fpeZ z^WEEL-?&Et)(XzaeUW&aeKBCgoDFKDi(MwDv4%bpqAcg}(8oS!?@x`6LRv@pYTVRj z?o9QQEkN3jt-a^%1qa=W1FX)fKXg8h1+xa(E<<7QKd0(a7WLlDbD2nuoV#9aEoh&% zPJ^s*8tf<)1*x^NMUAcUZg^8NsXka3RUhoCUPW}s}dMhOI7qeR%pU) z0qaQ|1>x~7<}{MCFiPlXulc|5>$l*DIi1Py=T`A|A`21tV3JU$O6bb0?XD#N@&g@@ zN$7%X0=QE&fb7x)()2exo9qvQNlgTkuWQsmTl1sXs#<7gi_qjki^E^2SM(kXJlQG; z!tKlWw@)D5WKGg^7e1`Kd%2a{8luvB5JD*n9`@K&EP5Tpu!y;ZXK&tFaNgi|BnqyB z7yad~%3!B_w6rmIu&X~mvUGUSJbIi@IAD7DJWGlTi0?JcN1wWNk&fRYa}=|WuV+5C z3i4xb3VFHHG%6^CL{Jrh^8UubeT$hlgD)29d38q zKrUd@;nke}XXfKwTx$Oan$U{lsTFgsadXlXQHwjQ0k_;OzIj?9+s3fvC3=2nZ?Njp zi?Zr_toecawP8Wf@>apuC4WR6edJTeZ8FExlbJV&q_dd^p5Z>8OztWz3AHuDqO(2f zHus@MpjJ57jkwBc62Zy_N3X<{yB90nRbc{pE&6reR&mT&C(Y6RtqC&`$*e_%9D4>*N0Sd8dYa^1n0+=ZZKDR5tY=joa2vL1!Gxzs)2(+ zK&d>Vi~YfFj9~?yof=1l<$>I3?ajUD_Cn||*|fu2Tmj#)3d|WAeK|$)F?nvrZmA#T{WGcdvC?^=m4#-M_O)UjKG zV>BphT$$`OSr?uW1s_aqcvs{plD8|CKwXA#pTsdg#7vv>vje?Txs_r?rM8|OH*)#3 z++U3!U~U7LyEzwPCslCv+7Ohs7OQssieh1ygc9F&-ZGLU@RyXD2r}}ej>nB=?KvtH z=fEa*nwlHzs1qI&>B@p9Xe`@`bi?l~4|PdG$vneoNw6qH;a^bhk~!IsU&4A!Fcqgi z(0RNIU#qBi|HFsK!G&yj0yOE+!yO@d;}3PGU2=Cp9m<%ZTT|GaBX_3B7`WR0hYtT$ zvmY&u$sU2}u}-^R=;eb0?0E!Rn*kTfK;(I*u_h$iT%O;xW^)xPKD*i$(FFoUOH(HO zKzy!N{+@fQ|BYQnE9&UOE+M7~xmvbB-RoCJTF**t5+oTO@cJHlNL(&!gNd>V-+({uUiji-a2N&?F|LF~j z%G~0p+QnJ4TBv;fFCze_(NBK#IrZxktttO5Wco?e=u^R2?qjg7fOOL-eb;62%#VZ8 zktp^+iFZ{nfv?Jsc1DR#DV9wrLF?>}81L*A$opu}L!`v4i+D2KN6m0{?V{mvX3<`^ z^WSB2-fReNARIdxGdlicO_+s!>FxT(kssm9*r?*+*CKA^S8yh8W7ZG|FL8OcEa$t$ zGsV%K0_j=%DVaWN)#UPO9#Da<_+ypR@~J3-SNS8Rui_i-0oty{xX~}WzouL;qyGok zgosMQ{oTS>dw1Z`A0!hPR!Rq;{R>27oeQ7MTH`$n1_({W9qg;b$=}t=^c(ALW0n+C zgCuruNxFu2pntXu*OCMHdQB3ka~_j=ZVnztFCZU77iTZMw4Jk3r(r zqT-K=@cj1GR!}=%=_<5KoP0_b>dJSoho{l;&9zu6iLIdi013;0L3d5$7XFWSA%ij4 z#6(V8x5WU=_J+Rde5&hmBvaeUp73}JzjVF&(}#XiTasS6Gf?IzsNzR1ZVY>kAXi1c zN$Ju4rz3y8e>@C|ilKhr{f%Ic*(npC2muR*L~!*P`4KAmS(HY{ldh(=Xx+VKRo~8A zUm*xnH3=`Hne*L8&|>f3b&cdpC(M@V847Qgu`+uU+_aBJ^5Re2N-sV9bEC0{+BN6j z0HCK%%QY$__qRp8+Iqa~&zVa=9EEGWL#NigrB`9T`7fQdUuX*M;IQpG=kyZ$o0{gv z44kJ3+pJp&OU6tVQ>hw#wNc|2Ae7NErM^GVxn@^P#JV!L663OW0ge zX9x>TbZr&ZWGo&j6KO)F-KXDH3(t zUN-NF#^ev@ z21W{klP>>`Ag1Hq-^6YEe1GAYGW@XbMMrFxpEQPk!-cA;qK$4Z9wZ;g=>(8`g=2Xf z)5p+Kpeop(y=0Z?YsU84f%)Q5-KXnokh3sjp@w)=atQBbCa(jSkmY2TeQQ z_`x0u^+KZJYRTZU2eam@tOfgsgG-;Ijmg+c3iF}3l7ag*>9}cjm&qE%q71@Mg&1>D zn{Q{9V3in&-TSOs)xWCk-q%~%l(76n`#>!vrd}vKGqf4|auXc*6*R-F)S&yeGxx*; z-m9z1-0ylNY8)z8=vp3Tme~!W8;NJw5mpnbIFWL7w%eI4!^wWdm;>T@fHq7Rzv9Xq zb1l}aA-GhWcZkLsq3*JqSTt973za{1AS984wnZvRn*oTlp1VdROWkk{K_O09C-W=q z272@37T*f0#g>_*{P13hN+>+0=@PCG8Py?9=pjSntvze+v0dBhu))wD>!=Bxp6k#H zxlO1Iaw>MDqS~XsX{GiOB2_owvqf%)LsCIalEg0Ot7KV|ZzGP@6R^QTLIM@mU?q`b zb0-o*OH>8-Y=4n_@FW0JWxDTZb(!X!X3=eBLf4IfJPl$Drm=>-T;$5=v+>*rTPV;& zNYEAIyUIwlcZR$?xyL|aXP~;aC3kEB!E<>Kfi1z+j$Xxx1=oAX#_%UV_|&{>5b-RakVPMP4TbLefHEYu zf4HN<9Su(YB0V2Q;1J(PX0VUjpuW@asEq{Mc86Fp%#NBV+dGmZ|FndHHn?B?)g;%1vIVk#La_X}shiz?d6 zl=e##C(6uMp1TW4i)Y5(q|g7VvzPX<`Ibdl>}l6DQ_%$3+0rf%2KbHaiOoL}%cR@A ztl8W9U#Q5t;9wys^9+}spVsMY_$;mK`!v>#%;>bI6F`YT-6F9#QtzL0jH&)2JDvH1 zyN)4=El8_roNFIZTKM%fz#ymP+n5PMb#=^0EU@&Z&06HexKED1p$j6BI>ru`D-?Jr*Z`EK_WCTH6fr+Tk`^PZV<#{k!@3r0-5l^Y3-{$rD4?Hb4n^aPof3lrg zYJ9=*Iqz$>`#(14>VpVjC~^kn(pkd8E+FR*1mOG)DE*G+Koft_r|H^^(_e!wCQu?nA>L5YR*1l|Z$T9SE+tNweOTC^GpyDs3nKw??k!u#E|^hl|Gx)#M~Bx-p8n!zoLzf-s~KJ0BlbkXN1gr zyt<06i3tfICA}g=B-J0pv+VehkRZ^|hX4Xdu0#VCy|G(WL>GI)m075HTJH3r#>k6V z3Q{ak;6!iXo3VPXD()Y%k{0uZAFf8Z*&ikhozW8vVRX*q15`JXNQVShraz(FgxqZS z@+xS(zgC?nygx;G#p0PGlY~5#C!5L8UKj)jVRWsYq>SRYD6e-~3R@I58g%NDor2Eglb z{sPw$Y*?HQBLi8Ha5^GK;47TEbinOI>CtW}%;}p-=d&Aa5P}lo;c>s&=y-8JLxny% zw1xU5r`-j7il9=_MRm1r{jy@b+ngD4BOY^cKU^tz*S^FtC*B}QsLuCd9*<&8U!yw- z3JQemL}*_fY$AP)j$R6h7vYgksum8Dss}pVq%Gs&g5Hhy1?OU`_2-$aVIGWs;Zw(b2L2_gvU?p3}T)HC&x?aI3}L* zAS8Ipe?zFkt2mA$?w|;rvYmNLG%a!Mm&}`7(OooBJ&sw4j{QQf+|sv3GQ0c4!Yln* zk|%($4+ z;FeRP=-{s&<79T8_*m)I<`Ra04m*T3-D1vXNCc066&w7mXE5ttBEEN5e^@@P=F!jg zPw^tE-5=axDU|&#$Gu{9*3_sq0{GFVzpWZ(6p+Z^)qW=7@iu!2F?9AWyqNb3Q zyRfGw&CvZG6D}GXYeUYH?SfW!Bq6*?M9*&%j*!fsU)~l+*L-B<9&WpAhHu3y=Er`C zWWr-T-5}?0=y4%(G=$C$zw~-c;0TfjB9c(Hof&JRG2vY@`|`Ke)+wkPimd(~JGRfa z<^4jH!0@WR*doF|A^uX}^`*`X6_9Ol%wdQ7@h1a!_xmB}zVd7eT}}C_QHTCu&CX_X zjxLHJ=b*BJZ zKdboX%pBK>MaLDI&bXZ+&=6bPd_9?|VS8^a&N#C3fGx{4mUZaEUk&De%G`OZ(}lF9 zXTRN^nx;hdrJ#zz?(RurWOi@pXte29P%c={gk=hjeO`S(VG9keoqzf@jdW?z=sx>7 zC%3L}GOnO@sR!l+QAz8AD}MNZpxVmy##xe;c>leadqkf|1Vqfj+9{aX?a<&?X7_HF zY@iq6Pug3|EQsQ6lIfeFiSoK*+0^&Uw7B>8R4Rlh36!6C=-|D1O6RyO)pmjEP>XH3 z0pA1E#iC3UsN1!}_Fhs;Z4VpYGp{o`6xvVoXY!^DpN$6OCYgx2^G++Ju8#Y#(*Hrb zggb3V{xVkJaM9skw5CAIu+wu!CcVGMQ4kwHBmn%(gcvL%jv81CkwM7ELbWurT_l&A z&wA>YR(I?{x|~@B{)L_j;cKt&DkOC%Tq6@i=$`vPXOL+aNR{XFh9lwtn&m6nUB2Je z+}&xC+1;1fhA>XJ`o|E|0nTdR4L0@lzN6hY+0Jn{D^QkU}gLH-wVRx z=geMpu~HOJz+_el3$@u2-SgGLt+iprAiJ%%I`B+U?VEfqNI4xrr;_=T>ZKIEV~i|A+*IY=4Q9;r+? zB11iua~fXA5Z7wEm+2%i!XLqE+3P(5A*yVsbSApU&9bCDZASD5OSnrl;w9f&WWCW{ zq>_Pjg|UUnNt7TW3lUG6{B?jv)q@^fP6h6j#t1TGDSU~yoD}pt)4LEKu|3WqYouQ;CSv=q+;K8pHxh3^eUTsnbb|;_h~*lnG_+(px-k$3aKo1!CRdyTm32g*oe@4% zAj!phOpFz?H1>6?4xWskt_ziQ&+W9J?YvB2f-vj`i#MK(hk&+Z0Pf&-&32gvmPkcN zs7IH+yzKCJcAVcmvgFm2*mrsEPYw1(W>5Nhi+jyay1YR2wB%dxo?ExAa{pT#P?8-V z^`XS|yIqM^-b$%xNle7FoUyvoZ&{ggbYhNvyVdtIq0y@JbwNZmZ$Z^jgKb5V-8bF8 zEc;Mv_xLhL+uYZ3r*B>7_qXmvTbbP}_@0T>G-d$ePq9$^b8|(-qF>@oc@m!fSB=sq z{dTF_0g2vNG|8=L`ktRWJ%J+HToSt@lqVLw)AXnJ^}eZhk7s}(-vomz((8#`OAa&| zkV8&|5DAeAC6}am6n(c+IPdF;?dNi2Yn&vpq{j2Ll6nl~XScOZ6rBlsp3EM{F8B~! z_kljIFh!54dmZ&Xs&1{KIDuLC?pmtCf4tP7f3SVOIJAg<4Pr|Nd=miMN8Qerg6Fa~ zK?!ykP9O(v2^1il@svO;bX6vn=vk1k3TksWH>OkiJ`pwAM8$|*aE9ZVKZqcnEJ`!D z2H~ur?zC`kDpPp^;BACS`LXjqb#psRs}vT5)iT|Ixgi#%9+>YoxqLW_jDD%0>WTXz zY3Ehj*-ChCJbs!ILl;zQ2yd|m@l zf2}R;{93ynXI(YwwEV?7rbJ9VWx0asjWA3?O(!Y8N_STsXDvw`?yL@rl?a6hLN)RD z`^v9k0%JC<^A}I@C4scGy9|^(R%u@tccXQ~ZE-M`@i}zId$K#y(L_`lv zn;{SOz^@n)lw#X+$3kC#2SM zYH9I7aEC0xJM=j@rTkOig(LC{P)apftiD{Z zWjjn)F%}p|DC51Kf2lx!g)Zv?NkbxeUo={c5L`S$TtGB#y;Vj$qh-tN&{j%qvY6eh zs#Jiz&zgnz62_!_*jYr|epSsvnnKKZnM7Uk85taj>@JH0EV67&Yk}`cc4iR;jmJ9u z9>@8)o8wkrG+)AwBYOQxtKju$WzNV!CaNBveqIrksq8>NdKxH_3hE_bZG>)}9L^mY zRf+)?QUfwTX_(Z&8|mhV`FLYD=0dWs2)+)sqDQ^@hiG@%LlfOu06zcfS#;~1uNfHJTg&uH5zJ_`{9~lcf z@|(Yn7N;#)5DzUx`pz|Lw6dBemBa-b{2M0!@N)dv`p)BL6y{(c_?!!|dx5i2En(mJ zI21hui?x~LR!({FF-6f}jPa@v+RO4X@l}8BX7saAs}3PC;1QK9@?A2r)mR>IxbwVbIXUVFSOPA^HsT0O2uFgbg2q8DYnp;lG_pz4EBsb|;vcLn$mUdE~ zVd+!YycV_q0-Lmz$-y`mn92%0oHS~_r5I20`vZ0IK{C5U+t7h(OjU>Nl<`~?lMs*3o{z++5GVey)m^^gJ2k8-msSv_D6Eh;XUdy8|4TQgvrOst!TZWM z@5p-M;$d0us@AuQ+3dECi8B-qjwn2B?|#-29#|1^(@A`%R0UyM;pP?f z7J1qI-_q~21{Zig(bST#_cWNJb%x7&Ly9N3Yk;2*8n?u!QeDh>nBSg%_L>&E^FIK` zB=&irt0Ry59aqmEDvenGKSTGwujvITU_VfxM0=nGu9{AZP$3zMWfNLB{RX)U4$db- z_Ux{yo+O3GgLRP565rs-o!UfcTM><42= z;tjHuE|yxV;2}D4Q3-5F$qFo6+e)SX&o=*WIYym7{i?$ZkmrR8{0-$(sz5{GP-HE| zLuO0Zay$tO#HKetAqBM}=9W@sl62*DR+^broV=-rJeiV2eWRWf#Ys1`rH`I1+#=k)8S)c9Un8CAC*e(fdFeYDLs3u_anc-g(-C*KInCsjjw7LsM2U3!vIxSLzK^ws9 z*pIpNZ~{wLSukHk7W^n|3L+$)Hzq17MH`3oiuERaV6y(R+7%5cw}{1C*^u&|qJ0#; zfCyTPF2;-brY>!PsS+k3jY92mJbIE;B}R|>pW>*q=3{on7#)?o#NWc9e^a^P#lrG% z5#egAR*s;;8Cf{4*Dz5fmnmG-0Z@1=S8^M|VMV%})&>*%7S1Kvn3bcYrSS}wet&ac zZ#kWo@>@U%$6s+b=M%l+0dTw$duasj_nZOjeW$5T_@WnQajbl!(k3e8bc1EonN zf}_;+b>k=R(4!RzFcV3jWKvqzVE=JcUHFVEXUgKoFWMGBnqkWTVe+(d6mXL*-NS9*gh%UL~1sq54@*8xX%a@ph zO0w)JV&$mP;_k4`DlJGzs{!zbntuv&ZIZA{ixphG(iJ{Bi}hb3HS2}`sCkf(WHCjl z)Z!qBq~n}7??)*5qFJOVGjF76rQ3w}AuoieT@M+eR*F=~$@nJn>la8+d46%H1&JWX z^w}pUe3yZqjlR2!RKn7mMU!Mj@xy16lO{p9LXCJ+ap9t2@CcB<|KgWzi-@x)c<~MN z$&6B>>>sf{zPL4xs63q8vx`H{Dr9V_Z8hyag7p`{9FP}4TU(pQs$yzcxX2ab<^JcZ z1UJmywp>Sm3#&{!;*w+s-M7VD%F40_txR_RD8~aGpK0mn-%d-|>`sdm<*PIwZDmTd zJvUcC-Mj%#yXpHe$gqH9a50XOzx!JzA)}V)BF%g$zx_YTY`*14S?nu17+wm=Nl&yS z8QY`WZ1q*i@WuQN*3JI>?C<~c`tUpZ_SSu#YRl-DDe}CToz*83Zj7Q2(44JIa|M%g zetfwfk#Wsy`O2_XX9eGk;pk=@aYdZHX_XhpiI@~l8U0p;m=q2xf^z`#4rg~*8_}=x zso4n{^uI~e^|aw&vG6Vtd_Y?%`@(`V8rMEFUoDd-K`ge;SA<>LdrDEBK?b(ny?pk~ zcQNB>Cw$jq@G_BFtbJ%ZtFWua=BidrS#QAK$40jAY!$f@4Zb=6wRjAT+t(_{$hxD_ zBcN`5+XVQB-mN1C%^oBR)&o#p8+N^8O&s&L7Y7oC?eMFSbX90yP0du(hP!up_J^Q? zFI`t(V8tFxWK}2|<@0}4YL_cLo*@8QLx(kdYcOq!V{$RgCxNP$Xt{pBw)Z<9lx+l> zCNq#`>^=PRee}4^jo75xM+2_cF!i<#<_5Czcaqqx70g-{u(%=7f-bg^Ll2(|CStaC zL-uIh+ly|>CQR)7=?^LuAHn+S)S~Mo$$6R9STrNYBcRsq9Cny1n{^^E>n@zrArXpk zR^$IZq8Aq;F}YhIEF~QsJNfgaWv^gLpu5l`xwyOg@Uza3HFMEL{+B_bhRiO=)Fk%2 zmnu2|jWkHWvAd4@yV;*P@N z!8d6$QMmh4biS0)XU&%EE)M60i7|qU2r#$qtHdZQ<9*WjU9lRW|BRyl+NsIdYUmO30ivxTy#-%q?%e(b!$=AHuT zIGkIJQ5V|aGb}`7K|r-pxhKZr2F^To_prYv0lSvtj-tq@^w4T(O8N(<>z5 z84{`fMndY_pz;7X+80kH3mO*wsyG|Pbvhfcj}nH*OAnIt)u7Uqikm#f0HPu zzLlIkhK8ofW1RJ|3L^tJt18zOq?gNi`n;@1OfD?Vw0Gm(w;}H?w!*c^Rw8% zHv4G>_JPhadKr*eVk#?^spI-OqDBX|D0N|vI8A27psoMW4Z1Y!80Qk_!YASOD@TiA z4lSQkLl^dBB#ES*2{kCt;&4uW7etZ_>7j6LVb8+t%lQhNrf~4DFMv3I5LhmoG^B*b zROt12G5eqYy}FQNBv|OaY`W@mD9pkX#d`v@8?8qNjP1RCiK%Aa+HQzJ1s4vj3sl%V zxI}50GrKa6XNi0SJ=JiO7P42I!i{aOo%ZI|F^=y-U+;Q0|MqNF$-3ury@!sqa!A^s zJOAWcs6wH?hMUZ=O;F>LUdnT!uNr;Wo5JEvR|gCD-RbQ9 z2ElD%392;tsYQERDLVVFsTjbRXogxt81ULhJ2{p)k}Tpw>H2gS3;pl?`xDoWlNaEy z-_;)Mu}t!ez98!IKjbz8F_EG%VaOn}ND^4G4WKyfMvf$?ksMHk`JA zzE}eNyKF9r{MX1#X7^CXhn-Uti$SA?AC-sYw}aQB%@}hR>#dB-Ck`fE3~|&d(uty{ zjk2O;r>9%3ok99`S5W#4mQ)Hy93nQTyWO?Moi<;lgL30lr^+d4PtipD44)*%Y?WBF zzL?23-fm_Fup%xlUn_o#+O5NvZ)zc}1nA`lVc$2dksI>?14s8YI7TUunBXl=q9VAg zf7f9q4fiSLSgB!}%`3pUs2+b2?Z@@M;jB&kh!ckv|LUq|T5bZv=*78V4nEKi;!|SL zYA|{z6Jo(2GBSA)_es{yyfy{)Pw~Z1^vY5rj#{GlYjO3@UcGkNyAXT#m`Y)M+H#n! z>-n+Q>e}^X3RAJL%ttUt{&**o?)aK%j5bkZFg*+!Lr21n#!($7?2B&uiFwU2)SZGDK-sMmwk&dR9A0|@4=s0GKNca7SZ*SY7R~4J&#@*F?)vZ_eL9b ze;!vWu37j78QHZ@68B|%daAhTD&|hzj`O-f2e|aH)2$8;=5}EWY#*EY+09dkEzu8= z9q`A=AnJ56EKp8$5Q7_=FSXfXD72j=FG_)RpWFqcmQ?9=hQa7oP#;Xz51kY7y8KrE z*Rzz2&E)Aa=gSotg4C{$`+~;}!=)?H$ey5cKu*cIiXF#7A$Ffq6a%&~%hZ?DYU2S< zB*I?(JN|Tz9n~B!V;^m~-=5ah^!4yKzo|;U%BQT$7n-(#jy+oLF!${W0^-pq@8;AM zUOUSB>nt6F*k=S~M2IkizMuB6Gb8b=uB+x8K4WgBc$jC^f7?8hX}g(fv(+|#I4+Gt z)KUI-?J!ZV)OzNJDIL}HPhLMl)CbMTI)m}o1)*G*Fe##mRaL!M@$O-mq5##B`__1l zMSDQK&yC~C4BngECybhC@s>MZM2H=iN_g?jqJRd+rAf@;amThYA+zKd5A2_@#L=%v z_#ori>0A6A;0^D5h1y83$QRq*mHhMB;w&A2wBc|6Ny!U~$yL@pTA%pk*qgc){AEOy zC0IYYM#5YRN9Kr_lHK`m%9Ay8A(bY(ZemH}AHTPkEz7br?^?q0Rjp-dyuJD>gE6^% zr1qwJXQ(913b>JA9<#IQ0xI(;F=#Wzu@mo`WueTw(Epo-`-+CI=HrD;nxS80x%G>O zlXBY11u(d}N4wHAGkyBIZ9Fe8zLw7euEnR4dYjK%Co^$YPR8o>s^f5VX4f7Iw&Qek zG>3_p^YxD7Q^}(gioCW3{QoRL%r3Lr)Ni-=prpYx+GxCmKfT^>$PAm$2tQA`Gtz4Q zgY_E9TqI|%tZ1FuAeVZuQ}%NDN))6ZP29;CZu{CD#(vNm)w1=tLbj9f=YB!6 z=&tLRT#PCG8};|NJ#MCJ0c~aYvu3r5mYacx$KvhxM`WDpK~!Q< zXG`wj#HbfnWq%N3r0-f-Ct}j@f)MCn%EHr1!1TEHM81X7W^x z63Kn3_wBpH9bnz0lg?E<){_XLUTew1GFzkmQ`k(C35Fu&5VHD+VgrDPPT{-A_{5({ zV7w3fwZAs(dzc)Y7D4Hxa2Bm`GhIG4IfcZH#k zX2XiEQNui%kLd4i@LQ^K=ft4EnBT5sj?--8n$Aumy(ZEf1Ii(I0k5+J*^>5uww;!r z8ZX^AuHGu<-mGYC*6@$fka^1>HL*8K00Iiwz)%x zl81odcukA?bO46F9jXljylpvu(&*mngxd~}n%L?2uzbUck^$uB_wjRKGuP(LqUneY z2i+!szioH!Z9}Js@V{u4u^`(sLUaNr)Rs(s1onf;Vh`L=6SWchdUHFVLJ!!D~y7RxI#hkmx zI5*%l8wy>l`FXL|uDdepk`xtA{~Gse9jm5}jRj?T5|R}=%wno(uNhm*lyzIMcd2SK z+2qga;CZ~JryP;-aGuQ^_a|pa$ms<%&#CM;Ns6W?K!=?gA}!tL#c6p^sD%s%br7>< z&Bo?HqRNI!OCvSuK2PShHQ#9SnYqo;GL4Z+7A8!cz5myAUg}OKojcpk)t>;(&hl{X zoaBRV_j)o8dUH{6pCl|zxz?Re7oU?y&gsbG%B~KR#M&ZZd~M&%CDR)(#*rkS<(58y zy_$H&8nl)!=c}ESdu&moYFsA9GxH3EBl7~DY>{C?%MfYANNLP2#-osa=q(fVZrX0R zeuk#FG@Hut5lAp-m2*_aj#1fzFve7knm%898xyjUU)q_)_PqLHllE7BB}CK)5dtEl z{+{cEcLJB>K*Q&XlZtkWi@0Pn+tW@H#!r8Bo#U&l9TsxJ|eEG*A`OyoDWkhGcC%&Hl0leL$L zP;hM-4~y+v5!ONmR`UJWH#TlyD#)F`8ZLS}_kw?od$U?8PPT9*e}}lT?hb41I7%&t z)B`|*x2MMauBT7)*gF#;!9Q&q9DT<>Ys5__7xxtALo~RV6Clv;d7aMbig96LD)*}R zWMES4K|Q*a45-Fcu!IjG_&hR3Wdt7KOa`Zq?rpxeCwUj%>-C;~N9&A8r6_`p2#_p-sC9zV<&#O~B1Gj~9OD4bcVJX%cD$wTJYNC>y4ap$5rdXB5NT~$5`TR>#Qo`gQ3IoPL z9fyfhmYoIUrW{xS5qI2pFpBkfx}^PQwlF5hOf335_q}-RgjPVDG5vhB20gnY${WG; z>}3t+z^T!`dEg~k&i94!#;aU>+p) z6E3Oz=n2?_;aJat@tk(=I_jmyg1rylE=ohxoWwk5&ycW5KLq5&rdasFkt&z)lveCa zxG00`r1qTI(=c$!Gu=5S|2NZmp_p?-Cdw>3-=yLZ2EfH~KKPld#`QiXXXu2C>GxDS ze>F<{W&a?Fz`G%3$<@biVe`KWjyz)hFh)BQ`dvS#Y-Mg&Gba}Z^1L`}fRZyLO?~6S zKmu=yK1b0UTcUYWTBFv+KAzbV2D6KE^QbOrP<7p@H$)^VFMrekfzfJh7 z7>9EH{(MIJoYVbs>_p0yWOhoum2Fy2^1mdza9oZ0zF89t`fd$=xr%Ll1D60U72C`W z@)0C|4S8Fo8T;PJ3k3y?O+U041q2Yh?5VeUP13R$l;sgp?Qs#+3pgc0L7JU1va-Z= zn3}eWsW*rrDtL@^qXmd2@Xew5yqr*b)EH2F`Jz0gq1tNHywx&6=YS=7rpnSt{Vv%j z%(neCc1Z-W^&K zk{HDAUj2^9A5I{8>_@oh)iTpbw#xZmIFdze2{p$&JmRQn?#$hl{3ZkK${#;_`v=J< zc2+lJ3s0GL7&Rp===DV^^!5tA?|IqNu~Ia2>0B;^Bk^xv`Mz{9aHn5-@E5djrScCAi8ORmKy=r8D@CoB;iubE#if2nX5|tJ~K$gkE^%3PbCZ#ts^jt zn}|9d-^KjE0+W3pho7ft!aQ=X+OU7)nSa8$6FYA%X>jz@piBH#JlV-us2f2Sp&dF@ zzUGR`0dMrSmVfT%?Nr>_BezH_Cz9X?NSp1xPk!XyuP)1`f1Z^;`FBiT{a)c`CG|)S zQ0M&`^adx;$rXpnJuawGt2fDXXLNd=dg%6HI4kL+vKGz_esySJoBi|>@Yvkv^*fGJ zqmB}N{%XGBs?+CBr&>AgQTh@Aik4T$(+CzBy5b)8C)!k zS@vw#zW7x-Yjfb8##K;AV``>v?{ml9a|@9#di!$sF^`3L7KjS&fiSAO?=G6SkF-*W z2M%{h?u{*RtE4d(YE8F@zJs8UXoZJyGD_Jn}PCkrCV&0_ZC4rg7DW5-Rm zWsno9-u>zgtw^S>JE$QoS~@J88%xycTD((ohai$XJENJ4uqOWD^r=T=b|~5%j14zb z4{Z3UnoV2sWRLs)%|_HBrrM0f(JB|u$g9Tx&tg>ZOL8HP&Z{Fy^2|6{@#CB^?>sb4 z95Z5)P&SWx(qyO5?Fv3duzjX&#ozoZo~bw~G01b%s6cEHh%rMVb(+W)yxuy`N|L$_WJ7-=2Td9~%&1ow> z9sT}h0Cdf`gAI-aW{P$ldH9P~(L7)*4DD4}_e`}|8=HH#C<~lIA>iD}ZihnoYshFg zLt*K=owjdrXs2+iaSg|UG>H!%ww}-cRQ|-xMoq%~{-5{9z^;vMB6-HUGsb zke@w*c{@zL6>a1QFrRSLs0W!348pm;aAKcyp_|67Yj89eEFbf8#_gmLbE8?K3so@w zjd21EG2qSZr?9R!bY!FR(9fKB&{5SOLP|I_;2G%0lmeHaDF*oZ*z-ZewZW8u zL)#g{ovEykOWl_2$i0%4W?Pu~c`M$zdQro^WY_ubOm&okc9~!(3{O%V+QoFSH{&mG zlaolVz%Z%KqE2Tdsh{QZ@+GmaH$E8kMbovnI{C;eU0k@`{U(*in-A66c~2~`SDVMf zE2x1meQrly)iP58-;7{BK3xmd0QH@mAL{_Gr?8Ug=oDV5_+wA+yh`&y^WG=3le)dg zZBCjg|M`CP-rH{aI0{c;s}X}7ZTFj8a;?wEBTb3${-;KDdix2tu-?l%_s}M(a+gcMg!;!8k%^Em7S86B~u8uS~Y%oSj2 zDb=HWzwQ^WUbo0sT-}$weyWZBfNs`#9D$*@^z?&WcrtuOv7?0SfQh%zg8r z#V%GJ&yyqWm!Ti6=T{k07c2QmWyd%>Pu;G5&Ky~zZzw#Uh*=rh%wg|EQzzqOFwWBM z>x=E(W1JoC(6fQlp__ClNSfHy24fpH0D^#c{MntuzWujlx}n922F`rNesn21V8I%>z$Xs%iObT zwZx>XmAg8+V(XQWvaSOuB&ljSzVFfl#noq+a&?KidTyyQ9}{a5&W&J8bzae}UqBpi zYNT}ITT6r7CyH@s%0aC8V2M2CUzIxv_p^F_-`-e|OCB4uRi}~)85?+vW0kC^z!j}S z_CSPunv+*eR2fNA5YOPlF;1KH*Xm?uTkAooHY=h+y@cSf(LnROqy5Ryg!e0j3q4sg zQTg0Y0?l^-_WRWqZ2)kBSk5xcG{J-}GA_8L=z8?qEuR{h&FX=nc`QH}Mmf)p$18PN zr}|oIMr>4T(C>45c^X`>i}munrqEh~I29FtP2=RiIKbndmcdqbhPwKZ`DluZ$+0b_kB#Peof~H zf|#R?TPM9;Ar^x6$U%)<5`$`CY0zphrw@_IkW>Ax%+v|wXrDK86{GtA#9jY^UU6DJ zD*cZUV&>DrnM+K0ADtEq3?$fa*n%Q4BE|`wH-&qWqma1bZZezSyig>nJfV z2T~M|7NAOq)p){G`r+{Md1Hb0xOjaZ$I`gh*fZ87U}q6+%0|SBsixx#-DeF6 z=kjjQ#s_XRtY>aDg%Z786yE`BIJtA&&IWV1-1Y2#zN#*1TCMVqG3N2KzSpPPX_CU~ z4f>0I;$?IZx{SU}W{EE6mqsk!ivP@9$sazZcN)AlIDAJY-_1F2FHsRTiWS87{L)gm zXqTa6^x7oy*aRoS*25J)8N34uq zOeLVo4`5po^DU$PIR7B=nmTV_o;pTKf_m?hvn= z&(oeLoGpA#%a0Tz2+R$5a?|tvP@>F+VPMFAr=4#f<3JaOS5mhNh2LK~IQ%_%V`6%e z-(;l;=Jq)b%?)r0FuIR4nCL~p&YlA3f??7K$NA$t*^h}wTlG7h%uZzV3z#n6>rLQ^ z^8aIPfOp8{?>7IR8orr=e0N|zZviH0<^`;rPl)x9?T*Rp5H#&h=s`!QV0%M(rlKj} ziesbQVxE*!ZhdLvbLPvRar4cZxdiwY{Jewna9j?kU$gyBZi(;JU*ff#A4JeXEzg<$ zf}QXXQWQU4{*oLcVK->-6Fm&(`b#+?uYb>uWG#~BY&_s;1M`rA@CD9FhGTB+!ob~* zNt6vc7W};fAv=IKBvlH7&MqPyDTKy}eOcxH3SHME@Z8=<(MJ?tS-6=l@?_RG3=I5m z=vV8k3Ao@GV{!TP11^2t>L95Xcl0Qw| zQXUa62cWY{kF}^Nr@(RmXncT^H~@p(9;5hqE^SE?3@4cWz7c&tf6c8k$RfCGxk=!L zbIcT;J&HN!JV{ogv60z^apJ6xi%nemCZbHgsyghkVso%6BSL%- z^$UJOxxXyMuNeO4<_15baZW!|iw@_3Fpv`5za0jJ(n>^|i?gAb+LOhsvtohhV9h3w0y<9ls+cRA6_Q!Pob_RQZ&?nj7fH!t* z_`1ijmy~FVzlKfV9%s{Hx*BJt)4AcWLdKB}A0a7!EU?+!#v+@W1&a?3Vya-JAtR^9 ze|Jrnjops}uK_~SgtC`2el;pohsy^tx=X|EnNUXHTAJIuswP6Q^*f1ULd^$jqT26$ zreQaqz^ICp8U1hZ5$G`|8EoA%e`L818yt+u3n(Z$_9ABm354foLW_Wqv%Q+S5-vYp zkbcjeJl+mbWtZBc%+K@|88e+xwPnVmg7wwhQNeFdjmG5-pMuJVDmQpSTeUq?`%?_1 z)OMh6EJTvb=3RPu+zEe^3p}u?lCOyRQ{#aq0S?7?KmYwYOTn7Y$oV9yNx%Jl2*c>S zm_Vkq{q-ecTe(4sirJ;%Z5n$f?k3-h2fWj+%h=ePcUf6a9uz2PU{m-~kP&~)q%%Kr zHDt^KjQiKMyxG5wES>YQ@p4Y7@`A^iMl-Bkw2M#TnGGLC>r{|7*&$fmYOS>dMkg0NmJRtW8L41>ugK>IRu#L^GszS()U(F)Gw$uIx_myj z1AD|5cXim*+sZfFYBtcgP*7#iu&XvgZF&gMp>H6C-PtNm2@ zjXy74uIV|;HjG?{CHbtE$_w?Jq#B%5uceCA7Tx|S4-x#B9hlp6z*^@=Yb`*3oUxE{ z%k5n?c)us1QHK#6@N|P#Lq|dVyDbEn=A`N+_U^D>d+hDYl9ee)IP_31tyJUwL_$k( z5EIT*xF*-M^RGpT+3BljKe|2nRQA=_cdXn!ewmv5Z{%Z&wC*jxx!{9;nzaoFN6aJf*D(rvPngmjF z?BxEvUzl3m6NuKb?-VS{gP1|22?Y_$dqn*LDSHd~W4TPM`=MWOS5df-aolevgz?6+ zbkLv>mmT)7^lu*%ZK`wsbRGE+uSe44_mNjj4$<0%Xg4&w51sHtRbz_5waNf)V?EWZ zYEkm;$;=5s8Qf6N5gq9rBD!kT)6X7^u#qnBh(r>=W9+m6qiU3BtQ90YyZY{3C_EJL<`{O(Iq%tT74DrI{u@+|^>>JFr8zfRXd6)**$j*kx%r z0*&xPvs&Hnc`vL;Q<)uDyM831utm5CvJ`rxQ<;u5^kK!G*Z25iCDdxUj*)KW77Km|3sn@pTokrc2{JbiS#NADZyT_Ws zh57?mJDos8&r`Ug4KEvW=F_j~MUVY3x&d%73I-uJTK7p5-ypSl)k}E9;);Sd48W)m z%nQ*(-F}^!bq|_>Z!Ars0iQeXZGB_pWAbGx3!Rs_z5oUU9jWFoF4fdzf0yi|Ui6jj zd{n^G`lCmB$RE}P<^}~Z(Yn1^CJL-^qD{t}asRbZEWFh>=47}!z6jSowsHx`r}2Mo zSF^mSv93kXj#{V^ev{uYw zq`kS#t{BGHpVH3uS|2!p#7KY;9!Ls?Nx}S>uV@*p&Z&4GwsV1uozB?4Qhdk0=s(u0 z;gFNJbK0ZEHLAq9$Ite9I`iveGiYZo4x40dJ?|rr+D;B~A{@!^H}3RKk{iXn8xp^Y z$)(4PI#WM<{_P?L1}l-eOCs(-+imv>r~0iI?$!K}ri7dh=shh7y(ME?Ob-1?;NeF2fk5bbKU9M zSI8To{L3fM3Shbh%=kJHtsRC&*evr3=eQd{NL+3+?#FVz^!w()(4O?~N}Or=lBenF zK)0k%;xXWXTway8PTXj89 z?2Wq}O^M_n=6}#$*32-=MsV#!Yc(W@IRY$iu3tJAn>W_ACC_1*k)D@=1Cskh1|%dF z++#DhN6*<3!Z}F@O%FATW%(@4SIQHk?^vax&oTto2e15)QwF`KK2TS`f3Is(xC5{@ zx`C?N)Cm@0d?BMsmo5#^7wM-x{o&QI8e>8AFW+=Adty6BveAqi zf|#Fx)xwo#7TzYGgP~W9`_)R{IlufbZ{?ny|1aqwd-lTKpu}v10$6S}T&qAk)U4OO zLzw2$6n^PV8EJ(tH0{d%{v`fUqxpWhfn@MAu0hvUT&yB}Ft~NKmAi@dQIONayb)@* z%G0v^R9)I*EDd`G_3cF2T%RZIeX9a6elUpH(e@<%g_KL*W-Eo%>yxTge=9ij^Fy}& z_~&-aHunw_f)7O%y{f4X7+3XYi|u3}GN$O5qF!Oj-Lex(ITJ~cbZs$pk&6w6;qL*S z5(HLQ56%S#asrD3Hn`AW#EcOzLO~vZmWFf?&%c)$H8(8&jqG7JOjYQ|j2eUf?L7ih zdHey!pAK{|y;S>W{?Z?fC$9%0)RD`i)IQC>yPg=7bb1xHK8y1p7bmE<2vfy}e5V%T z@Jymh0=gb57yju*TaUrmRIZ(-gUd&;%}AVOquB0y_yRG|3+dc9^uKsGz6vRH-+mfA2@(`$3Pi zTB-Pr%au|dD?+?Q#(AYVyh9?kCR^T*cKA?F^d@&P=#MWDQ~(4?4UHoa&XnrmEpJt| zBGXWypz%b@1Tn^oODalBgw_u){<~gI_Z0`6nRaCIL z7DMFz?LP$OCC=_bYqhCM_J$>KOCrSA$W6J-ibhK~d?8-pzoBj2y7MN(V=$mV`jaGH z(N;hG^-#mFFO`|}5%jL7VqdPb^>AAX-K87|T8d9oxYxfSwI+$13vYRZ)Hj5fgLQRy z&htF{oOeb-xvDgUC^$@YQ3INX1CEPGuVJk{BDINq;f(HbwHJip(zYRRtA6ZXc$a;c z3xgCRrBdlp(D>pYt+BeEdsp>|p1jgpZBbYDV=M(5mShbV-R#OQB-YgKv~4>hfls4J z&3B%a)^KPocDo&>z$cQ@{(p+Lg$%Q7x{g#4#tX(q6@`$^p!1=vJmA>q`Gg@y;RHZ2vUSssK>F9^~B}(bbrO~CUH&* zn>c3AMzTGsbLqNK7Sr+bZSNID7OVb?{4qwk-}|3^3Wx0cP)92nlSd*p%%83tjkg|2 zG*UJB3ee`PYn2*AD??03eu2{TR_01$iviN(Mb)OQ45*v6wykTt$pN*=PTeRII@zM| z>qq0=`x@%}j}Yw~+o7TDdnxaY{(w{8@JsR}Zlf&D$S7Mi&zAa`^TA9_KYDPoTn9`Q z*B7q?hP!ww^F=?ZgnfGb@2OAD!M}cT>h-z-g^ z-?`3}o;k{5qKuM<#M*!Jz!jQAzayaNNiff2-4=XKAw9?;FWMv=rfCJ}+1Tnw=?zS{ zf}N~+>?8X{&XVdk|K!jgRj2~Y zwX%e#V}~QM@GdY;puepQIkS}0;oaVPgKfWw$Q`yy;pcGs!H_HM;{k5L0aHcP3X>A5 zbXgvEYt(XhwyY1-sE6zBY9V*!F}cA+#@l+@r0BM{-}>Nb{q3GV+LsB1&i5;{{2VoZ z$;5-k74-0Gq2bTu`kU&H(8Owb^I36)dP`+&=TEF;7rO`%-4J)?c^>Ai-r>c@f~&@{ zA{8PpqlYKcs*ljcIO57lgYfV^XsoaY)>WhQ&<3Xpj5Ck+C9sdp4!Kcg`CZ1o%lNqz z<>PKslRs{KL$9si$g-(8RCC^cXXdGw-XlSgzE(ue6z?h#+fYT{X8K`CVtuq!`xe}C zv*Ziw70oZb=zl)LPeVd|*H#g7Ek@V~)bkdzGa^io^ngAWHy%t4}!CN`r})R#s%c;V?A=0Iyw^W{^&fiB*sw{Z=~!sq^y{>K5$+g zbuF6%h-#0MO)sI)C%^>k{4$Mrb+8mb@vSrwYkCfXY{$O zx-H3@+h@HlYC|OrNEC>Yu(qt4Tuc_-X za}^lg+R9|JujDz{bf{CN`gRF(jhwak2bC5|Nd-t7mdVBq-_hxBo6B&_<4*bTyJyw7 zbBc{1BR@r3pE+5cw?}OvTG#2NoGh8ayC?BYGb#6zP2m&drttnnErjGDKz=&Wlm0g5 z%sL6%Z-z?j{<;prcNM(f9ik8feA<w>EXM^ zy(L>m2;Q6P=q^K*QL|Aif&BxwbN)MEp(%;Eh@SE~W+yDu4>10Vk+tu~<-A>Ra#RW-XuAzX>FOw}v*>pt*AASYmUa1v zgEdHcHZMZFca+Z*(7L?psKs?_Ow4|K#ywAZrB1}=>LB;UljR!EUAUz4!8LDQr6-(~ zupM_uiE)=t{E)|WwHgxl8*>%AruMfxH?jVdQPnbJ&QsAVq2yNf=8X-n{H;<%4pSEn z+df)AzL}iby5WuPooRAYAy*fZ_XgJ|M~I&g2z4V`Frr3N7kWc+vciF`hQgV9E|6tv znkYzUr*1~rp)+p?LhKxY%&16xQF>ho8-um%;J8PK=IV8_gsU^DyJT7JpfCcFC+WJ2 zhBU$r3drve)W;tytL?hsS2%()ev1UMCu3%@dd}(IdCE5kmx_Bcfv$rimVmC_2Nzs% z2$~tv5=-J8m<-)LE#Y+a`8qlICm`@1jUV*2%H&p-w|>=~-U5Q+Z(}bLCetZQr9g`~ z8vQ47_nSDyJl*gap2}NCyaV(QvR-`ZQhJ&Cj*t{W+Pxj1pNVWBCNKXBce5BkRU%kcRd2-gt z?P|`bS}zW}qc)vgN10y-`72E!@))vBR>L?E_MK+#5T1kWKF0wNb>KmJ`}&G(+cR5( zQ-m>&ZG3`87*rZgNly%(?SYA-pv3`wzZ}H0mQtsw6A-X2JD(@SDn5^E;iqn$3O1mvu4@NB(4EjCpfCJ5AL#Td(R*_ znWpRi$5+;B(4dnZnr-hI%P;G*$)pdBIddO(t*B}BvOl})&nf0CM-id*LD^QrP+t1M z0gh{4mDrNc@@i08Er+qbxRP(+WSJc2TkoT#C!yY|;!&gS9_eYlGxKLTD_2^wIe3hW zjiHD<6-WZyaVbo?JCr zq-R4O_=w~?OxiNW^zu!ZW8EW=6U=Vos2&62K%+^7eWR7HxfbUXEWMt1Y}&XCB*GU= zWSqhB-M&6svtH-eM^5PVbjZ?USv$hP%;(Cu z(dX78oOxD|w4HAu<3Bk#U!wVdA)tAY2UJ6JOUhk$YD?@1Tm^qbcS z;~i7p11}5)tnBE^6-3#T=h){ccKO4RFo}+-;qo*b<;yLo3yO_X$tAm3NR@rtHtY%+ z+RAE$DB8`%rK?WJ?TUhG>kYI20L$OJ+Iw?qh{FG_5@(Jr@$D5bipyM9ZjzgN(=QMa zv_Vt?!0SL4=VWcx^FK(-*bgr&1m6leZCLzcQMgnaaH(=P$>J&Xi^PP6mD|pqQ3u0g ztr}%=3RBkI@p*}adgH^&JM}1Y`5bGI=EX8=zk6U=|E*}lOzKF`7D|#FX~_s5_=fN^ z242)EFT3Sty)==rlNDicPz6yhlSdXx6Q3A=__F&eAE}DLlCyhkBZj-iNRnNA1&-b6zO>19#=ku{STX zOS*1FnU99;3+^&7&{mpvVgmc)tKz>(7m{WGMpCEJ-?NJ+SL??20K@g7YhY|3uI)~o zQM5oBnq!w#^9Ox17T_9QWcBaNbafO3a1@il{GyHa%%G3g2lm2)hFx8rn@OLSatFAb zeK~@w^NsfMCurSir`oNqA+u)MYBqTQz;&Vn4TlSJxL0Yjr^-gLhvmGh^Y!X0HRPNR zM6NHSG;kF5pOXi)_gT&>FZ3n0HksuX0%6Z|MQz!WVC=M9&R2I5oF>$zRTpy|1?f7VaxGlP%qQr%39 z&xKs44)y~8Y6vA_8QjcKS198qFAh#wRs8-qe$Y$AeN29^LK(J86?eDbVyFTZ^iuli}QFH9X71QImM;2%^nz=L~ICJ zrQAcBaMmJeX;o?W@aT1Kkvt^1Sl$Rd`qdsTmLmE!9$D+Ha--qn;I8T8-CT;DBVCAj zs*U(RK+o`Xi^7ICkhz}Kp~H_dxK4H(E?LI40aA1vx%HXHK?$$;=^k~puJYb-KVp=4 zbz1*k{>XbG(5#*Au%n_4gph;%l*wzSHX8a0dq#J&hFkCO&91(@_)bNw;`;S-SD7{FDcQsUMm z*!IsCJ?3vS{7(jLK~~D{MA@SBm)Di8v!wkyJ%>zd4ot+WT#Lk`LOD9QrpeZL!;@R7%T@cJ0$`FGV{NXS5Na-EEO}mC9{Zsm{pO zu1zX2gUV8w8A}e1^ZuXrjPCFL@8@$r_kK9%U7qLrJkR%8j@^@eeT)n&3=l#_?bnW*y#b*Poyorfbo976LbhnU_sHoX$A1)uCC)1dRfR3k zagL@l)C(Xm&J${hdLyK?yf_q^NxTr0TV z`SQa4536!>Y){=>8nnl5>_3OHA7%R9clF-CK|LB^;BzUy8ljq)xIFitq}-pVFvP|5Xg>dIFk$Bt&5$?$z_3jeq! z|9Gz`_}(G%)4M?F-zO^PmCuT@^!rh?Y1A z1j)vYeo^{*THarhG-35usb@!1(|r%*)C{)q!?C1K1tIqf&gYf~8N1q08y1vn+$sj! zP<7L*3XggFjx1B4DbI8F(QB=miqHtyU#~qoQK@jXQO$8NUTrBBb?a6xtN%W&hnWlP z5P|soQ7SdWIfaU**4=yBbW5#a(gwMb-{@UuAE+^hd0kn}n0O8Xu? zPA@SqqSVQqx$eTsF1ula3C^$lVBIGS4^j@UDs1_(pWLM=PVs;`#)y}}wx>Hr;^%42v1OPU@}v0uhkt@k)qkhf zp9jf2{!ze{_2WFRC%}#07_rOasa|Sv7S1+ygGb+ekZ0&z*a~MnlJSY2$R@ae9;Q^v zB}}0V8y&cnQhYUc*YNvJGT$m!z`>vMeu~Rh5#=uJ3>O`umR!qfVfjma6%AW@ z36B3}_J)v+zVPo2M!b-mZ5GjMzBRVLefQd5`Bp3a_D(y(xOq7$zR^8bL&`Mk(pXY{#8FK79CR`Rv76C#Ib%9_i}|2g)wZ zuV?|KJ1FGT9LB_m3S%Vin}x-g{Rtt+aSjp_40PM`Z6mW4)*lkr!?U*X(JZa@FdU9(>^Usl|h{K@Uh{ob6> z-ej=<4a5gzV|gbK#7YCLe$u1x;^ED?S98wU1sc&IbLYe7mLIuw!Jg zhp?FEhzf$ggB{sS36ma)Hv?!Ny7+w>GkpYzb^=|ToZm$3^4t!}rcT7IDWw+Wvul=JF20As!DVcM#!GYVr4dXVwIGKFSarKMj(wh)w0yIebZ14&+b~ z1@BuXr~^?7_)pu$+_dJPZg}wo5X0Hwsf?SD0xUczrxqOxb(4Wvc)eT~MG*zs2=U7K zp!&O|ba7^`oo63;dr;t)Ie_zVtVthoiJb(3=?0WqYKA*o?*H}ov%#=MI;b}VkkA;5 z|L6rPUGE6Tv`~vJxX@DSeqp1VO=#=jmC1LXcf$JXFAep)x7V^2Rx@odhv$YW6^`t8 z*s?U&F3tJi=A+LXV9BUEuf=4;9^hW#xaUf#n~~Rsn0VF>b8EM;6u>$)4V>ZQxg1f? zmOZKD;>#m(WaluZp;c%?k8t?umKXw;dkW{O&>a4K!GWM7rPK!hG&b>Mn>|7`B{${mu6*?>$cW4_R2 zgA`Q^g=aGJTk5piEC44e6>?V|N#?VD>^Rh@x0ZvOPu=aql3O?JAX-#PyKuEkvb&D@ zf>(t-yuXvVPn^z_-IpJU+3k3qI!(xZHj_8`v6kbMbzD(4jwa?a`-vmBlh`de-x_$J z0H8P^9=DcJ-b5Cc!QtPlhTl{3*z}_Oc1lNxLbLW9;{|@m@HbAC$;X5zI`z;5$>)H% zg(`{jxZq9DgTPUDz7oJfjabMfEZx3g)QU`VAHfvP+Hp49Z+O2&+ zcDM-Y=*fwApiVdio&pYNwvBdV(*Y)@2kmFv7M=ooT&9tn1}S>aEW1Rcu)9oZ&LwFS zfj4eiCPf04e@60}>J*9PL{pM!tmNGlv6+j`or|ESgOYDSiHvv(V62Ce*4 zL*J&^C87PsBzUMxIYs~H8BLllr<4S1g)s~Oy`lq4EFNUBoigIVtg^&=P+%==KS{g& z{zwA4hDQ=9J4olwOK>K0bTBz~chEc04cwnogK&Mp%3pP4AoBHiim>N56IipDY3zW@ zePp8U0&^tib$S=fhQH1xRuXcvbq_jXf_{r!0_0#9k;N9W;6QQPE4#s6;e}$=So?s!!Y8sK%&)E+M zBp~7$QPxaQ`ovEuhUhO1J)2G7&HC5nGKC@WF8N*X7Nx59&DozW8fs5CuD3e*Jp^y^ zx}EIVYP7;Q!FV@zA$I=8hVkowXA)%t+A=h|&K5sKN)hgmga77TJrRcyQ{h5Ee8++J zcfN^OiY`Rq&@$>z-n)z7MPptC0J*^Xp>N{h_q%W=0Oc=@PP-1iNS6`vmP&V0s_W+%;sTIUiBo8BIXUrAPO0%{JQQn$%ZvHd$%7X*XG=)F0 z1(O)5H9vbcA&<&v?Gums7$dN*3;c3l$5J_GE z)M?VJiL6r}(Uw66Zf@v~hLtDh@%A;T8zaJ(G-+0#H-8Yw{!q__j!Z>7kDWtWt2G0r zP#90SMNE_TJYl$3hr3(OVJ=TEalQ0->^swKCFJTgQ(G$O(mmStf{MZC1!1})1zx}oe_coi#2z_JoaurgZ zr-+2DA$o$l8l6S1J)6yl+yt_lwY=`UY-Uh%I?$KCWQFrQ9(iR?BNxZ=VPS51H{H?d zZdf=GmO0qrcX$%v7>z2osPE76vmzO&a+F4LooqDcUz_(}GqLH(Dv#z&dGFtVnvb_vWz!=7=zmZ2XE>FfgPxt2g1YAnI7HlpM1`x#Fha$!tPD za?_2V+wIf?Vz1Qv)a{?dMi;`7avzb^!C)=e`)l2XXSpK#jKH;=I>CtN5fweray&|% zW{FRMx{Kxa{A0h35Bu~E4dC8WHcdXTNRvMIb{oOq#d3&zVs$(|ohAIfU`EpvbVVp1 zqLJJq!kQY1Z;}YgALcKH9%71r-X5RvDI3Y>VlWcW6Rx(l%TfD>fz_0XO zyaD_L!aY3;ymFeL&C8l~$72Bh{o;r>*}?AtT(QlYm~e%9IRNw7#)u@3cQs-->e78P zco)%u8=XlG+~W7}52d^{?ZnY?B#$FDgcCN?z+2e@dYH~Yo%`1D~-1`>%s=2;-`PO;32&AXRY zaNJ5C*|ZR3RDsgyRjgB#RtETCc?>s8{(+G?a6U)ieB&UJ5o{hKYQqH`!IKa8Dv;%I zWaTX+1tGk`jm@O0nP6e#gDnGi^SbvZn=l9-*{8Z(CT+(rfwRGB&u`DB&prH(=zI7r zutH#>gUMt0;4F9oB*Mc=lZOC`!_#bR?x+f;O|w9|vauJrZ4E^#Ti9*L+;=|jM2bm01 zrA&JpJ>pfwdo!tn!Gg67JWZL)=O>a5?Dy>e+jh8rZU(x;qkixVl1RdS%M++`uycP~ zZu`HR9nj+j(b_PWu1D1|!KV~@@&cZ2`0`%8O}a^HT4K&^fB5^5_CzZr2l=g)g=%rY zg&BK)pJ&A)UMal8keGn~Jmqg}V%S8Pc|{8r9%00R1iSNy^QW|a!-P{g#o^7*`Wg~5 zl9AhfmGS9ECLPE|fp5qc;J}5Ihqhn(?LU>oC4jV6^2mKhOiigcludsZ2b}sQSO|P) z9oYq#=gLwof_dHb=jP*APAxNdGZJ>MBGDoE^Mg@SE6~aA)KI9U0U3Yn+!@ zDDSV4{2;F<>EI*f()I)xyq!{XAJ*(=Cu?-4iP-r?xZl4nep((5k1SP*@q$48S;6Q- zPcAPBZcv#G@C3@mQ~%G&=E8^9`&A?P3*u|W zO(O12%ltknrA}b9)NnR~yX9<_)NKQr1xX)ShJ>pEtqm1z!>KdX(wlw1p|z7)6H?In zY7Ljr@(tn_QaW)DO6kgl2YHv{z=?>t)LwzL$z_M|m;J^_i#oIRL(CTwVMOsyJa^PS zagRKK!@t2MeR7e!h&SYjnVKN2O~vr$ez#mPqsbfvVWR3*h*G?=A;>_0@EFac)w`e) zj)b6l1dI^jeyb_jjhp{5L7@IcOzsUSBJn9AZ#-age#5zml%XsggFWnf3F-kO&_-L( zPmB?sz&N-5UOJSR0R#gbtuynleni*jp^z3r5~_B$C4*ErksI!s$~ZUtesP!%<}*M^ z80*B_b3oYYNvR`~h4O(KGd(!;!9b;?j$dFqH5cB03bJ@!f&P)QI|Z=#x$aE96`r7F zauCVXsx5Nrb%e9fYePu=bd8xlSz;Mn2|}ed+6;qiO|sAg|MZ1J^zA~eBc;V7+VeZ} z)OJt~MQ>*OfClUwp)Y$;=G+Oi=8*LN0hkhxUFR45Sd85Ja-$;Qfj5u;%>&JN&NO@OnI#uMomXz)XpkzFVefsCAbI3gvs5SUu3dbui5ixGhuTuX3=QUUA_i z`e!1mCxp9xH2!~nf>J9an=+~mQLVED*&q~F}H4km@# z7o&JmsfLOs(2zlSc+LZL@~h*HLX=G4bGmjN;o8_+lfRe)jjIY1a37UWUO|BOSgMo^ zAmD+VgvSB?RLLaQjzC*E0$Nu0zusJ}NbU{3?r8CW5|J#QKxj%Ur16Zu4j*c1UkKY~ zo$-~Ks+tfn8i5Hu8??Ol3ZHJj(-5wu*aX^~d_=JD7!%u`pi~SfRZ5-z#V|d@or}GF zs5>%_rBB+z6A!de_bw@7Xn`KEEemQHC-e#PU0LSr2xlD^G_&o7g>iT?L8U}OeCGG1 z=x=-O(KdKwi_2=l+=~kZ1P4sd1sMqO_}pB zB7*+^rjfid)O2qHkmMv+WYvW#9SIu@B!rj5wSOV|(w2)g_H{*%X$C}nn)xsNOCp6( zwMwUyo{`r^uyRBoryNK*we^YL>qrBHxco)cU}AiNQLvHb`hPP@xJpugMwCOne&j1e zeh7AY+AEb`o%rrHEAGn$NA1Al{dAP=5iD_6k6Zd%j6@i3bLsEWO+UsXxbXZk=W)^F z?gu1?=k*IKo!|KGgyQC>#_uM+F>+3INdvEKCT`aTwtOYepN5udTrv??`wif-AdM1usIi6&P)p0Ms+4g zqJrl`@T(q-qO-O~mkptwIe%_Wq<(JZYX9+2XW)Yo;ZI>(t1&_^g>_O(WpiS7tpbpYvL%_eaoU`u?9BA&o zxYbfAIRZh1mRPgNpqa(HqPI{PcmvJ2RTb_hQgi_+CJSU+?#@>3R4@vqLYqZ3ra6j* zXkVsBOWjZk9xFXKoL_b6h;C{hzaM(wIL@SU&Z)WhH39vWqk~sPuc*FL&7X8wo*4%x z{gEMfS;S5Q5f75%K%<1F?{BW2ej6iL7(X~LdPVKGpeDWKh6<3e@0Icn*bq%`DnSVk z1;i&v`|0D&8_ZGkK}BhDI=jK$$~dJ)vy;+Cmp+|NqBm0YOv#@-M+h`xB*ReO17nR% zlRi%ii?D0mvX7`>H{>#JYWK+!hTnA_#QN}oDYZ8k*Y!oneE%+EQ)_W@BiTGYKMQ#s zT~%y`SbHI*ri;KE>>@iC{|Hi~N%lr0Jtor6j!zdTH|wLR19fCnSH|B2Y<`##I(GK; zXz#^L#WIQrv;8_=nA7(lV)KT$AXBYs_OJOg-4JD*EggL@Ov!`lj|2jby-d3{Fm>$$ z86<4kMVBLZ=v>R=VRszjJtVgH&ZydRpyfGlwr0@)vk&*|GY7R$MOz$4W-N8S|NUzC zFY6u#1q$<@X+_rU-lls5PpaY`Zcw~=-JFCgTdDkTqGIo{sDV$W6l(+^ZSOZ-|9QU= ztaEJ#M$hXk<>vkI*OGMZ>H=xIcjq^^JgWDuus9cXV?%;y)#8u8?gcz-=%B(aH9|N@ zmwWLE?WQO2u3bqLjwgKD%)`6;?PecQQhk#$3$J=0Jay-^b8Cw&DHAqUn&2+_{f zRhIY*SE+fRl-vSXD*9wnuS=&*zpwn`>E))mEPcnRzo3{X3Kmh@ywHu|{Zk8Axf-rkccGGRPTh zIR7V^@PlzD8=Uc{9BvP_ZknVj`B+_}+lO9&LH1RHC#$V*P zU9OeYHD2@8gp908PK@piRc^{i=%vR1V7!yfOQx*R{U?S`X8SLP%{z{{6!Lt*o@w{9 zWIoNNeSj#Dr=gLC_GbGC&MO*zuD3%Fut6eC#K}w<72N8qmU=Fj++5M?^bQCeXUZiE zn%o%>(ew2=MYBg?`s#^6{5!a$6t92J-)8c<&JWF#8%5ZA!VT^22Taghp%L1ok&Ngu z0FH+19#-5-Ah|?|4W}-y_$NzvxA8MM`ux1*ij_w9BBI(c})$k zFE#VC5!~lRSp1|MROYZy@A+t(5p{lN-9aTzaw$}jN?%7BqO+y*(G}B$UmMr(RBOgS zaq)C8&AD_D5Y$15FG3}{39a09K9+c?uH)4 z`p7`q`>y%mt}4g9+{fq@qe%YeB%?o|$vWib=MqNLFM47s^JoQrMU-%IvKuA=CYu^U zDx=%67n3jyeV$^&KRq%1#MVLbCqA-lKL4Vxvxle>0lZcAdg~v?Zk*lJE^tzTM!W`H zKmhwU@7@%e_zp!~CP^E%*Q>=)!losE5G;q*9BHLD{b3Asn}ITtC|=W-Sr8RjiiPIg z)64}i!8$TN&BI&_ue!C3SV*_{X_8|dTJAta#jf->n9p7e&Hr(v6V5AXKj1G=OD?IE zXUrh*c)U2LylK?&0?9B3?uO}7YbeB1=NXe>D1`W^mDNYZHkC2B9ModM`P+vS(7IfU z|BofewswYKlOpBLDdlD7*6HkWyQf3c$xSmb!`!{ZSwC4{9p|9;X)(@28`!A588&l6)Zg!H#pu#invixa^|N z8;RMJ!$3Bjci;;`ukpkOP(Y@*J*QTJP=&C7c{9SDck<7FL9 zWc?mo%>8;aIe^Epbg3Dr%=?6+&D)BWAMDq^h~QF5erLZEzefW)g`f+O{A-}1nHI<~ z4v|7*+DjAuwK=#p?EyW@>EZeM{4ob7@u|fAENby+rQjri?zqM{B#bAouxSRm)K_wo z+IJ9?%Ug3kvHj?d_-^!dz8X>fKXLy!WAb2DD5_iuN>WmXcBXR08j#ADi{UgUZ;5rS zO5W`dOPU5IM!4O-lCBExBtJZWw}I5YO4 zp%J(;EQ=gSSc|czgj%=Tgw#LRPWPgN%5o)F`}OOvVCW6jYWJdk&G+A0k+MLos6K4!7M|1ivs7UP)e{<##XP^Z zT;^Vn)WGrY5@;GJr48h@5D~igF2#Zn-rS3w#RJhgEk;QvYEUEa*#P}(7f z@KWhdG$0;SGGQ??2RJ_fjYwU&E1A?%E8b+DHvR=dHYe=}x*wZrg?cLi;y+B#gHo#6 z6oIkM->j7I0pI2*or62L>Z2ZhZB@oz>a_Jp*;{t-Iht&YlY9u`gFj|sT|ipL&`a`r zs%d)+0EGLRz$rrwwG)x4!BBKC3Ty1-jqtqo=T`=EyhX83_8L z7*&_js>A)RY_Sm#lx4i*Rr?1VVT?9ioue=7Phc`vD35ky8$ESqX(@0@dxAI{i0)JO z&tU8RmZ;N~YCYhRD zotpAk7t~QN9>Tf1Hik%t5+zj|qf!m^hPS*4t>kvKU0|t&5XMq>L3aSeP|zJgQ74!c zTMKGhZNR2q8u04PMqWLMW4ol0+7NQKNnhsf-C)l+z^th2ASb0 z`ZAlKUwO!DnMfARI3FFt`N~*`7%g#xqCFlB3z=&BjIKuLzEC#R2K6YTWmHBP{#ifF zPBxVFfvRlqh~IKNJq~(t-C=PfCddfQ1rq6KgtjrN)Is1W2P^5R5-x>YA_1Zz_<0!B zQc|_=;U`cY>7t~`&ELv#j9lHg13=cp#UB&+0KaHj9SDb zN0@-R68ncV_X~1<6O-1fwXWlCGT{Z25d|hF-39HvCGhHoeSBnL50@L_PoS3;Ct@Lw z7w7-LYkr!o$SK^*4UNv$mg6p|)3%^HaRS+8BQ#8{;Evh@rGXZp|ED}$AGUb_>e#gE zePTqlFXqTQXi2in)Yp%5&Eft6~JY#oWYNdhl34p@mC3c_ixjPL^fYIk+i9#rU^ z`VKeB6hf{~QCnT87vnz^{YhfaM{{)PkMJJsXeF*}H~DjCV!mB9SDXFzfV8}xEeJ_Z z2^Mz2N6Vy6t*xDQYe$ltBI&0Br7qdFM&R{i2JWL)ts%}7m(&vc8O&QZnucIkdE!&u zv2sUFb?2ja>3lLRs7|)$W*8xB5{0&iP%lQMLpk$4wQ*(^90!UR`Ft@c0a6~Je9g3t zE!`Sl`*cT-8FUiiLt0Pi+kx0av_l|2FQ`ryb2%cdQP&A&J=k>oV0avwsSz6h2c1E9 zJS+=SN}HhICd#BM(JLXm|F|ARyNr6E1&`Bq-2VnSdymuZZQaN2<&0B;WiQY0HHS9Ipa=*v2h_YVkV0yfx+a7wyROn@8huoa9O z%7uPvv+igD!QpeTn1rU(ZEYc6)sVoib{&eUCS2zn>Zai2ml^P*fo{m*{zvJNG^%;{ z2(7OKK$uQBN!)uT^+KTh`H#?RDVxyR0+vninWbQsJfh4+5O zya34R>GsJII%gB96(GC1yL8;{P zG<1pninHz8L0(avhnC6kVmtZ77@ojgCjy(YV+2<}x286c%bbrtNlw&@0!rhhcO~6J z=dxivtNUm@yqNWWL_a4p7u}J=6M@{8o7=Cr%$N31w%ZF;v=}*z8UP@4Hs?;0tV_n3 z8N*vkp&aEGQC?A|81(uq@2S|XN(o1Tqaix@87+j)^fpND73Ve<5C^t)IrVjUVATn# zf2(LuuyF5Fz!@|PzHd@WYS3gtj}N{>9XoOR)Z!%|zy33b()v82JjSA@d?y2n>HF0Q z9%wo-w_Yx2)`MzEd+q^nD8;!w1cghPpuBcoW!kA3dL0`jMwhZ+>;2~Fav5d7gjs@* znP^RdrGZ8fZz{{a9zzWx0cTo=l zJ!^%3nhP94z{CRNRPThQX|CuXQP~koG%$xM1n_3`R7-V8z#OY4=iR35mAfHa30W?2 zVn+nZWdV#>6OYG(k*U__!0Y==+4RM_wrN_@(7s&q2~FmG!6MWy4j=nTVgf9EoM3iI zJN^s-Z7r=wjL8V}d1iV)$ZW#UfGGTrTJk>HqxGsP5_b*d^8s`IidW<`KY66ugqUR~ z1#*IpaSdo;bw9jLqqzFHoqRFZ+?gn9-$-(rlM~uZ^d}SK*=Yq*lL$K11Ih4J#@P)_ znU;(s`tF`X^zGP{6v|2bfo`!CiYMXzxd|#`OmHZknIlONazvLD-J1!^#`PKL@aAm( zO~A7WH=|rSl(j397NWJD;Km-a2ytQN{PS$s4$1?dlEp)%fG`gjc-x8FGlhfD##|zN zYb2*kz_F|Rtcd>GD`{&xlyva-6oQ|OJ|S4Ro(TW0z&0hnB{3WL9Xy_oz&wG2P0%4C zee9{7_oKV9&rDzxi4OV*;UEf5^BC>z40$$n`5J|*f}v}xnk42oDkU35Vv-r+%W7v2 zw2=_-Bi&K9Cata?PEt#%+ASm<YPs?lU4fICoZxTvXy56!+XmW~|0ug?Tz$bfo~ zdFD*okh4sx}*~U+a7D+Rwq|KgRH?^(5XGk4~M1f!E1#w zF;QHV0I`X=R7=1i3&FuGTCx~^IQj5bm`L1%a)oVeYHGm0b*|^eNJ$UmCdQ$s8p(VE zZUPAi9}jsDQ!xLxZ*9krAW8VrpXB)=T$lw|`&&bh{nP$xYAdV_3=q1~J876J$+kfg zh>wSWx}a1nz@yw+zU%W;J-8dcye{XF*KVP$c2>g-fQ!9;2u>2}_!z{~aEV^e2W$Gq zHnKqvwPe#C6mug+JCQ&^wNGCbq-%p5ACXNAfmG1%^tl8u8~O$@z6m4Lin)7`0knJh zYLJaaqF&4ArjTCPf&GV|$HusHgi5m1EpAhD(gSei)V)qbQg0&hdKVoBgWprB<4Yx# zXtEhDx6^?DP3&hn8!Q%tJXdU7N))j~hzl0!PA22UL*-)DF9t6Z%It|EK0)KYRgZc` z*|UBmpi}ookq!1o;w>&Z?S2nKVQ;nbAXLw1c3}wN8EUE);&6)74zQ12-I>PDc3z8! z8z%!iJ0TOv6QifZXS^Y2v1Y}Vf(5!b_&afqy}B}umUdoepA!ZO%4pd}v4WGH++64) z0d;|yp1KMl{z>^(5g$2=KfVtBYmsid3k;+mm`Fl65m=R+`fM)n)RI}q^lmXGZipd75%NblWytzvto*Vfj~G4` z%$I|1L{dl01*^2Ogcq+3bKyrc!2Akz{gIK%^i+<@D8wb>_^Is{4xYK$Rb zI?#;`3TX)s#`zI;T=A#r=J(#wAN>$`O-gK(8m55rt<$y8rYy!a(Lv= zt-A8+x!j?0YAO>ZrxYO&AfVJuqTa=4BvC)#{}>3I&o;JGdL&8C*R?>N zb)1bxQGNbOja~3Vh+GCypqi^(YnCZea7+`*=FjI1DN(cs)%@8;KsA+x^yuEsYX3_^a78L)1ME{T^VONJ!;4 zDF2aOc4xc*il3_Z@4ot|)6-#GY!=%41iTa9X@*j%n+x)U@<)4PG&AT|qB|ErWS&Io zjh*`Fv1U;M+wE|C?f5cON5z(?Wc|YgGgdLIn+lygt1o-5s1?`Nac_HXr^d7X#BY2j z{`;vhmQsz4B?~~^Jt3@;0cSEY@4=dQ)-O&HdPU-;c3X9cgObzLsoWtFr+X7dHl|-M ze&13ewziQgX$R6JXwSPl*iw_$+E1IZ(oQxc{LZ;pO1m4P-9Wa<49^3wU|g*)>r5