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 ( + + + + + ); + } + + 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)/incidents.tsx b/src/app/(app)/incidents.tsx new file mode 100644 index 0000000..2a1dc13 --- /dev/null +++ b/src/app/(app)/incidents.tsx @@ -0,0 +1,73 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { router } from 'expo-router'; +import { LayoutDashboard } from 'lucide-react-native'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, View } from 'react-native'; + +import { IncidentCard } from '@/components/command/incident-card'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { type IncidentCommandBoard } from '@/models/v4/incidentCommand/incidentCommandBoard'; +import { useCallsStore } from '@/stores/calls/store'; +import { useIncidentsStore } from '@/stores/command/incidents-store'; + +export default function Incidents() { + const { t } = useTranslation(); + const incidents = useIncidentsStore((state) => state.incidents); + const isLoading = useIncidentsStore((state) => state.isLoading); + const error = useIncidentsStore((state) => state.error); + const fetchActiveIncidents = useIncidentsStore((state) => state.fetchActiveIncidents); + const calls = useCallsStore((state) => state.calls); + const fetchCalls = useCallsStore((state) => state.fetchCalls); + + useFocusEffect( + useCallback(() => { + fetchActiveIncidents(); + fetchCalls(); + }, [fetchActiveIncidents, fetchCalls]) + ); + + // Active commands carry only a numeric CallId; join the calls store (string CallId) for a human title. + const callTitle = useCallback( + (callId: number) => { + const call = calls.find((c) => c.CallId === String(callId)); + return call?.Name || call?.Nature || t('incidents.unnamed'); + }, + [calls, t] + ); + + const renderContent = () => { + if (isLoading && incidents.length === 0) { + return ; + } + + if (error) { + return ; + } + + return ( + + testID="incidents-list" + data={incidents} + keyExtractor={(item: IncidentCommandBoard) => item.Command.IncidentCommandId} + renderItem={({ item }: { item: IncidentCommandBoard }) => ( + router.push(`/incident/${item.Command.CallId}` as never)} /> + )} + refreshControl={} + ListEmptyComponent={} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); + }; + + return ( + + + {renderContent()} + + ); +} diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 7708523..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,14 +8,16 @@ 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'; -import { StopMarker } from '@/components/routes/stop-marker'; 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'; @@ -24,7 +26,6 @@ import { locationService } from '@/services/location'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useMapsStore } from '@/stores/maps/store'; -import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; @@ -72,10 +73,8 @@ function MapContent() { }, [extremeAlerts.length]); // Route overlay state - const activeUnitId = useCoreStore((state) => state.activeUnitId); const activeInstance = useRoutesStore((state) => state.activeInstance); const instanceStops = useRoutesStore((state) => state.instanceStops); - const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); const fetchStopsForInstance = useRoutesStore((state) => state.fetchStopsForInstance); const [showRouteOverlay, setShowRouteOverlay] = useState(true); @@ -95,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). @@ -117,13 +123,10 @@ function MapContent() { }; }, [locationLatitude, locationLongitude, isMapLocked]); - // Fetch active route overlay data + // Fetch map layers (department-level, no unit context needed) useEffect(() => { - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - fetchActiveLayers(); - } - }, [activeUnitId, fetchActiveRoute, fetchActiveLayers]); + fetchActiveLayers(); + }, [fetchActiveLayers]); // Fetch stops when active instance changes useEffect(() => { @@ -135,60 +138,12 @@ function MapContent() { // 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]); - // Parse route geometry for overlay - const routeOverlayGeoJSON = useMemo(() => { - if (!showRouteOverlay || !activeInstance) return null; - const geometry = activeInstance.ActualRouteGeometry || ''; - 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' as const, properties: {}, geometry: parsed }; - } - if (Array.isArray(parsed)) { - return { type: 'Feature' as const, properties: {}, geometry: { type: 'LineString', coordinates: parsed } }; - } - return null; - } catch { - return null; - } - }, [showRouteOverlay, activeInstance]); - - // Get remaining stops for route overlay - const remainingStops = useMemo(() => { - if (!showRouteOverlay || !activeInstance) return []; - return instanceStops.filter((s) => s.Status === 0 || s.Status === 1); - }, [showRouteOverlay, activeInstance, instanceStops]); - - // Next stop for geofence circle - const nextStop = useMemo(() => { - return remainingStops.find((s) => s.Status === 0 || s.Status === 1) || null; - }, [remainingStops]); - - // Geofence circle GeoJSON - const geofenceGeoJSON = useMemo((): GeoJSON.Feature | null => { - if (!nextStop || !nextStop.GeofenceRadiusMeters) return null; - const points = 64; - const coords: number[][] = []; - const radiusDeg = nextStop.GeofenceRadiusMeters / 111320; - for (let i = 0; i <= points; i++) { - const angle = (i / points) * 2 * Math.PI; - coords.push([nextStop.Longitude + radiusDeg * Math.cos(angle), nextStop.Latitude + radiusDeg * Math.sin(angle)]); - } - return { - type: 'Feature', - properties: {}, - geometry: { type: 'Polygon', coordinates: [coords] }, - }; - }, [nextStop]); - // Update map style when theme changes useEffect(() => { const newStyle = getMapStyle(); @@ -539,68 +494,21 @@ function MapContent() { ) : null} - - - {/* Active route polyline overlay */} - {routeOverlayGeoJSON ? ( - - - - ) : null} - - {/* Geofence circle around next stop */} - {geofenceGeoJSON ? ( - - - - - ) : null} - - {/* Route stop markers */} - {showRouteOverlay - ? remainingStops.map((stop) => - stop.Latitude && stop.Longitude ? ( - - - - ) : null - ) - : 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 ? ( @@ -628,6 +541,9 @@ function MapContent() { {/* Pin Detail Modal */} + + {/* Layer toggles + custom/indoor maps browser */} + setIsLayersSheetOpen(false)} /> ); } @@ -697,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/routes/poi/[id].tsx b/src/app/(app)/poi/[id].tsx similarity index 56% rename from src/app/routes/poi/[id].tsx rename to src/app/(app)/poi/[id].tsx index 25ee61f..0d5b009 100644 --- a/src/app/routes/poi/[id].tsx +++ b/src/app/(app)/poi/[id].tsx @@ -7,7 +7,6 @@ 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'; @@ -19,7 +18,6 @@ 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() { @@ -34,8 +32,6 @@ export default function PoiDetailScreen() { 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); @@ -64,15 +60,6 @@ export default function PoiDetailScreen() { } }; - const handleSetDestination = () => { - if (!poi || !destinationEnabled) { - return; - } - - setSelectedStatusPoi(poi); - openStatusBottomSheet(true); - }; - if (isLoadingDetail && !poi) { return ( @@ -91,67 +78,59 @@ export default function PoiDetailScreen() { const destinationEnabled = isPoiDestinationEnabled(poi, poiTypesById); return ( - <> - - - - {displayName} - - - {poiTypeName} + + + + {displayName} + + + {poiTypeName} + + {destinationEnabled ? ( + + {t('routes.poi_destination_enabled')} - {destinationEnabled ? ( - - {t('routes.poi_destination_enabled')} - - ) : null} - - + ) : null} + + - + - - - {destinationEnabled ? ( - - ) : null} - + + + - - - {poi.Address ? ( - - {t('routes.poi_address')} - {poi.Address} - - ) : null} - - {poi.Note ? ( - - {t('routes.poi_note')} - {poi.Note} - - ) : null} + + + {poi.Address ? ( + + {t('routes.poi_address')} + {poi.Address} + + ) : null} + {poi.Note ? ( - {t('routes.poi_coordinates')} - - {t('routes.poi_coordinates_value', { - latitude: poi.Latitude.toFixed(6), - longitude: poi.Longitude.toFixed(6), - })} - + {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)/routes.tsx b/src/app/(app)/routes.tsx deleted file mode 100644 index 31d8e3d..0000000 --- a/src/app/(app)/routes.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import { RoutesHome } from '@/components/routes/routes-home'; - -export default function Routes() { - return ; -} 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/_layout.tsx b/src/app/_layout.tsx index 3e3bde7..3dc1296 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -190,7 +190,6 @@ function RootLayout() { - 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: () =>
Close Call Sheet
, })); -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/incident/[id].tsx b/src/app/incident/[id].tsx new file mode 100644 index 0000000..cd72a4e --- /dev/null +++ b/src/app/incident/[id].tsx @@ -0,0 +1,75 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import { ShieldPlus } from 'lucide-react-native'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView, View } from 'react-native'; + +import { CommandBoard } from '@/components/command/command-board'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { useCommandBoardStore } from '@/stores/command/board-store'; + +export default function IncidentDetail() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const callId = Number(id); + + const board = useCommandBoardStore((state) => state.board); + const isLoading = useCommandBoardStore((state) => state.isLoading); + const error = useCommandBoardStore((state) => state.error); + const currentCallId = useCommandBoardStore((state) => state.currentCallId); + const fetchBoard = useCommandBoardStore((state) => state.fetchBoard); + const establishCommand = useCommandBoardStore((state) => state.establishCommand); + const clearBoard = useCommandBoardStore((state) => state.clearBoard); + + useEffect(() => { + if (!Number.isNaN(callId)) { + fetchBoard(callId); + } + return () => clearBoard(); + }, [callId, fetchBoard, clearBoard]); + + const handleEstablish = useCallback(() => { + if (!Number.isNaN(callId)) { + establishCommand(callId); + } + }, [callId, establishCommand]); + + const renderContent = () => { + if (isLoading && !board) { + return ; + } + + if (error) { + return ; + } + + // No command established yet for this call → offer to establish one. + if (!board || currentCallId !== callId) { + return ( + + + + ); + } + + return ( + fetchBoard(callId)} />} contentContainerStyle={{ padding: 16 }}> + + + ); + }; + + return ( + + + + {renderContent()} + + ); +} 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/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/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/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__/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/__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-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/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/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/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 d538292..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'; @@ -36,7 +37,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 +62,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); @@ -127,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) */} + @@ -153,12 +149,6 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC )} - {isPoiPin ? ( - - ) : null} 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/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/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/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/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/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/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/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/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__/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/__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/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/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/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/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 e937807..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": "لا توجد بيانات موقع متاحة", @@ -479,6 +651,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": "إعدادات الصوت", @@ -526,7 +720,7 @@ "sso_signing_in": "جارٍ تسجيل الدخول عبر SSO...", "sso_subtitle": "أدخل اسم المستخدم الخاص بك للبحث عن تكوين SSO", "sso_title": "تسجيل الدخول عبر SSO", - "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid Unit", + "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid IC", "title": "تسجيل الدخول", "username": "اسم المستخدم", "username_not_found": "اسم المستخدم غير موجود", @@ -566,6 +760,8 @@ "layer_toggles": "التحكم بالطبقات", "layers_on": "{{active}} / {{total}} نشط", "loading": "جارٍ تحميل الخرائط...", + "map_layers": "طبقات الخريطة", + "no_layers": "لا توجد طبقات خريطة متاحة", "no_maps": "لا توجد خرائط", "no_maps_description": "لا توجد خرائط متاحة لقسمك.", "no_results": "لم يتم العثور على نتائج", @@ -597,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": { @@ -789,7 +1000,6 @@ "about": "حول التطبيق", "account": "الحساب", "activating_unit": "جاري تفعيل الوحدة...", - "active_unit": "الوحدة النشطة", "app_info": "معلومات التطبيق", "app_name": "اسم التطبيق", "arabic": "عربي", @@ -837,7 +1047,6 @@ "logout_confirm_title": "تأكيد تسجيل الخروج", "more": "المزيد", "no_units_available": "لا توجد وحدات متاحة", - "none_selected": "لم يتم اختيار أي شيء", "notifications": "الإشعارات", "notifications_description": "تفعيل الإشعارات لتلقي التنبيهات والتحديثات", "notifications_enable": "تفعيل الإشعارات", @@ -851,7 +1060,6 @@ "server": "الخادم", "server_url": "عنوان URL للخادم", "server_url_note": "ملاحظة: هذا هو عنوان URL لواجهة برمجة تطبيقات Resgrid. يتم استخدامه للاتصال بخادم Resgrid. لا تقم بتضمين /api/v4 في عنوان URL أو شرطة مائلة في النهاية.", - "set_active_unit": "تعيين الوحدة النشطة", "share": "مشاركة", "spanish": "إسباني", "status_page": "حالة النظام", @@ -874,6 +1082,9 @@ "version": "الإصدار", "website": "الموقع الإلكتروني" }, + "sidebar": { + "menu": "القائمة" + }, "status": { "add_note": "إضافة ملاحظة", "both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "المكالمات", + "command_board": "لوحة القيادة", "contacts": "جهات الاتصال", + "incidents": "Incidents", "map": "الخريطة", "notes": "الملاحظات", "protocols": "البروتوكولات", diff --git a/src/translations/de.json b/src/translations/de.json index 56025b1..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Anrufe", + "command_board": "Führungstafel", "contacts": "Kontakte", + "incidents": "Incidents", "map": "Karte", "notes": "Notizen", "protocols": "Protokolle", diff --git a/src/translations/en.json b/src/translations/en.json index bf448c3..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,6 +1082,9 @@ "version": "Version", "website": "Website" }, + "sidebar": { + "menu": "Menu" + }, "status": { "add_note": "Add Note", "both_destinations_enabled": "Can respond to calls or stations", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Calls", + "command_board": "Command Board", "contacts": "Contacts", + "incidents": "Incidents", "map": "Map", "notes": "Notes", "protocols": "Protocols", diff --git a/src/translations/es.json b/src/translations/es.json index 7f18022..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Llamadas", + "command_board": "Panel de comando", "contacts": "Contactos", + "incidents": "Incidents", "map": "Mapa", "notes": "Notas", "protocols": "Protocolos", diff --git a/src/translations/fr.json b/src/translations/fr.json index 9f45e00..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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é", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Appels", + "command_board": "Tableau de commandement", "contacts": "Contacts", + "incidents": "Incidents", "map": "Carte", "notes": "Notes", "protocols": "Protocoles", diff --git a/src/translations/it.json b/src/translations/it.json index 1d061bc..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Chiamate", + "command_board": "Plancia di comando", "contacts": "Contatti", + "incidents": "Incidents", "map": "Mappa", "notes": "Note", "protocols": "Protocolli", diff --git a/src/translations/pl.json b/src/translations/pl.json index c2767ae..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,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", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Zgłoszenia", + "command_board": "Tablica dowodzenia", "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..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", @@ -479,6 +651,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", @@ -526,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", @@ -566,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", @@ -597,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": { @@ -789,7 +1000,6 @@ "about": "Om", "account": "Konto", "activating_unit": "Aktiverar enhet...", - "active_unit": "Aktiv enhet", "app_info": "Appinfo", "app_name": "Appnamn", "arabic": "Arabiska", @@ -837,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", @@ -851,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", @@ -874,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", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Samtal", + "command_board": "Ledningstavla", "contacts": "Kontakter", + "incidents": "Incidents", "map": "Karta", "notes": "Anteckningar", "protocols": "Protokoll", diff --git a/src/translations/uk.json b/src/translations/uk.json index 2c9ca18..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": "Немає даних про місцезнаходження", @@ -479,6 +651,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": "Налаштування звуку", @@ -526,7 +720,7 @@ "sso_signing_in": "Вхід через SSO...", "sso_subtitle": "Введіть ім'я користувача для пошуку конфігурації SSO", "sso_title": "Увійти через SSO", - "subtitle": "Введіть облікові дані для доступу до Resgrid Unit", + "subtitle": "Введіть облікові дані для доступу до Resgrid IC", "title": "Увійти", "username": "Ім'я користувача", "username_not_found": "Ім'я користувача не знайдено", @@ -566,6 +760,8 @@ "layer_toggles": "Перемикачі шарів", "layers_on": "{{active}} / {{total}} активних", "loading": "Завантаження карт...", + "map_layers": "Шари карти", + "no_layers": "Немає доступних шарів карти", "no_maps": "Немає карт", "no_maps_description": "Для вашого підрозділу немає доступних карт.", "no_results": "Результатів не знайдено", @@ -597,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": { @@ -789,7 +1000,6 @@ "about": "Про додаток", "account": "Обліковий запис", "activating_unit": "Активація підрозділу...", - "active_unit": "Активний підрозділ", "app_info": "Інформація про додаток", "app_name": "Назва додатку", "arabic": "Арабська", @@ -837,7 +1047,6 @@ "logout_confirm_title": "Підтвердити вихід", "more": "Більше", "no_units_available": "Немає доступних підрозділів", - "none_selected": "Нічого не вибрано", "notifications": "Push-сповіщення", "notifications_description": "Увімкніть сповіщення для отримання попереджень та оновлень", "notifications_enable": "Увімкнути сповіщення", @@ -851,7 +1060,6 @@ "server": "Сервер", "server_url": "URL сервера", "server_url_note": "Примітка: Це URL API Resgrid. Використовується для підключення до сервера Resgrid. Не включайте /api/v4 у URL та не додавайте кінцевий слеш.", - "set_active_unit": "Встановити активний підрозділ", "share": "Поділитися", "spanish": "Іспанська", "status_page": "Стан системи", @@ -874,6 +1082,9 @@ "version": "Версія", "website": "Веб-сайт" }, + "sidebar": { + "menu": "Меню" + }, "status": { "add_note": "Додати примітку", "both_destinations_enabled": "Може реагувати на виклики або станції", @@ -908,7 +1119,9 @@ }, "tabs": { "calls": "Виклики", + "command_board": "Дошка управління", "contacts": "Контакти", + "incidents": "Incidents", "map": "Карта", "notes": "Примітки", "protocols": "Протоколи",