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

@@ -34,7 +34,7 @@
### :package: Deployment Options
-Resgrid Unit can be deployed in multiple ways:
+Resgrid IC can be deployed in multiple ways:
#### 📱 Mobile Applications
- **Android**: APK and AAB builds for Google Play Store and direct distribution
diff --git a/app.config.ts b/app.config.ts
index 978a147..61494fd 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -223,6 +223,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'./customGradle.plugin.js',
'./customManifest.plugin.js',
'./plugins/withForegroundNotifications.js',
+ './plugins/withNotificationIcon.js',
'./plugins/withNotificationSounds.js',
'./plugins/withMediaButtonModule.js',
[
diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png
index 2c25798..1a38832 100644
Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ
diff --git a/assets/favicon.png b/assets/favicon.png
index c7ae4a2..3fda96b 100644
Binary files a/assets/favicon.png and b/assets/favicon.png differ
diff --git a/assets/icon.ico b/assets/icon.ico
index 8a08f90..ba201a8 100644
Binary files a/assets/icon.ico and b/assets/icon.ico differ
diff --git a/assets/icon.png b/assets/icon.png
index f4f6910..ec2672a 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/assets/icon/favicon.png b/assets/icon/favicon.png
index a463af3..3b59715 100644
Binary files a/assets/icon/favicon.png and b/assets/icon/favicon.png differ
diff --git a/assets/ios-icon.png b/assets/ios-icon.png
index f4f6910..ec2672a 100644
Binary files a/assets/ios-icon.png and b/assets/ios-icon.png differ
diff --git a/assets/notification-icon.png b/assets/notification-icon.png
index 011616f..19b02d0 100644
Binary files a/assets/notification-icon.png and b/assets/notification-icon.png differ
diff --git a/assets/notification-icons/ic_notification-hdpi.png b/assets/notification-icons/ic_notification-hdpi.png
new file mode 100644
index 0000000..68db637
Binary files /dev/null and b/assets/notification-icons/ic_notification-hdpi.png differ
diff --git a/assets/notification-icons/ic_notification-mdpi.png b/assets/notification-icons/ic_notification-mdpi.png
new file mode 100644
index 0000000..d975457
Binary files /dev/null and b/assets/notification-icons/ic_notification-mdpi.png differ
diff --git a/assets/notification-icons/ic_notification-xhdpi.png b/assets/notification-icons/ic_notification-xhdpi.png
new file mode 100644
index 0000000..8635920
Binary files /dev/null and b/assets/notification-icons/ic_notification-xhdpi.png differ
diff --git a/assets/notification-icons/ic_notification-xxhdpi.png b/assets/notification-icons/ic_notification-xxhdpi.png
new file mode 100644
index 0000000..26777a8
Binary files /dev/null and b/assets/notification-icons/ic_notification-xxhdpi.png differ
diff --git a/assets/notification-icons/ic_notification-xxxhdpi.png b/assets/notification-icons/ic_notification-xxxhdpi.png
new file mode 100644
index 0000000..5c349d5
Binary files /dev/null and b/assets/notification-icons/ic_notification-xxxhdpi.png differ
diff --git a/docker-compose.yml b/docker-compose.yml
index bfd9ae6..fda5714 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,22 +10,22 @@ services:
- "8080:80"
environment:
- APP_ENV=production
- - UNIT_NAME=Resgrid Unit
- - UNIT_SCHEME=ResgridUnit
- - UNIT_BUNDLE_ID=com.resgrid.unit
- - UNIT_PACKAGE=com.resgrid.unit
- - UNIT_VERSION=0.0.1
- - UNIT_BASE_API_URL=https://api.resgrid.com
- - UNIT_API_VERSION=v4
- - UNIT_RESGRID_API_URL=/api/v4
- - UNIT_CHANNEL_HUB_NAME=eventingHub
- - UNIT_REALTIME_GEO_HUB_NAME=geolocationHub
- - UNIT_LOGGING_KEY=
- - UNIT_APP_KEY=
- - UNIT_MAPBOX_PUBKEY=
- - UNIT_SENTRY_DSN=
- - UNIT_COUNTLY_APP_KEY=
- - UNIT_COUNTLY_SERVER_URL=
+ - IC_NAME=Resgrid IC
+ - IC_SCHEME=ResgridIC
+ - IC_BUNDLE_ID=com.resgrid.command
+ - IC_PACKAGE=com.resgrid.command
+ - IC_VERSION=0.0.1
+ - IC_BASE_API_URL=https://api.resgrid.com
+ - IC_API_VERSION=v4
+ - IC_RESGRID_API_URL=/api/v4
+ - IC_CHANNEL_HUB_NAME=eventingHub
+ - IC_REALTIME_GEO_HUB_NAME=geolocationHub
+ - IC_LOGGING_KEY=
+ - IC_APP_KEY=
+ - IC_MAPBOX_PUBKEY=
+ - IC_SENTRY_DSN=
+ - IC_COUNTLY_APP_KEY=
+ - IC_COUNTLY_SERVER_URL=
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
diff --git a/docker/README.md b/docker/README.md
index d672e2f..ad174a0 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,6 +1,6 @@
# Docker Deployment Guide
-This guide explains how to deploy the Resgrid Unit application using Docker.
+This guide explains how to deploy the Resgrid IC application using Docker.
## Quick Start
@@ -11,14 +11,14 @@ This guide explains how to deploy the Resgrid Unit application using Docker.
docker pull ghcr.io/resgrid/unit:latest
# Or pull from Docker Hub (if configured)
-docker pull
/resgrid-unit:latest
+docker pull /resgrid-ic:latest
# Run the container
docker run -d \
-p 8080:80 \
- -e UNIT_BASE_API_URL="https://api.example.com" \
- -e UNIT_APP_KEY="your-app-key" \
- --name resgrid-unit \
+ -e IC_BASE_API_URL="https://api.example.com" \
+ -e IC_APP_KEY="your-app-key" \
+ --name resgrid-ic \
ghcr.io/resgrid/unit:latest
```
@@ -26,14 +26,14 @@ docker run -d \
```bash
# Build the Docker image
-docker build -t resgrid-unit:latest .
+docker build -t resgrid-ic:latest .
# Run the container
docker run -d \
-p 8080:80 \
- -e UNIT_BASE_API_URL="https://api.example.com" \
- --name resgrid-unit \
- resgrid-unit:latest
+ -e IC_BASE_API_URL="https://api.example.com" \
+ --name resgrid-ic \
+ resgrid-ic:latest
```
## Environment Variables
@@ -42,26 +42,26 @@ All configuration is done via environment variables at runtime. The Docker image
### Required Variables
-- `UNIT_BASE_API_URL` - Base URL for the API (e.g., `https://api.resgrid.com`)
+- `IC_BASE_API_URL` - Base URL for the API (e.g., `https://api.resgrid.com`)
### Optional Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_ENV` | `production` | Application environment |
-| `UNIT_NAME` | `Resgrid Unit` | Application name |
-| `UNIT_SCHEME` | `ResgridUnit` | URL scheme |
-| `UNIT_VERSION` | `0.0.1` | Application version |
-| `UNIT_API_VERSION` | `v4` | API version |
-| `UNIT_RESGRID_API_URL` | `/api/v4` | Resgrid API URL path |
-| `UNIT_CHANNEL_HUB_NAME` | `eventingHub` | SignalR channel hub name |
-| `UNIT_REALTIME_GEO_HUB_NAME` | `geolocationHub` | SignalR geolocation hub name |
-| `UNIT_LOGGING_KEY` | `""` | Logging service key |
-| `UNIT_APP_KEY` | `""` | Application key |
-| `UNIT_MAPBOX_PUBKEY` | `""` | Mapbox public key |
-| `UNIT_SENTRY_DSN` | `""` | Sentry DSN for error tracking |
-| `UNIT_COUNTLY_APP_KEY` | `""` | Countly app key for analytics |
-| `UNIT_COUNTLY_SERVER_URL` | `""` | Countly server URL |
+| `IC_NAME` | `Resgrid IC` | Application name |
+| `IC_SCHEME` | `ResgridIC` | URL scheme |
+| `IC_VERSION` | `0.0.1` | Application version |
+| `IC_API_VERSION` | `v4` | API version |
+| `IC_RESGRID_API_URL` | `/api/v4` | Resgrid API URL path |
+| `IC_CHANNEL_HUB_NAME` | `eventingHub` | SignalR channel hub name |
+| `IC_REALTIME_GEO_HUB_NAME` | `geolocationHub` | SignalR geolocation hub name |
+| `IC_LOGGING_KEY` | `""` | Logging service key |
+| `IC_APP_KEY` | `""` | Application key |
+| `IC_MAPBOX_PUBKEY` | `""` | Mapbox public key |
+| `IC_SENTRY_DSN` | `""` | Sentry DSN for error tracking |
+| `IC_COUNTLY_APP_KEY` | `""` | Countly app key for analytics |
+| `IC_COUNTLY_SERVER_URL` | `""` | Countly server URL |
## Docker Compose
@@ -71,26 +71,26 @@ Create a `docker-compose.yml` file:
version: '3.8'
services:
- resgrid-unit:
+ resgrid-ic:
image: ghcr.io/resgrid/unit:latest
ports:
- "8080:80"
environment:
- APP_ENV=production
- - UNIT_NAME=Resgrid Unit
- - UNIT_SCHEME=ResgridUnit
- - UNIT_VERSION=7.1
- - UNIT_BASE_API_URL=https://api.resgrid.com
- - UNIT_API_VERSION=v4
- - UNIT_RESGRID_API_URL=/api/v4
- - UNIT_CHANNEL_HUB_NAME=eventingHub
- - UNIT_REALTIME_GEO_HUB_NAME=geolocationHub
- - UNIT_LOGGING_KEY=${UNIT_LOGGING_KEY}
- - UNIT_APP_KEY=${UNIT_APP_KEY}
- - UNIT_MAPBOX_PUBKEY=${UNIT_MAPBOX_PUBKEY}
- - UNIT_SENTRY_DSN=${UNIT_SENTRY_DSN}
- - UNIT_COUNTLY_APP_KEY=${UNIT_COUNTLY_APP_KEY}
- - UNIT_COUNTLY_SERVER_URL=${UNIT_COUNTLY_SERVER_URL}
+ - IC_NAME=Resgrid IC
+ - IC_SCHEME=ResgridIC
+ - IC_VERSION=7.1
+ - IC_BASE_API_URL=https://api.resgrid.com
+ - IC_API_VERSION=v4
+ - IC_RESGRID_API_URL=/api/v4
+ - IC_CHANNEL_HUB_NAME=eventingHub
+ - IC_REALTIME_GEO_HUB_NAME=geolocationHub
+ - IC_LOGGING_KEY=${IC_LOGGING_KEY}
+ - IC_APP_KEY=${IC_APP_KEY}
+ - IC_MAPBOX_PUBKEY=${IC_MAPBOX_PUBKEY}
+ - IC_SENTRY_DSN=${IC_SENTRY_DSN}
+ - IC_COUNTLY_APP_KEY=${IC_COUNTLY_APP_KEY}
+ - IC_COUNTLY_SERVER_URL=${IC_COUNTLY_SERVER_URL}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
@@ -111,13 +111,13 @@ docker-compose up -d
Create a `.env` file (never commit this to version control):
```env
-UNIT_BASE_API_URL=https://api.resgrid.com
-UNIT_APP_KEY=your-secret-app-key
-UNIT_LOGGING_KEY=your-logging-key
-UNIT_MAPBOX_PUBKEY=your-mapbox-public-key
-UNIT_SENTRY_DSN=your-sentry-dsn
-UNIT_COUNTLY_APP_KEY=your-countly-app-key
-UNIT_COUNTLY_SERVER_URL=https://countly.example.com
+IC_BASE_API_URL=https://api.resgrid.com
+IC_APP_KEY=your-secret-app-key
+IC_LOGGING_KEY=your-logging-key
+IC_MAPBOX_PUBKEY=your-mapbox-public-key
+IC_SENTRY_DSN=your-sentry-dsn
+IC_COUNTLY_APP_KEY=your-countly-app-key
+IC_COUNTLY_SERVER_URL=https://countly.example.com
```
Run with the environment file:
@@ -126,7 +126,7 @@ Run with the environment file:
docker run -d \
-p 8080:80 \
--env-file .env \
- --name resgrid-unit \
+ --name resgrid-ic \
ghcr.io/resgrid/unit:latest
```
@@ -138,48 +138,48 @@ Create a `deployment.yaml`:
apiVersion: v1
kind: ConfigMap
metadata:
- name: resgrid-unit-config
+ name: resgrid-ic-config
data:
- UNIT_BASE_API_URL: "https://api.resgrid.com"
- UNIT_API_VERSION: "v4"
- UNIT_NAME: "Resgrid Unit"
+ IC_BASE_API_URL: "https://api.resgrid.com"
+ IC_API_VERSION: "v4"
+ IC_NAME: "Resgrid IC"
---
apiVersion: v1
kind: Secret
metadata:
- name: resgrid-unit-secrets
+ name: resgrid-ic-secrets
type: Opaque
stringData:
- UNIT_APP_KEY: "your-secret-app-key"
- UNIT_LOGGING_KEY: "your-logging-key"
- UNIT_MAPBOX_PUBKEY: "your-mapbox-public-key"
+ IC_APP_KEY: "your-secret-app-key"
+ IC_LOGGING_KEY: "your-logging-key"
+ IC_MAPBOX_PUBKEY: "your-mapbox-public-key"
---
apiVersion: apps/v1
kind: Deployment
metadata:
- name: resgrid-unit
+ name: resgrid-ic
spec:
replicas: 3
selector:
matchLabels:
- app: resgrid-unit
+ app: resgrid-ic
template:
metadata:
labels:
- app: resgrid-unit
+ app: resgrid-ic
spec:
containers:
- - name: resgrid-unit
+ - name: resgrid-ic
image: ghcr.io/resgrid/unit:latest
ports:
- containerPort: 80
envFrom:
- configMapRef:
- name: resgrid-unit-config
+ name: resgrid-ic-config
- secretRef:
- name: resgrid-unit-secrets
+ name: resgrid-ic-secrets
resources:
requests:
memory: "128Mi"
@@ -204,14 +204,14 @@ spec:
apiVersion: v1
kind: Service
metadata:
- name: resgrid-unit
+ name: resgrid-ic
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 80
selector:
- app: resgrid-unit
+ app: resgrid-ic
```
Deploy:
@@ -247,25 +247,25 @@ This approach allows the same Docker image to be used across multiple environmen
### View container logs
```bash
-docker logs resgrid-unit
+docker logs resgrid-ic
```
### Verify environment variables are injected
```bash
-docker exec resgrid-unit cat /usr/share/nginx/html/env-config.js
+docker exec resgrid-ic cat /usr/share/nginx/html/env-config.js
```
### Access container shell
```bash
-docker exec -it resgrid-unit sh
+docker exec -it resgrid-ic sh
```
### Check nginx configuration
```bash
-docker exec resgrid-unit cat /etc/nginx/nginx.conf
+docker exec resgrid-ic cat /etc/nginx/nginx.conf
```
## Multi-Architecture Support
@@ -284,14 +284,14 @@ To update to a new version:
docker pull ghcr.io/resgrid/unit:latest
# Stop and remove the old container
-docker stop resgrid-unit
-docker rm resgrid-unit
+docker stop resgrid-ic
+docker rm resgrid-ic
# Start a new container with the updated image
docker run -d \
-p 8080:80 \
--env-file .env \
- --name resgrid-unit \
+ --name resgrid-ic \
ghcr.io/resgrid/unit:latest
```
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index 29e5dc8..6b042fb 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -17,24 +17,24 @@ cat > "${HTML_DIR}/env-config.js" << EOF
// This file is generated at container startup and injects environment variables
window.__ENV__ = {
APP_ENV: "$(js_escape "${APP_ENV:-production}")",
- NAME: "$(js_escape "${UNIT_NAME:-Resgrid Unit}")",
- SCHEME: "$(js_escape "${UNIT_SCHEME:-ResgridUnit}")",
- BUNDLE_ID: "$(js_escape "${UNIT_BUNDLE_ID:-com.resgrid.unit}")",
- PACKAGE: "$(js_escape "${UNIT_PACKAGE:-com.resgrid.unit}")",
- VERSION: "$(js_escape "${UNIT_VERSION:-0.0.1}")",
+ NAME: "$(js_escape "${IC_NAME:-Resgrid IC}")",
+ SCHEME: "$(js_escape "${IC_SCHEME:-ResgridIC}")",
+ BUNDLE_ID: "$(js_escape "${IC_BUNDLE_ID:-com.resgrid.command}")",
+ PACKAGE: "$(js_escape "${IC_PACKAGE:-com.resgrid.command}")",
+ VERSION: "$(js_escape "${IC_VERSION:-0.0.1}")",
ANDROID_VERSION_CODE: 1,
- BASE_API_URL: "$(js_escape "${UNIT_BASE_API_URL:-https://api.resgrid.com}")",
- API_VERSION: "$(js_escape "${UNIT_API_VERSION:-v4}")",
- RESGRID_API_URL: "$(js_escape "${UNIT_RESGRID_API_URL:-/api/v4}")",
- CHANNEL_HUB_NAME: "$(js_escape "${UNIT_CHANNEL_HUB_NAME:-eventingHub}")",
- REALTIME_GEO_HUB_NAME: "$(js_escape "${UNIT_REALTIME_GEO_HUB_NAME:-geolocationHub}")",
- LOGGING_KEY: "$(js_escape "${UNIT_LOGGING_KEY:-}")",
- APP_KEY: "$(js_escape "${UNIT_APP_KEY:-}")",
- UNIT_MAPBOX_PUBKEY: "$(js_escape "${UNIT_MAPBOX_PUBKEY:-}")",
+ BASE_API_URL: "$(js_escape "${IC_BASE_API_URL:-https://api.resgrid.com}")",
+ API_VERSION: "$(js_escape "${IC_API_VERSION:-v4}")",
+ RESGRID_API_URL: "$(js_escape "${IC_RESGRID_API_URL:-/api/v4}")",
+ CHANNEL_HUB_NAME: "$(js_escape "${IC_CHANNEL_HUB_NAME:-eventingHub}")",
+ REALTIME_GEO_HUB_NAME: "$(js_escape "${IC_REALTIME_GEO_HUB_NAME:-geolocationHub}")",
+ LOGGING_KEY: "$(js_escape "${IC_LOGGING_KEY:-}")",
+ APP_KEY: "$(js_escape "${IC_APP_KEY:-}")",
+ IC_MAPBOX_PUBKEY: "$(js_escape "${IC_MAPBOX_PUBKEY:-}")",
IS_MOBILE_APP: "false",
- SENTRY_DSN: "$(js_escape "${UNIT_SENTRY_DSN:-}")",
- COUNTLY_APP_KEY: "$(js_escape "${UNIT_COUNTLY_APP_KEY:-}")",
- COUNTLY_SERVER_URL: "$(js_escape "${UNIT_COUNTLY_SERVER_URL:-}")"
+ SENTRY_DSN: "$(js_escape "${IC_SENTRY_DSN:-}")",
+ COUNTLY_APP_KEY: "$(js_escape "${IC_COUNTLY_APP_KEY:-}")",
+ COUNTLY_SERVER_URL: "$(js_escape "${IC_COUNTLY_SERVER_URL:-}")"
};
EOF
diff --git a/electron-builder.config.js b/electron-builder.config.js
index e3bdfeb..e9af535 100644
--- a/electron-builder.config.js
+++ b/electron-builder.config.js
@@ -3,8 +3,8 @@
* https://www.electron.build/configuration/configuration
*/
module.exports = {
- appId: 'com.resgrid.unit',
- productName: 'Resgrid Unit',
+ appId: 'com.resgrid.command',
+ productName: 'Resgrid IC',
copyright: 'Copyright © 2024 Resgrid',
directories: {
@@ -57,7 +57,7 @@ module.exports = {
installerHeaderIcon: 'assets/icon.ico',
createDesktopShortcut: true,
createStartMenuShortcut: true,
- shortcutName: 'Resgrid Unit',
+ shortcutName: 'Resgrid IC',
license: 'LICENSE',
},
@@ -74,10 +74,10 @@ module.exports = {
vendor: 'Resgrid',
desktop: {
entry: {
- Name: 'Resgrid Unit',
- Comment: 'Resgrid Unit Management Application',
+ Name: 'Resgrid IC',
+ Comment: 'Resgrid Incident Command Application',
Category: 'Office;Utility;',
- StartupWMClass: 'resgrid-unit',
+ StartupWMClass: 'resgrid-ic',
},
},
},
diff --git a/electron/main.js b/electron/main.js
index c5957f9..b901858 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -148,7 +148,7 @@ ipcMain.handle('show-notification', async (event, { title, body, data }) => {
}
const notification = new Notification({
- title: title || 'Resgrid Unit',
+ title: title || 'Resgrid IC',
body: body || '',
silent: false,
icon: path.join(__dirname, '../assets/icon.png'),
diff --git a/env.js b/env.js
index da09940..93d2c8e 100644
--- a/env.js
+++ b/env.js
@@ -34,11 +34,11 @@ require('dotenv').config({
// TODO: Replace these values with your own
-const BUNDLE_ID = 'com.resgrid.ic'; // ios bundle id
-const PACKAGE = 'com.resgrid.ic'; // android package name
+const BUNDLE_ID = 'com.resgrid.command'; // ios bundle id
+const PACKAGE = 'com.resgrid.command'; // android package name
const NAME = 'Resgrid IC'; // app name
const EXPO_ACCOUNT_OWNER = 'resgrid'; // expo account owner
-const EAS_PROJECT_ID = '6beb03e6-f639-4f35-bef7-5cfb5f173218'; // TODO(IC): replace with the IC app's OWN EAS project id (this is still the Unit project id)
+const EAS_PROJECT_ID = 'b29e7235-267a-494d-8b59-466701ada204'; // Resgrid IC EAS project id
const SCHEME = 'ResgridIC'; // app scheme
/**
diff --git a/jest-setup.ts b/jest-setup.ts
index 7527560..edbb3b6 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -255,3 +255,14 @@ jest.mock('zod', () => ({
},
__esModule: true,
}));
+
+// Mock NetInfo globally — the native module isn't available in the Jest environment
+jest.mock('@react-native-community/netinfo', () => ({
+ __esModule: true,
+ default: {
+ addEventListener: jest.fn(() => jest.fn()),
+ fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true, type: 'wifi', details: {} })),
+ },
+ addEventListener: jest.fn(() => jest.fn()),
+ fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true, type: 'wifi', details: {} })),
+}));
diff --git a/package.json b/package.json
index be5398a..140ddc7 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"test:ci": "yarn run test --coverage",
"test:watch": "yarn run test --watch",
"install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash",
- "e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development",
+ "e2e-test": "maestro test .maestro/ -e APP_ID=com.resgrid.command.development",
"web:build": "cross-env EXPO_NO_DOTENV=1 expo export --platform web",
"web:staging": "cross-env APP_ENV=staging yarn run web",
"web:production": "cross-env APP_ENV=production yarn run web",
diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js
index eab2b92..587b37b 100644
--- a/plugins/withInCallAudioModule.js
+++ b/plugins/withInCallAudioModule.js
@@ -194,7 +194,7 @@ class InCallAudioPackage : ReactPackage {
/**
* Helper to resolve package name
*/
-function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') {
+function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.command') {
const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/;
const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle');
diff --git a/plugins/withMediaButtonModule.js b/plugins/withMediaButtonModule.js
index aec6d49..523ee53 100644
--- a/plugins/withMediaButtonModule.js
+++ b/plugins/withMediaButtonModule.js
@@ -382,10 +382,10 @@ class MediaButtonPackage : ReactPackage {
* - Kotlin DSL: namespace = "com.example.app"
*
* @param {string} projectRoot - The project root directory
- * @param {string} fallback - Fallback package name if not found (default: 'com.resgrid.unit')
+ * @param {string} fallback - Fallback package name if not found (default: 'com.resgrid.command')
* @returns {string} The resolved namespace or fallback
*/
-function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') {
+function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.command') {
// Regex that accepts optional equals sign for both Groovy and Kotlin DSL
const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/;
diff --git a/plugins/withNotificationIcon.js b/plugins/withNotificationIcon.js
new file mode 100644
index 0000000..2e2c92c
--- /dev/null
+++ b/plugins/withNotificationIcon.js
@@ -0,0 +1,72 @@
+const { withDangerousMod, withAndroidManifest, withAndroidColors, AndroidConfig } = require('expo/config-plugins');
+const { copyFileSync, existsSync, mkdirSync } = require('fs');
+const { resolve } = require('path');
+
+const ANDROID_RES_PATH = 'android/app/src/main/res/';
+
+/**
+ * Pre-rendered pure-white silhouette densities (assets/notification-icons/) — the status-bar
+ * small icon Android tints. Generated from assets/notification-icon.png.
+ */
+const DENSITIES = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi'];
+
+/** Brand tint applied by Android behind/around the white silhouette. */
+const NOTIFICATION_COLOR = '#2484c4';
+
+/** Copies the ic_notification drawable into every density bucket at prebuild. */
+function withNotificationIconDrawables(config) {
+ return withDangerousMod(config, [
+ 'android',
+ (config) => {
+ for (const density of DENSITIES) {
+ const source = resolve(config.modRequest.projectRoot, `assets/notification-icons/ic_notification-${density}.png`);
+ if (!existsSync(source)) {
+ throw new Error(`Notification icon asset missing: ${source}`);
+ }
+ const targetDir = resolve(config.modRequest.projectRoot, `${ANDROID_RES_PATH}drawable-${density}`);
+ if (!existsSync(targetDir)) {
+ mkdirSync(targetDir, { recursive: true });
+ }
+ copyFileSync(source, resolve(targetDir, 'ic_notification.png'));
+ }
+ return config;
+ },
+ ]);
+}
+
+/**
+ * Points FCM at the drawable + tint color so Android notifications (which bypass notifee's
+ * display path) use the branded silhouette instead of a gray launcher-icon blob.
+ */
+function withNotificationIconManifest(config) {
+ return withAndroidManifest(config, (config) => {
+ const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
+ mainApplication['meta-data'] = mainApplication['meta-data'] || [];
+
+ const upsert = (name, attrs) => {
+ const existing = mainApplication['meta-data'].find((item) => item.$['android:name'] === name);
+ if (existing) {
+ existing.$ = { 'android:name': name, ...attrs };
+ } else {
+ mainApplication['meta-data'].push({ $: { 'android:name': name, ...attrs } });
+ }
+ };
+
+ upsert('com.google.firebase.messaging.default_notification_icon', { 'android:resource': '@drawable/ic_notification' });
+ upsert('com.google.firebase.messaging.default_notification_color', { 'android:resource': '@color/notification_icon_color' });
+ return config;
+ });
+}
+
+/** Registers the tint color resource the manifest meta-data references. */
+function withNotificationIconColor(config) {
+ return withAndroidColors(config, (config) => {
+ config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
+ name: 'notification_icon_color',
+ value: NOTIFICATION_COLOR,
+ });
+ return config;
+ });
+}
+
+module.exports = (config) => withNotificationIconColor(withNotificationIconManifest(withNotificationIconDrawables(config)));
diff --git a/public/service-worker.js b/public/service-worker.js
index 0a650ff..f4e0efc 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,12 +1,12 @@
/* eslint-disable no-undef */
/* eslint-disable no-unused-vars */
/**
- * Service Worker for Resgrid Unit Web Push Notifications
+ * Service Worker for Resgrid IC Web Push Notifications
* This file handles background push notifications when the app is not in focus
*/
// Cache name for offline support (optional)
-const CACHE_NAME = 'resgrid-unit-v1';
+const CACHE_NAME = 'resgrid-ic-v1';
// Store pending notification data for newly opened windows
const pendingNotifications = new Map();
@@ -27,7 +27,7 @@ self.addEventListener('push', function (event) {
}
}
- const title = data.title || 'Resgrid Unit';
+ const title = data.title || 'Resgrid IC';
const options = {
body: data.body || data.message || 'You have a new notification',
icon: '/icon-192.png',
diff --git a/src/api/command/__tests__/board.test.ts b/src/api/command/__tests__/board.test.ts
new file mode 100644
index 0000000..c624477
--- /dev/null
+++ b/src/api/command/__tests__/board.test.ts
@@ -0,0 +1,64 @@
+import { createApiEndpoint } from '../../common/client';
+import { deleteNode, establishCommand, getCommandBoard, moveResource } from '../board';
+
+jest.mock('../../common/client');
+
+const mockCreate = createApiEndpoint as jest.MockedFunction;
+
+describe('command board api', () => {
+ const get = jest.fn();
+ const post = jest.fn();
+ const put = jest.fn();
+ const del = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCreate.mockReturnValue({ get, post, put, delete: del } as unknown as ReturnType);
+ });
+
+ it('getCommandBoard interpolates the callId into the route and returns the response data', async () => {
+ const payload = { Data: { Command: { CallId: 5 } } };
+ get.mockResolvedValue({ data: payload });
+
+ const result = await getCommandBoard(5);
+
+ expect(mockCreate).toHaveBeenCalledWith('/IncidentCommand/GetCommandBoard/5');
+ expect(get).toHaveBeenCalledTimes(1);
+ expect(result).toBe(payload);
+ });
+
+ it('establishCommand posts CallId with a null CommandDefinitionId when none is supplied', async () => {
+ post.mockResolvedValue({ data: { Data: null } });
+
+ await establishCommand(5);
+
+ expect(mockCreate).toHaveBeenCalledWith('/IncidentCommand/EstablishCommand');
+ expect(post).toHaveBeenCalledWith({ CallId: 5, CommandDefinitionId: null });
+ });
+
+ it('establishCommand forwards the template id when supplied', async () => {
+ post.mockResolvedValue({ data: { Data: null } });
+
+ await establishCommand(5, 9);
+
+ expect(post).toHaveBeenCalledWith({ CallId: 5, CommandDefinitionId: 9 });
+ });
+
+ it('deleteNode interpolates the node id into the route and issues a delete', async () => {
+ del.mockResolvedValue({ data: { Data: true } });
+
+ await deleteNode('node-1');
+
+ expect(mockCreate).toHaveBeenCalledWith('/IncidentCommand/DeleteNode/node-1');
+ expect(del).toHaveBeenCalledTimes(1);
+ });
+
+ it('moveResource posts the move input body', async () => {
+ post.mockResolvedValue({ data: { Data: null } });
+
+ await moveResource({ resourceAssignmentId: 'ra-1', targetNodeId: 'node-2' });
+
+ expect(mockCreate).toHaveBeenCalledWith('/IncidentCommand/MoveResource');
+ expect(post).toHaveBeenCalledWith({ ResourceAssignmentId: 'ra-1', TargetNodeId: 'node-2' });
+ });
+});
diff --git a/src/api/command/__tests__/sync.test.ts b/src/api/command/__tests__/sync.test.ts
new file mode 100644
index 0000000..c518f1c
--- /dev/null
+++ b/src/api/command/__tests__/sync.test.ts
@@ -0,0 +1,34 @@
+import { createApiEndpoint } from '../../common/client';
+import { getBundle } from '../sync';
+
+jest.mock('../../common/client');
+
+const mockCreate = createApiEndpoint as jest.MockedFunction;
+
+describe('command sync api', () => {
+ const get = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCreate.mockReturnValue({ get, post: jest.fn(), put: jest.fn(), delete: jest.fn() } as unknown as ReturnType);
+ });
+
+ it('getBundle requests /Sync/Bundle with includeAccountability=true by default', async () => {
+ const payload = { Data: { Boards: [] } };
+ get.mockResolvedValue({ data: payload });
+
+ const result = await getBundle();
+
+ expect(mockCreate).toHaveBeenCalledWith('/Sync/Bundle');
+ expect(get).toHaveBeenCalledWith({ includeAccountability: true });
+ expect(result).toBe(payload);
+ });
+
+ it('getBundle forwards includeAccountability=false', async () => {
+ get.mockResolvedValue({ data: { Data: null } });
+
+ await getBundle(false);
+
+ expect(get).toHaveBeenCalledWith({ includeAccountability: false });
+ });
+});
diff --git a/src/api/command/board.ts b/src/api/command/board.ts
new file mode 100644
index 0000000..388589a
--- /dev/null
+++ b/src/api/command/board.ts
@@ -0,0 +1,186 @@
+// API wrappers for the v4 IncidentCommand controller (the live command board).
+// Mirrors Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs.
+// NOTE: these endpoints use ROUTE params (e.g. GetCommandBoard/{callId}), so the id is
+// interpolated into the endpoint path rather than passed as a query param.
+
+import { type CommandStructureNode } from '@/models/v4/incidentCommand/commandStructureNode';
+import {
+ type CommandAccountabilityResult,
+ type CommandNodeResult,
+ type CommandTimelineResult,
+ type CommandTransferResult,
+ type EvaluateAccountabilityResult,
+ type IncidentCommandActionResult,
+ type IncidentCommandBoardResult,
+ type IncidentCommandResult,
+ type IncidentMapAnnotationResult,
+ type IncidentTimerResult,
+ type ResourceAssignmentResult,
+ type TacticalObjectiveResult,
+} from '@/models/v4/incidentCommand/incidentCommandResults';
+import { type IncidentMapAnnotation } from '@/models/v4/incidentCommand/incidentMapAnnotation';
+import { type IncidentTimer } from '@/models/v4/incidentCommand/incidentTimer';
+import { type ResourceAssignment } from '@/models/v4/incidentCommand/resourceAssignment';
+import { type TacticalObjective } from '@/models/v4/incidentCommand/tacticalObjective';
+
+import { createApiEndpoint } from '../common/client';
+
+const BASE = '/IncidentCommand';
+
+/** Coerce a typed entity into the loosely-typed body shape the api helper expects. */
+const asBody = (entity: object): Record => entity as unknown as Record;
+
+// ---------------------------------------------------------------------------
+// Reads
+// ---------------------------------------------------------------------------
+
+/** One-shot snapshot of the live command board for a call (lanes, assignments, objectives, timers, accountability, roles). */
+export const getCommandBoard = async (callId: number) => {
+ const response = await createApiEndpoint(`${BASE}/GetCommandBoard/${callId}`).get();
+ return response.data;
+};
+
+/** Personnel accountability / PAR list for a call. */
+export const getAccountability = async (callId: number) => {
+ const response = await createApiEndpoint(`${BASE}/GetAccountability/${callId}`).get();
+ return response.data;
+};
+
+/** Append-only ICS-201 timeline for a call. */
+export const getTimeline = async (callId: number) => {
+ const response = await createApiEndpoint(`${BASE}/GetTimeline/${callId}`).get();
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Command lifecycle
+// ---------------------------------------------------------------------------
+
+/** Establish command on a call, optionally seeding lanes from a CommandDefinition template. */
+export const establishCommand = async (callId: number, commandDefinitionId?: number | null) => {
+ const response = await createApiEndpoint(`${BASE}/EstablishCommand`).post({
+ CallId: callId,
+ CommandDefinitionId: commandDefinitionId ?? null,
+ });
+ return response.data;
+};
+
+/** Transfer Incident Commander to another user. */
+export const transferCommand = async (input: { incidentCommandId: string; toUserId: string; notes?: string }) => {
+ const response = await createApiEndpoint(`${BASE}/TransferCommand`).post({
+ IncidentCommandId: input.incidentCommandId,
+ ToUserId: input.toUserId,
+ Notes: input.notes ?? '',
+ });
+ return response.data;
+};
+
+/** Close the command instance for a call (auto-closes incident channels server-side). */
+export const closeCommand = async (incidentCommandId: string) => {
+ const response = await createApiEndpoint(`${BASE}/CloseCommand/${incidentCommandId}`).put({});
+ return response.data;
+};
+
+/** Update the incident action plan. */
+export const updateActionPlan = async (input: { incidentCommandId: string; actionPlan: string }) => {
+ const response = await createApiEndpoint(`${BASE}/UpdateActionPlan`).put({
+ IncidentCommandId: input.incidentCommandId,
+ ActionPlan: input.actionPlan,
+ });
+ return response.data;
+};
+
+/** Run an on-demand accountability sweep; returns userIds newly flagged Critical (PAR overdue). */
+export const evaluateAccountability = async (callId: number) => {
+ const response = await createApiEndpoint(`${BASE}/EvaluateAccountability/${callId}`).post({});
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Structure (lanes / span-of-control)
+// ---------------------------------------------------------------------------
+
+/** Create or update a command structure node (lane). */
+export const saveNode = async (node: CommandStructureNode) => {
+ const response = await createApiEndpoint(`${BASE}/SaveNode`).post(asBody(node));
+ return response.data;
+};
+
+/** Soft-delete a command structure node (lane). */
+export const deleteNode = async (commandStructureNodeId: string) => {
+ const response = await createApiEndpoint(`${BASE}/DeleteNode/${commandStructureNodeId}`).delete();
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Resources
+// ---------------------------------------------------------------------------
+
+/** Assign a resource (unit/personnel/ad-hoc) to a structure node. */
+export const assignResource = async (assignment: ResourceAssignment) => {
+ const response = await createApiEndpoint(`${BASE}/AssignResource`).post(asBody(assignment));
+ return response.data;
+};
+
+/** Move a resource assignment to a different node. */
+export const moveResource = async (input: { resourceAssignmentId: string; targetNodeId: string }) => {
+ const response = await createApiEndpoint(`${BASE}/MoveResource`).post({
+ ResourceAssignmentId: input.resourceAssignmentId,
+ TargetNodeId: input.targetNodeId,
+ });
+ return response.data;
+};
+
+/** Release a resource assignment from the board. */
+export const releaseResource = async (resourceAssignmentId: string) => {
+ const response = await createApiEndpoint(`${BASE}/ReleaseResource/${resourceAssignmentId}`).post({});
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Objectives
+// ---------------------------------------------------------------------------
+
+/** Create or update a tactical objective / benchmark. */
+export const saveObjective = async (objective: TacticalObjective) => {
+ const response = await createApiEndpoint(`${BASE}/SaveObjective`).post(asBody(objective));
+ return response.data;
+};
+
+/** Mark a tactical objective complete. */
+export const completeObjective = async (tacticalObjectiveId: string) => {
+ const response = await createApiEndpoint(`${BASE}/CompleteObjective/${tacticalObjectiveId}`).post({});
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Timers
+// ---------------------------------------------------------------------------
+
+/** Start a scene / benchmark / role timer. */
+export const startTimer = async (timer: IncidentTimer) => {
+ const response = await createApiEndpoint(`${BASE}/StartTimer`).post(asBody(timer));
+ return response.data;
+};
+
+/** Acknowledge a due timer. */
+export const acknowledgeTimer = async (incidentTimerId: string) => {
+ const response = await createApiEndpoint(`${BASE}/AcknowledgeTimer/${incidentTimerId}`).post({});
+ return response.data;
+};
+
+// ---------------------------------------------------------------------------
+// Map annotations
+// ---------------------------------------------------------------------------
+
+/** Create or update a tactical-map annotation. */
+export const saveAnnotation = async (annotation: IncidentMapAnnotation) => {
+ const response = await createApiEndpoint(`${BASE}/SaveAnnotation`).post(asBody(annotation));
+ return response.data;
+};
+
+/** Soft-delete a tactical-map annotation. */
+export const deleteAnnotation = async (incidentMapAnnotationId: string) => {
+ const response = await createApiEndpoint(`${BASE}/DeleteAnnotation/${incidentMapAnnotationId}`).delete();
+ return response.data;
+};
diff --git a/src/api/command/sync.ts b/src/api/command/sync.ts
new file mode 100644
index 0000000..af6af85
--- /dev/null
+++ b/src/api/command/sync.ts
@@ -0,0 +1,16 @@
+// API wrappers for the v4 Sync controller (offline / shift-start aggregates).
+// Mirrors Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs.
+// NOTE: unlike the IncidentCommand controller, Sync endpoints take QUERY params.
+
+import { type SyncBundleResult } from '@/models/v4/incidentCommand/incidentCommandResults';
+
+import { createApiEndpoint } from '../common/client';
+
+/**
+ * Shift-start aggregate: one render-ready board (incl. computed accountability / PAR) per ACTIVE incident
+ * in the caller's department, plus active ad-hoc resources and the next-sync cursor, in a single call.
+ */
+export const getBundle = async (includeAccountability = true) => {
+ const response = await createApiEndpoint('/Sync/Bundle').get({ includeAccountability });
+ return response.data;
+};
diff --git a/src/api/common/cached-client.ts b/src/api/common/cached-client.ts
index 3e24a45..3398a2c 100644
--- a/src/api/common/cached-client.ts
+++ b/src/api/common/cached-client.ts
@@ -9,6 +9,15 @@ interface CacheConfig {
enabled?: boolean; // Whether to use cache for this endpoint
}
+const buildCachedResponse = (data: T, stale = false): AxiosResponse =>
+ ({
+ data,
+ status: 200,
+ statusText: stale ? 'OK (stale cache)' : 'OK (cached)',
+ headers: {},
+ config: {},
+ }) as AxiosResponse;
+
export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConfig = { enabled: true }) => {
const api = createApiEndpoint(endpoint);
const defaultTTL = 5 * 60 * 1000; // 5 minutes
@@ -21,18 +30,22 @@ export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConf
const cached = cacheManager.get(endpoint, params);
if (cached) {
- return Promise.resolve({
- data: cached,
- status: 200,
- statusText: 'OK (cached)',
- headers: {},
- config: {},
- } as AxiosResponse);
+ return buildCachedResponse(cached);
}
- const response = await api.get(params);
- cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL);
- return response;
+ try {
+ const response = await api.get(params);
+ cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL);
+ return response;
+ } catch (error) {
+ // Offline / request failed: fall back to the last cached value even if
+ // its TTL has expired, so read paths keep working without a connection.
+ const stale = cacheManager.getStale(endpoint, params);
+ if (stale !== null) {
+ return buildCachedResponse(stale, true);
+ }
+ throw error;
+ }
},
post: api.post,
put: api.put,
diff --git a/src/api/devices/push.ts b/src/api/devices/push.ts
index 7be8a32..7fa23d6 100644
--- a/src/api/devices/push.ts
+++ b/src/api/devices/push.ts
@@ -1,12 +1,12 @@
+import { type PushRegistrationInput } from '@/models/v4/device/pushRegistrationInput';
import { type PushRegistrationResult } from '@/models/v4/device/pushRegistrationResult';
-import { type PushRegistrationUnitInput } from '@/models/v4/device/pushRegistrationUnitInput';
import { createApiEndpoint } from '../common/client';
-const registerUnitDeviceApi = createApiEndpoint('/Devices/RegisterUnitDevice');
+const registerDeviceApi = createApiEndpoint('/Devices/RegisterDevice');
-export const registerUnitDevice = async (data: PushRegistrationUnitInput) => {
- const response = await registerUnitDeviceApi.post({
+export const registerDevice = async (data: PushRegistrationInput) => {
+ const response = await registerDeviceApi.post({
...data,
});
return response.data;
diff --git a/src/api/incidentCommand/commands.ts b/src/api/incidentCommand/commands.ts
new file mode 100644
index 0000000..1557549
--- /dev/null
+++ b/src/api/incidentCommand/commands.ts
@@ -0,0 +1,58 @@
+import { createApiEndpoint } from '../common/client';
+
+/**
+ * Command definition templates (CommandsController) — department-defined lane sets
+ * per call type. Lets a security company or event operator pre-build boards like
+ * "Concert" with Medical Booth / Patrols / Customer Service lanes.
+ */
+
+export interface CommandRoleResultData {
+ CommandDefinitionRoleId: number;
+ Name: string;
+ Description?: string | null;
+ LaneType: number;
+ SortOrder: number;
+ MinUnitPersonnel: number;
+ MaxUnitPersonnel: number;
+ MinUnits: number;
+ MaxUnits: number;
+ MinTimeInRole: number;
+ MaxTimeInRole: number;
+ /** Lane identification color (hex); board lanes and marker tints inherit it. */
+ Color?: string | null;
+ ForceRequirements: boolean;
+ RequiredUnitTypes: number[];
+ RequiredPersonnelRoles: number[];
+}
+
+export interface CommandResultData {
+ CommandDefinitionId: number;
+ CallTypeId?: number | null;
+ Name: string;
+ Description?: string | null;
+ Timer: boolean;
+ TimerMinutes: number;
+ Lanes: CommandRoleResultData[];
+}
+
+interface V4Result {
+ Data: T;
+ Status?: string;
+ Message?: string;
+}
+
+export const getAllCommands = async () => {
+ const response = await createApiEndpoint('/Commands/GetAllCommands').get>();
+ return response.data;
+};
+
+export const getCommand = async (commandDefinitionId: number) => {
+ const response = await createApiEndpoint(`/Commands/GetCommand/${commandDefinitionId}`).get>();
+ return response.data;
+};
+
+/** Pass callTypeId 0 for the "Any Call Type" default template. */
+export const getCommandForCallType = async (callTypeId: number) => {
+ const response = await createApiEndpoint(`/Commands/GetCommandForCallType/${callTypeId}`).get>();
+ return response.data;
+};
diff --git a/src/api/incidentCommand/incidentCommand.ts b/src/api/incidentCommand/incidentCommand.ts
new file mode 100644
index 0000000..2ba3961
--- /dev/null
+++ b/src/api/incidentCommand/incidentCommand.ts
@@ -0,0 +1,123 @@
+import type {
+ CommandAccountabilityResult,
+ CommandNodeResult,
+ CommandStructureNode,
+ CommandTimelineResult,
+ CommandTransferResult,
+ EstablishCommandInput,
+ EvaluateAccountabilityResult,
+ IncidentCommandActionResult,
+ IncidentCommandBoardResult,
+ IncidentCommandResult,
+ IncidentMapAnnotation,
+ IncidentMapAnnotationResult,
+ IncidentTimer,
+ IncidentTimerResult,
+ MoveResourceInput,
+ ResourceAssignment,
+ ResourceAssignmentResult,
+ TacticalObjective,
+ TacticalObjectiveResult,
+ TransferCommandInput,
+ UpdateActionPlanInput,
+} from '@/models/v4/incidentCommand/incidentCommandModels';
+
+import { createApiEndpoint } from '../common/client';
+
+// Core routes use path parameters (e.g. GetCommandBoard/{callId}), so endpoints
+// with a path parameter are created per call.
+
+export const establishCommand = async (input: EstablishCommandInput) => {
+ const response = await createApiEndpoint('/IncidentCommand/EstablishCommand').post({ ...input });
+ return response.data;
+};
+
+export const getCommandBoard = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentCommand/GetCommandBoard/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const transferCommand = async (input: TransferCommandInput) => {
+ const response = await createApiEndpoint('/IncidentCommand/TransferCommand').post({ ...input });
+ return response.data;
+};
+
+export const closeCommand = async (incidentCommandId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/CloseCommand/${encodeURIComponent(incidentCommandId)}`).put({});
+ return response.data;
+};
+
+export const updateActionPlan = async (input: UpdateActionPlanInput) => {
+ const response = await createApiEndpoint('/IncidentCommand/UpdateActionPlan').put({ ...input });
+ return response.data;
+};
+
+export const getAccountability = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentCommand/GetAccountability/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const evaluateAccountability = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentCommand/EvaluateAccountability/${encodeURIComponent(String(callId))}`).post({});
+ return response.data;
+};
+
+export const saveCommandNode = async (node: Partial) => {
+ const response = await createApiEndpoint('/IncidentCommand/SaveNode').post({ ...node });
+ return response.data;
+};
+
+export const deleteCommandNode = async (commandStructureNodeId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/DeleteNode/${encodeURIComponent(commandStructureNodeId)}`).delete();
+ return response.data;
+};
+
+export const assignResource = async (assignment: Partial) => {
+ const response = await createApiEndpoint('/IncidentCommand/AssignResource').post({ ...assignment });
+ return response.data;
+};
+
+export const moveResource = async (input: MoveResourceInput) => {
+ const response = await createApiEndpoint('/IncidentCommand/MoveResource').post({ ...input });
+ return response.data;
+};
+
+export const releaseResource = async (resourceAssignmentId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/ReleaseResource/${encodeURIComponent(resourceAssignmentId)}`).post({});
+ return response.data;
+};
+
+export const saveObjective = async (objective: Partial) => {
+ const response = await createApiEndpoint('/IncidentCommand/SaveObjective').post({ ...objective });
+ return response.data;
+};
+
+export const completeObjective = async (tacticalObjectiveId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/CompleteObjective/${encodeURIComponent(tacticalObjectiveId)}`).post({});
+ return response.data;
+};
+
+export const startIncidentTimer = async (timer: Partial) => {
+ const response = await createApiEndpoint('/IncidentCommand/StartTimer').post({ ...timer });
+ return response.data;
+};
+
+export const acknowledgeIncidentTimer = async (incidentTimerId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/AcknowledgeTimer/${encodeURIComponent(incidentTimerId)}`).post({});
+ return response.data;
+};
+
+export const saveMapAnnotation = async (annotation: Partial) => {
+ const response = await createApiEndpoint('/IncidentCommand/SaveAnnotation').post({ ...annotation });
+ return response.data;
+};
+
+export const deleteMapAnnotation = async (incidentMapAnnotationId: string) => {
+ const response = await createApiEndpoint(`/IncidentCommand/DeleteAnnotation/${encodeURIComponent(incidentMapAnnotationId)}`).delete();
+ return response.data;
+};
+
+export const getCommandTimeline = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentCommand/GetTimeline/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
diff --git a/src/api/incidentCommand/incidentResources.ts b/src/api/incidentCommand/incidentResources.ts
new file mode 100644
index 0000000..cb8815c
--- /dev/null
+++ b/src/api/incidentCommand/incidentResources.ts
@@ -0,0 +1,66 @@
+import type {
+ AdHocPersonnelListResult,
+ AdHocPersonnelResult,
+ AdHocUnitResult,
+ AdHocUnitsResult,
+ IncidentAdHocPersonnel,
+ IncidentAdHocUnit,
+ IncidentCommandActionResult,
+} from '@/models/v4/incidentCommand/incidentCommandModels';
+
+import { createApiEndpoint } from '../common/client';
+
+export interface AssignPersonnelToUnitInput {
+ IncidentAdHocPersonnelId: string;
+ RidingResourceKind: number;
+ RidingResourceId: string;
+}
+
+export interface FormUnitInput {
+ CallId: number;
+ Name: string;
+ Type?: string;
+ UnitTypeId?: number | null;
+ ExternalAgencyName?: string;
+ AdHocPersonnelIds: string[];
+}
+
+export const createAdHocUnit = async (unit: Partial) => {
+ const response = await createApiEndpoint('/IncidentResources/CreateAdHocUnit').post({ ...unit });
+ return response.data;
+};
+
+export const getAdHocUnits = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentResources/GetAdHocUnits/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const releaseAdHocUnit = async (incidentAdHocUnitId: string) => {
+ const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocUnit/${encodeURIComponent(incidentAdHocUnitId)}`).post({});
+ return response.data;
+};
+
+export const createAdHocPersonnel = async (personnel: Partial) => {
+ const response = await createApiEndpoint('/IncidentResources/CreateAdHocPersonnel').post({ ...personnel });
+ return response.data;
+};
+
+export const getAdHocPersonnel = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentResources/GetAdHocPersonnel/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const releaseAdHocPersonnel = async (incidentAdHocPersonnelId: string) => {
+ const response = await createApiEndpoint(`/IncidentResources/ReleaseAdHocPersonnel/${encodeURIComponent(incidentAdHocPersonnelId)}`).post({});
+ return response.data;
+};
+
+export const assignPersonnelToUnit = async (input: AssignPersonnelToUnitInput) => {
+ const response = await createApiEndpoint('/IncidentResources/AssignPersonnelToUnit').post({ ...input });
+ return response.data;
+};
+
+export const formUnit = async (input: FormUnitInput) => {
+ const response = await createApiEndpoint('/IncidentResources/FormUnit').post({ ...input });
+ return response.data;
+};
diff --git a/src/api/incidentCommand/incidentRoles.ts b/src/api/incidentCommand/incidentRoles.ts
new file mode 100644
index 0000000..6f97fa9
--- /dev/null
+++ b/src/api/incidentCommand/incidentRoles.ts
@@ -0,0 +1,23 @@
+import type { IncidentCapabilitiesResult, IncidentCommandActionResult, IncidentRoleAssignment, IncidentRoleResult, IncidentRolesResult } from '@/models/v4/incidentCommand/incidentCommandModels';
+
+import { createApiEndpoint } from '../common/client';
+
+export const assignIncidentRole = async (assignment: Partial) => {
+ const response = await createApiEndpoint('/IncidentRoles/AssignRole').post({ ...assignment });
+ return response.data;
+};
+
+export const removeIncidentRole = async (incidentRoleAssignmentId: string) => {
+ const response = await createApiEndpoint(`/IncidentRoles/RemoveRole/${encodeURIComponent(incidentRoleAssignmentId)}`).post({});
+ return response.data;
+};
+
+export const getIncidentRoles = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentRoles/GetRoles/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const getMyIncidentCapabilities = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentRoles/GetMyCapabilities/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
diff --git a/src/api/incidentCommand/incidentVoice.ts b/src/api/incidentCommand/incidentVoice.ts
new file mode 100644
index 0000000..63e5b8b
--- /dev/null
+++ b/src/api/incidentCommand/incidentVoice.ts
@@ -0,0 +1,40 @@
+import type { IncidentCommandActionResult, IncidentVoiceChannelResult, IncidentVoiceChannelsResult, VoiceTransmissionLogResult, VoiceTransmissionLogsResult } from '@/models/v4/incidentCommand/incidentCommandModels';
+
+import { createApiEndpoint } from '../common/client';
+
+export interface CreateIncidentChannelInput {
+ CallId: number;
+ Name: string;
+}
+
+export interface LogTransmissionInput {
+ CallId: number;
+ DepartmentVoiceChannelId: string;
+ StartedOn: string;
+ EndedOn?: string;
+}
+
+export const createIncidentChannel = async (input: CreateIncidentChannelInput) => {
+ const response = await createApiEndpoint('/IncidentVoice/CreateIncidentChannel').post({ ...input });
+ return response.data;
+};
+
+export const getChannelsForCall = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentVoice/GetChannelsForCall/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
+
+export const closeIncidentChannels = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentVoice/CloseIncidentChannels/${encodeURIComponent(String(callId))}`).post({});
+ return response.data;
+};
+
+export const logTransmission = async (input: LogTransmissionInput) => {
+ const response = await createApiEndpoint('/IncidentVoice/LogTransmission').post({ ...input });
+ return response.data;
+};
+
+export const getTransmissionLog = async (callId: string | number) => {
+ const response = await createApiEndpoint(`/IncidentVoice/GetTransmissionLog/${encodeURIComponent(String(callId))}`).get();
+ return response.data;
+};
diff --git a/src/api/incidentCommand/sync.ts b/src/api/incidentCommand/sync.ts
new file mode 100644
index 0000000..71927e0
--- /dev/null
+++ b/src/api/incidentCommand/sync.ts
@@ -0,0 +1,31 @@
+import type { SyncBundleResult, SyncChangesResult, SyncReferenceResult } from '@/models/v4/incidentCommand/incidentCommandModels';
+
+import { createApiEndpoint } from '../common/client';
+
+/**
+ * Row-based delta pull of every change-tracked incident-command row modified
+ * since the cursor. Pass 0 (or omit) for a full pull. Soft-deleted / closed /
+ * released rows are included so the client can remove them locally.
+ */
+export const getSyncChanges = async (sinceUnixEpochMs = 0) => {
+ const response = await createApiEndpoint('/Sync/Changes').get({ since: sinceUnixEpochMs });
+ return response.data;
+};
+
+/**
+ * Shift-start aggregate: one render-ready IncidentCommandBoard (including
+ * computed PAR/accountability) per active incident + active ad-hoc resources.
+ */
+export const getSyncBundle = async (includeAccountability = true) => {
+ const response = await createApiEndpoint('/Sync/Bundle').get({ includeAccountability });
+ return response.data;
+};
+
+/**
+ * Slowly-changing department reference data (call types, priorities, command
+ * templates, units, groups, POIs, personnel roster, ...) for offline start.
+ */
+export const getSyncReference = async (bypassCache = false) => {
+ const response = await createApiEndpoint('/Sync/Reference').get({ bypassCache });
+ return response.data;
+};
diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts
index 06548eb..269de84 100644
--- a/src/api/mapping/mapping.ts
+++ b/src/api/mapping/mapping.ts
@@ -23,10 +23,6 @@ import { createApiEndpoint } from '../common/client';
const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers');
const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers');
-const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', {
- ttl: 5 * 60 * 1000,
- enabled: true,
-});
const getPoisApi = createCachedApiEndpoint('/Mapping/GetPois', {
ttl: 5 * 60 * 1000,
enabled: true,
@@ -41,9 +37,6 @@ const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', {
ttl: 5 * 60 * 1000,
enabled: true,
});
-const getIndoorMapApi = createApiEndpoint('/Mapping/GetIndoorMap');
-const getIndoorMapFloorApi = createApiEndpoint('/Mapping/GetIndoorMapFloor');
-const getIndoorMapZonesGeoJSONApi = createApiEndpoint('/Mapping/GetIndoorMapZonesGeoJSON');
const searchIndoorLocationsApi = createApiEndpoint('/Mapping/SearchIndoorLocations');
const getNearbyIndoorMapsApi = createApiEndpoint('/Mapping/GetNearbyIndoorMaps');
@@ -52,12 +45,20 @@ const getCustomMapsApi = createCachedApiEndpoint('/Mapping/GetCustomMaps', {
ttl: 5 * 60 * 1000,
enabled: true,
});
-const getCustomMapApi = createApiEndpoint('/Mapping/GetCustomMap');
-const getCustomMapLayerApi = createApiEndpoint('/Mapping/GetCustomMapLayer');
-const getMapLayerGeoJSONApi = createApiEndpoint('/Mapping/GetMapLayerGeoJSON');
-const getCustomMapRegionsGeoJSONApi = createApiEndpoint('/Mapping/GetCustomMapRegionsGeoJSON');
const searchCustomMapRegionsApi = createApiEndpoint('/Mapping/SearchCustomMapRegions');
+/**
+ * The GeoJSON endpoints return a raw `application/geo+json` FeatureCollection body
+ * (not the standard v4 `{ Data: ... }` envelope). Accept either shape.
+ */
+const unwrapGeoJSON = (body: unknown): FeatureCollection => {
+ const asAny = body as { type?: string; Data?: FeatureCollection };
+ if (asAny?.type === 'FeatureCollection') {
+ return body as FeatureCollection;
+ }
+ return asAny?.Data ?? { type: 'FeatureCollection', features: [] };
+};
+
// Discovery endpoints
const getAllActiveLayersApi = createCachedApiEndpoint('/Mapping/GetAllActiveLayers', {
ttl: 5 * 60 * 1000,
@@ -91,9 +92,7 @@ export const getPois = async (poiTypeId?: number, destinationOnly?: boolean) =>
};
export const getPoi = async (poiId: number | string) => {
- const response = await getPoiApi.get({
- poiId: encodeURIComponent(String(poiId)),
- });
+ const response = await createCachedApiEndpoint(`/Mapping/GetPoi/${encodeURIComponent(String(poiId))}`, { ttl: 5 * 60 * 1000, enabled: true }).get();
return response.data;
};
@@ -115,24 +114,18 @@ export const getIndoorMaps = async () => {
};
export const getIndoorMap = async (mapId: string) => {
- const response = await getIndoorMapApi.get({
- id: encodeURIComponent(mapId),
- });
+ const response = await createApiEndpoint(`/Mapping/GetIndoorMap/${encodeURIComponent(mapId)}`).get();
return response.data;
};
export const getIndoorMapFloor = async (floorId: string) => {
- const response = await getIndoorMapFloorApi.get({
- floorId: encodeURIComponent(floorId),
- });
+ const response = await createApiEndpoint(`/Mapping/GetIndoorMapFloor/${encodeURIComponent(floorId)}`).get();
return response.data;
};
-export const getIndoorMapZonesGeoJSON = async (floorId: string) => {
- const response = await getIndoorMapZonesGeoJSONApi.get({
- floorId: encodeURIComponent(floorId),
- });
- return response.data;
+export const getIndoorMapZonesGeoJSON = async (floorId: string): Promise => {
+ const response = await createApiEndpoint(`/Mapping/GetIndoorMapZonesGeoJSON/${encodeURIComponent(floorId)}`).get();
+ return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult;
};
export const searchIndoorLocations = async (term: string, mapId?: string) => {
@@ -165,31 +158,23 @@ export const getCustomMaps = async (type?: number) => {
};
export const getCustomMap = async (mapId: string) => {
- const response = await getCustomMapApi.get({
- id: encodeURIComponent(mapId),
- });
+ const response = await createApiEndpoint(`/Mapping/GetCustomMap/${encodeURIComponent(mapId)}`).get();
return response.data;
};
export const getCustomMapLayer = async (layerId: string) => {
- const response = await getCustomMapLayerApi.get({
- layerId: encodeURIComponent(layerId),
- });
+ const response = await createApiEndpoint(`/Mapping/GetCustomMapLayer/${encodeURIComponent(layerId)}`).get();
return response.data;
};
-export const getMapLayerGeoJSON = async (layerId: string) => {
- const response = await getMapLayerGeoJSONApi.get({
- layerId: encodeURIComponent(layerId),
- });
- return response.data;
+export const getMapLayerGeoJSON = async (layerId: string): Promise => {
+ const response = await createApiEndpoint(`/Mapping/GetMapLayerGeoJSON/${encodeURIComponent(layerId)}`).get();
+ return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult;
};
-export const getCustomMapRegionsGeoJSON = async (layerId: string) => {
- const response = await getCustomMapRegionsGeoJSONApi.get({
- layerId: encodeURIComponent(layerId),
- });
- return response.data;
+export const getCustomMapRegionsGeoJSON = async (layerId: string): Promise => {
+ const response = await createApiEndpoint(`/Mapping/GetCustomMapRegionsGeoJSON/${encodeURIComponent(layerId)}`).get();
+ return { Data: unwrapGeoJSON(response.data) } as GetGeoJSONResult;
};
export const searchCustomMapRegions = async (term: string, layerId?: string) => {
diff --git a/src/api/routes/routes.ts b/src/api/routes/routes.ts
deleted file mode 100644
index d04cdd9..0000000
--- a/src/api/routes/routes.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { cacheManager } from '@/lib/cache/cache-manager';
-import { type ContactResult } from '@/models/v4/contacts/contactResult';
-import { type ContactsResult } from '@/models/v4/contacts/contactsResult';
-import {
- type CancelRouteInput,
- type CheckInInput,
- type CheckOutInput,
- type EndRouteInput,
- type GeofenceCheckInInput,
- type PauseRouteInput,
- type ResumeRouteInput,
- type SkipStopInput,
- type StartRouteInput,
- type UpdateStopNotesInput,
-} from '@/models/v4/routes/routeInputs';
-import {
- type GetActiveRouteForUnitResult,
- type GetActiveRouteInstancesResult,
- type GetDirectionsResult,
- type GetRouteDeviationsResult,
- type GetRouteInstanceResult,
- type GetRouteInstancesResult,
- type GetRouteInstanceStopsResult,
- type GetRoutePlanResult,
- type GetRoutePlansResult,
- type GetRouteProgressResult,
- type GetScheduledRoutesResult,
- type SaveRoutePlanResult,
-} from '@/models/v4/routes/routeResults';
-
-import { createCachedApiEndpoint } from '../common/cached-client';
-import { api, createApiEndpoint } from '../common/client';
-
-// Route plan endpoints
-const getRoutePlansApi = createCachedApiEndpoint('/Routes/GetRoutePlans', {
- ttl: 60 * 1000,
- enabled: true,
-});
-
-// Route lifecycle endpoints (no path params)
-const startRouteApi = createApiEndpoint('/Routes/StartRoute');
-const endRouteApi = createApiEndpoint('/Routes/EndRoute');
-const pauseRouteApi = createApiEndpoint('/Routes/PauseRoute');
-const resumeRouteApi = createApiEndpoint('/Routes/ResumeRoute');
-const cancelRouteApi = createApiEndpoint('/Routes/CancelRoute');
-
-// Instance tracking endpoints (no path params)
-const getActiveRoutesApi = createApiEndpoint('/Routes/GetActiveRoutes');
-
-// Stop interaction endpoints (no path params)
-const checkInAtStopApi = createApiEndpoint('/Routes/CheckInAtStop');
-const checkOutFromStopApi = createApiEndpoint('/Routes/CheckOutFromStop');
-const skipStopApi = createApiEndpoint('/Routes/SkipStop');
-const geofenceCheckInApi = createApiEndpoint('/Routes/GeofenceCheckIn');
-const updateStopNotesApi = createApiEndpoint('/Routes/UpdateStopNotes');
-
-// Deviation endpoints (no path params)
-const getUnacknowledgedDeviationsApi = createApiEndpoint('/Routes/GetUnacknowledgedDeviations');
-
-// Fleet monitoring endpoints (no path params)
-const getActiveRouteInstancesApi = createApiEndpoint('/Routes/GetActiveRouteInstances');
-const getScheduledRoutesApi = createApiEndpoint('/Routes/GetScheduledRoutes');
-
-// --- Route Plans ---
-
-export const getRoutePlans = async () => {
- const response = await getRoutePlansApi.get();
- return response.data;
-};
-
-export const getRoutePlansForUnit = async (unitId: string) => {
- const response = await api.get(`/Routes/GetRoutePlansForUnit/${encodeURIComponent(unitId)}`);
- return response.data;
-};
-
-export const getRoutePlan = async (routePlanId: string) => {
- const response = await api.get(`/Routes/GetRoutePlan/${encodeURIComponent(routePlanId)}`);
- return response.data;
-};
-
-// --- Route Lifecycle ---
-
-export const startRoute = async (input: StartRouteInput) => {
- const response = await startRouteApi.post(input as unknown as Record);
- cacheManager.remove('/Routes/GetRoutePlans');
- return response.data;
-};
-
-export const endRoute = async (input: EndRouteInput) => {
- const response = await endRouteApi.post(input as unknown as Record);
- cacheManager.remove('/Routes/GetRoutePlans');
- return response.data;
-};
-
-export const pauseRoute = async (input: PauseRouteInput) => {
- const response = await pauseRouteApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const resumeRoute = async (input: ResumeRouteInput) => {
- const response = await resumeRouteApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const cancelRoute = async (input: CancelRouteInput) => {
- const response = await cancelRouteApi.post(input as unknown as Record);
- cacheManager.remove('/Routes/GetRoutePlans');
- return response.data;
-};
-
-// --- Instance Tracking ---
-
-export const getActiveRouteForUnit = async (unitId: string) => {
- const response = await api.get(`/Routes/GetActiveRouteForUnit/${encodeURIComponent(unitId)}`);
- return response.data;
-};
-
-export const getActiveRoutes = async () => {
- const response = await getActiveRoutesApi.get();
- return response.data;
-};
-
-export const getRouteProgress = async (instanceId: string) => {
- const response = await api.get(`/Routes/GetRouteProgress/${encodeURIComponent(instanceId)}`);
- return response.data;
-};
-
-export const getStopsForInstance = async (instanceId: string) => {
- const response = await api.get(`/Routes/GetStopsForInstance/${encodeURIComponent(instanceId)}`);
- return response.data;
-};
-
-export const getInstanceDirections = async (instanceId: string) => {
- const response = await api.get(`/Routes/GetInstanceDirections/${encodeURIComponent(instanceId)}`);
- return response.data;
-};
-
-export const getDirections = async (routePlanId: string) => {
- const response = await api.get(`/Routes/GetDirections/${encodeURIComponent(routePlanId)}`);
- return response.data;
-};
-
-// --- Stop Interactions ---
-
-export const checkInAtStop = async (input: CheckInInput) => {
- const response = await checkInAtStopApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const checkOutFromStop = async (input: CheckOutInput) => {
- const response = await checkOutFromStopApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const skipStop = async (input: SkipStopInput) => {
- const response = await skipStopApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const geofenceCheckIn = async (input: GeofenceCheckInInput) => {
- const response = await geofenceCheckInApi.post(input as unknown as Record);
- return response.data;
-};
-
-export const updateStopNotes = async (input: UpdateStopNotesInput) => {
- const response = await updateStopNotesApi.post(input as unknown as Record);
- return response.data;
-};
-
-// --- Contacts ---
-
-export const getStopContact = async (routeStopId: string) => {
- const response = await api.get(`/Routes/GetStopContact/${encodeURIComponent(routeStopId)}`);
- return response.data;
-};
-
-export const getRouteContacts = async (routePlanId: string) => {
- const response = await api.get(`/Routes/GetRouteContacts/${encodeURIComponent(routePlanId)}`);
- return response.data;
-};
-
-// --- Deviations ---
-
-export const getUnacknowledgedDeviations = async () => {
- const response = await getUnacknowledgedDeviationsApi.get();
- return response.data;
-};
-
-export const acknowledgeDeviation = async (deviationId: string) => {
- const response = await api.post(`/Routes/AcknowledgeDeviation/${encodeURIComponent(deviationId)}`, {});
- return response.data;
-};
-
-// --- History ---
-
-export const getRouteHistory = async (routePlanId: string) => {
- const response = await api.get(`/Routes/GetRouteHistory/${encodeURIComponent(routePlanId)}`);
- return response.data;
-};
-
-// --- Fleet Monitoring ---
-
-export const getActiveRouteInstances = async () => {
- const response = await getActiveRouteInstancesApi.get();
- return response.data;
-};
-
-export const getScheduledRoutes = async () => {
- const response = await getScheduledRoutesApi.get();
- return response.data;
-};
diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx
index a8d5f3d..e3d85b5 100644
--- a/src/app/(app)/__tests__/calls.test.tsx
+++ b/src/app/(app)/__tests__/calls.test.tsx
@@ -3,6 +3,10 @@ import { router } from 'expo-router';
import React from 'react';
// Mock Platform before any other imports
+jest.mock('@/components/command/start-command-sheet', () => ({
+ StartCommandSheet: () => null,
+}));
+
jest.mock('react-native', () => ({
Platform: {
OS: 'ios',
diff --git a/src/app/(app)/__tests__/command.test.tsx b/src/app/(app)/__tests__/command.test.tsx
new file mode 100644
index 0000000..a8ff5ec
--- /dev/null
+++ b/src/app/(app)/__tests__/command.test.tsx
@@ -0,0 +1,437 @@
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { Dimensions } from 'react-native';
+
+const mockPush = jest.fn();
+
+jest.mock('expo-router', () => ({
+ router: { push: (...args: unknown[]) => mockPush(...args) },
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` });
+ return {
+ AlarmClock: icon('alarm-clock'),
+ Check: icon('check'),
+ ClipboardList: icon('clipboard-list'),
+ CloudOff: icon('cloud-off'),
+ ExternalLink: icon('external-link'),
+ GripVertical: icon('grip-vertical'),
+ MapPin: icon('map-pin'),
+ Mic: icon('mic'),
+ MicOff: icon('mic-off'),
+ Phone: icon('phone'),
+ PhoneOff: icon('phone-off'),
+ RadioTower: icon('radio-tower'),
+ Plus: icon('plus'),
+ RefreshCw: icon('refresh'),
+ Trash2: icon('trash'),
+ Truck: icon('truck'),
+ UserCog: icon('user-cog'),
+ UserPlus: icon('user-plus'),
+ XCircle: icon('x-circle'),
+ };
+});
+
+jest.mock('@/components/ui/focus-aware-status-bar', () => ({
+ FocusAwareStatusBar: () => null,
+}));
+
+jest.mock('@/components/ui/html-renderer', () => ({
+ HtmlRenderer: () => null,
+}));
+
+// AnimatePresence-driven dialog never mounts under jest — flatten to plain views
+jest.mock('@/components/ui/alert-dialog', () => {
+ const React = require('react');
+ const passthrough = (name: string) => (props: any) => React.createElement('View', { ...props, testID: props.testID ?? `mock-${name}` }, props.children);
+ return {
+ AlertDialog: ({ children, isOpen }: any) => (isOpen ? children : null),
+ AlertDialogBackdrop: () => null,
+ AlertDialogContent: passthrough('alert-content'),
+ AlertDialogHeader: passthrough('alert-header'),
+ AlertDialogBody: passthrough('alert-body'),
+ AlertDialogFooter: passthrough('alert-footer'),
+ };
+});
+
+jest.mock('@/components/command/add-assignment-sheet', () => ({
+ AddAssignmentSheet: () => null,
+}));
+
+jest.mock('@/components/command/add-resource-sheet', () => ({
+ AddResourceSheet: () => null,
+}));
+
+jest.mock('@/lib/utils', () => ({
+ ...jest.requireActual('@/lib/utils'),
+ getTimeAgoUtc: jest.fn(() => '5 minutes ago'),
+}));
+
+jest.mock('@/stores/app/core-store', () => ({
+ useCoreStore: jest.fn(),
+}));
+
+jest.mock('@/stores/calls/store', () => ({
+ useCallsStore: jest.fn(),
+}));
+
+jest.mock('@/stores/command/store', () => ({
+ useCommandStore: jest.fn(),
+}));
+
+jest.mock('@/stores/roles/store', () => ({
+ useRolesStore: jest.fn(),
+}));
+
+import { useCoreStore } from '@/stores/app/core-store';
+import { useCallsStore } from '@/stores/calls/store';
+import { useCommandStore } from '@/stores/command/store';
+import { useRolesStore } from '@/stores/roles/store';
+
+import CommandBoard from '../command';
+
+const mockUseCoreStore = useCoreStore as unknown as jest.Mock;
+const mockUseCallsStore = useCallsStore as unknown as jest.Mock;
+const mockUseCommandStore = useCommandStore as unknown as jest.Mock;
+const mockUseRolesStore = useRolesStore as unknown as jest.Mock;
+
+const mockSwitchCommand = jest.fn();
+const mockEndCommand = jest.fn();
+const mockRefreshBoard = jest.fn();
+const mockRemoveRole = jest.fn();
+const mockMoveResourceAssignment = jest.fn();
+const mockReleaseResourceAssignment = jest.fn();
+const mockFetchTimeline = jest.fn();
+const mockRefreshAccountability = jest.fn();
+
+const serverBoard = (callId: string, overrides: Record = {}) => ({
+ callId,
+ board: {
+ Command: { IncidentCommandId: `cmd-${callId}`, DepartmentId: 1, CallId: parseInt(callId, 10), EstablishedByUserId: 'u1', EstablishedOn: '2026-07-19T10:00:00Z', CurrentCommanderUserId: 'u1', IcsLevel: 1, Status: 0 },
+ Nodes: [],
+ Assignments: [],
+ Objectives: [],
+ Timers: [],
+ Annotations: [],
+ Accountability: [],
+ Roles: [],
+ ...overrides,
+ },
+ adHocUnits: [],
+ adHocPersonnel: [],
+ isProvisional: false,
+ lastRefreshed: null,
+});
+
+const setupStores = ({ boards = {} as Record, activeCallId = null as string | null, calls = [] as any[], users = [] as any[] }) => {
+ const commandState = {
+ boards,
+ activeCallId,
+ isRefreshing: false,
+ switchCommand: mockSwitchCommand,
+ endCommand: mockEndCommand,
+ refreshBoard: mockRefreshBoard,
+ assignRole: jest.fn(),
+ removeRole: mockRemoveRole,
+ addAdHocUnit: jest.fn(),
+ releaseAdHocUnitEntry: jest.fn(),
+ addAdHocPersonnel: jest.fn(),
+ releaseAdHocPersonnelEntry: jest.fn(),
+ refreshAccountability: mockRefreshAccountability,
+ moveResourceAssignment: mockMoveResourceAssignment,
+ releaseResourceAssignment: mockReleaseResourceAssignment,
+ startTimer: jest.fn(),
+ createVoiceChannel: jest.fn(),
+ fetchVoiceChannels: jest.fn(),
+ closeVoiceChannels: jest.fn(),
+ fetchTransmissionLog: jest.fn(),
+ recordTransmission: jest.fn(),
+ acknowledgeTimer: jest.fn(),
+ transferIncidentCommand: jest.fn(),
+ fetchTimeline: mockFetchTimeline,
+ };
+ mockUseCommandStore.mockImplementation((selector: any) => (selector ? selector(commandState) : commandState));
+
+ const coreState = { activeCall: null, activePriority: null };
+ mockUseCoreStore.mockImplementation((selector: any) => (selector ? selector(coreState) : coreState));
+
+ const callsState = { calls };
+ mockUseCallsStore.mockImplementation((selector: any) => (selector ? selector(callsState) : callsState));
+
+ const rolesState = { users, roles: [] as any[], fetchUsers: jest.fn() };
+ mockUseRolesStore.mockImplementation((selector: any) => (selector ? selector(rolesState) : rolesState));
+};
+
+describe('CommandBoard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the zero state with a link to calls when no board is open', () => {
+ setupStores({});
+
+ const { getByTestId, getByText, unmount } = render();
+
+ expect(getByTestId('command-board-screen')).toBeTruthy();
+ expect(getByText('command.empty_heading')).toBeTruthy();
+
+ fireEvent.press(getByTestId('command-go-to-calls'));
+ expect(mockPush).toHaveBeenCalledWith('/calls');
+
+ unmount();
+ });
+
+ it('renders the board sections for the active board', () => {
+ setupStores({
+ boards: { '101': serverBoard('101') },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Structure Fire', Address: '123 Main St', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ });
+
+ const { getByTestId, unmount } = render();
+
+ expect(getByTestId('command-active-call')).toBeTruthy();
+ expect(getByTestId('command-roles-section')).toBeTruthy();
+ expect(getByTestId('command-resources-section')).toBeTruthy();
+ expect(getByTestId('command-accountability-section')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('shows a board switcher when multiple commands are open and switches on tap', () => {
+ setupStores({
+ boards: { '101': serverBoard('101'), '102': serverBoard('102') },
+ activeCallId: '101',
+ calls: [
+ { CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' },
+ { CallId: '102', Number: '26-15', Name: 'MVA', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' },
+ ],
+ });
+
+ const { getByTestId, unmount } = render();
+
+ expect(getByTestId('command-board-switcher')).toBeTruthy();
+ fireEvent.press(getByTestId('command-board-tab-102'));
+ expect(mockSwitchCommand).toHaveBeenCalledWith('102');
+
+ unmount();
+ });
+
+ it('renders server roles, ad-hoc units, and PAR accountability rows', () => {
+ const board = serverBoard('101', {
+ Roles: [{ IncidentRoleAssignmentId: 'ra-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, UserId: 'u-9', RoleType: 7, AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z' }],
+ Accountability: [{ UserId: 'u-2', FullName: 'Crew A Leader', LastCheckIn: '2026-07-19T10:30:00Z', NeedsCheckIn: false, MinutesRemaining: 12, Status: 'Green', DurationMinutes: 20, WarningThresholdMinutes: 5 }],
+ }) as any;
+ board.adHocUnits = [{ IncidentAdHocUnitId: 'ah-1', DepartmentId: 1, CallId: 101, Name: 'Mutual Aid Engine', Type: 'Engine', CreatedByUserId: 'u1', CreatedOn: '2026-07-19T10:00:00Z' }];
+
+ setupStores({
+ boards: { '101': board },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones' }],
+ });
+
+ const { getByTestId, getByText, unmount } = render();
+
+ expect(getByText('Sam Jones')).toBeTruthy();
+ expect(getByText('Mutual Aid Engine')).toBeTruthy();
+ expect(getByText('Crew A Leader')).toBeTruthy();
+ expect(getByText('command.par_green')).toBeTruthy();
+
+ fireEvent.press(getByTestId('assignment-remove-ra-1'));
+ expect(mockRemoveRole).toHaveBeenCalledWith('101', 'ra-1');
+
+ unmount();
+ });
+
+ it('lists tracked department units and personnel as resources with their lane and releases on remove', () => {
+ const board = serverBoard('101', {
+ Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }],
+ Assignments: [
+ // Department unit assigned to a lane
+ { ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false },
+ // Department person in the unassigned pool (no lane)
+ { ResourceAssignmentId: 'as-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: '', ResourceKind: 1, ResourceId: 'u-9', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false },
+ ],
+ }) as any;
+
+ setupStores({
+ boards: { '101': board },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }],
+ });
+
+ const { getByTestId, getByText, unmount } = render();
+
+ expect(getByTestId('resource-dept-as-1')).toBeTruthy();
+ expect(getByTestId('resource-dept-as-2')).toBeTruthy();
+ expect(getByText('Sam Jones')).toBeTruthy();
+ expect(getByText('Station 1')).toBeTruthy();
+ expect(getByTestId('resource-dept-as-2-status')).toBeTruthy();
+ expect(getByText('command.unassigned')).toBeTruthy();
+
+ fireEvent.press(getByTestId('resource-dept-remove-as-1'));
+ expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-1');
+
+ unmount();
+ });
+
+ it('prompts to move a resource already assigned to another lane and moves on confirm', async () => {
+ const board = serverBoard('101', {
+ Nodes: [
+ { CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 },
+ { CommandStructureNodeId: 'lane-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 2, Name: 'Medical', SortOrder: 1 },
+ ],
+ Assignments: [
+ { ResourceAssignmentId: 'as-9', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 4, ResourceId: 'ah-1', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false },
+ ],
+ }) as any;
+ board.adHocUnits = [{ IncidentAdHocUnitId: 'ah-1', DepartmentId: 1, CallId: 101, Name: 'Mutual Aid Engine', Type: 'Engine', CreatedByUserId: 'u1', CreatedOn: '2026-07-19T10:00:00Z' }];
+
+ setupStores({
+ boards: { '101': board },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ });
+
+ const { getByTestId, queryByTestId, unmount } = render();
+
+ // Open the assign sheet for the OTHER lane and pick the already-assigned ad-hoc unit
+ fireEvent.press(getByTestId('lane-assign-lane-2'));
+ await waitFor(() => expect(getByTestId('resource-kind-tab-2')).toBeTruthy());
+ fireEvent.press(getByTestId('resource-kind-tab-2'));
+ fireEvent.press(getByTestId('resource-option-4-ah-1'));
+ fireEvent.press(getByTestId('resource-assign-save'));
+
+ // Conflict dialog appears; cancel does nothing
+ await waitFor(() => expect(getByTestId('move-conflict-dialog')).toBeTruthy());
+ fireEvent.press(getByTestId('move-conflict-cancel'));
+ expect(mockMoveResourceAssignment).not.toHaveBeenCalled();
+
+ // Repeat and confirm — the existing assignment is moved, not duplicated
+ fireEvent.press(getByTestId('lane-assign-lane-2'));
+ await waitFor(() => expect(getByTestId('resource-kind-tab-2')).toBeTruthy());
+ fireEvent.press(getByTestId('resource-kind-tab-2'));
+ fireEvent.press(getByTestId('resource-option-4-ah-1'));
+ fireEvent.press(getByTestId('resource-assign-save'));
+ await waitFor(() => expect(getByTestId('move-conflict-dialog')).toBeTruthy());
+ fireEvent.press(getByTestId('move-conflict-confirm'));
+
+ await waitFor(() => expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'as-9', 'lane-2'));
+ expect(queryByTestId('move-conflict-dialog')).toBeNull();
+
+ unmount();
+ });
+
+ it('ends the active command and supports manual refresh', () => {
+ setupStores({
+ boards: { '101': serverBoard('101') },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ });
+
+ const { getByTestId, unmount } = render();
+
+ fireEvent.press(getByTestId('command-end-command'));
+ expect(mockEndCommand).toHaveBeenCalledWith('101');
+
+ fireEvent.press(getByTestId('command-refresh'));
+ expect(mockRefreshBoard).toHaveBeenCalledWith('101');
+ expect(mockRefreshAccountability).toHaveBeenCalledWith('101');
+
+ unmount();
+ });
+
+ it('shows the provisional badge for a board established offline', () => {
+ const board = serverBoard('101') as any;
+ board.isProvisional = true;
+ board.board = null;
+
+ setupStores({
+ boards: { '101': board },
+ activeCallId: '101',
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ });
+
+ const { getByTestId, unmount } = render();
+
+ expect(getByTestId('command-provisional-badge')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('keeps the vertical layout on a phone in landscape', () => {
+ const landscapePhone = { fontScale: 1, height: 390, scale: 1, width: 844 };
+ act(() => Dimensions.set({ screen: landscapePhone, window: landscapePhone }));
+ setupStores({
+ activeCallId: '101',
+ boards: { '101': serverBoard('101') },
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ });
+
+ const { getByTestId, queryByTestId, unmount } = render();
+
+ expect(getByTestId('command-structure-section')).toBeTruthy();
+ expect(queryByTestId('command-landscape-structure-board')).toBeNull();
+
+ unmount();
+ const portrait = { fontScale: 1, height: 844, scale: 1, width: 390 };
+ act(() => Dimensions.set({ screen: portrait, window: portrait }));
+ });
+
+ it('uses horizontal swimlanes on landscape tablets and moves a selected resource on lane tap', async () => {
+ const landscape = { fontScale: 1, height: 800, scale: 1, width: 1200 };
+ act(() => Dimensions.set({ screen: landscape, window: landscape }));
+ const board = serverBoard('101', {
+ Assignments: [
+ {
+ ResourceAssignmentId: 'resource-assignment-1',
+ IncidentCommandId: 'cmd-101',
+ DepartmentId: 1,
+ CallId: 101,
+ CommandStructureNodeId: 'lane-1',
+ ResourceKind: 1,
+ ResourceId: 'u-9',
+ AssignedByUserId: 'u1',
+ AssignedOn: '2026-07-19T10:00:00Z',
+ RequirementsWarning: false,
+ },
+ ],
+ Nodes: [
+ { CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 },
+ { CommandStructureNodeId: 'lane-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 6, Name: 'Staging', SortOrder: 1 },
+ ],
+ });
+ setupStores({
+ activeCallId: '101',
+ boards: { '101': board },
+ calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
+ users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones' }],
+ });
+
+ const { getByTestId, unmount } = render();
+
+ expect(getByTestId('command-landscape-structure-board')).toBeTruthy();
+ expect(getByTestId('command-landscape-lanes')).toBeTruthy();
+ const resourceCard = getByTestId('landscape-resource-resource-assignment-1');
+ fireEvent(resourceCard, 'longPress');
+ expect(getByTestId('landscape-drop-target-lane-2')).toBeTruthy();
+ fireEvent(resourceCard, 'pressOut');
+ await waitFor(() => expect(resourceCard.props.className).not.toContain('bg-primary-50'));
+ fireEvent.press(resourceCard);
+ fireEvent.press(getByTestId('landscape-lane-lane-2'));
+
+ await waitFor(() => expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'resource-assignment-1', 'lane-2'));
+
+ unmount();
+ const portrait = { fontScale: 1, height: 844, scale: 1, width: 390 };
+ act(() => Dimensions.set({ screen: portrait, window: portrait }));
+ });
+});
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 41ffda9..dd3c7a4 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -3,13 +3,14 @@
import { NovuProvider } from '@novu/react-native';
import Countly from 'countly-sdk-react-native-bridge';
import * as NavigationBar from 'expo-navigation-bar';
-import { Redirect, SplashScreen, Tabs } from 'expo-router';
-import { CloudAlert, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native';
+import { Redirect, router, SplashScreen, Tabs } from 'expo-router';
+import { ArrowLeft, ClipboardList, LayoutDashboard, CloudAlert, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { OfflineStatusBar } from '@/components/common/offline-status-bar';
import { NotificationButton } from '@/components/notifications/NotificationButton';
import { NotificationInbox } from '@/components/notifications/NotificationInbox';
import Sidebar from '@/components/sidebar/sidebar';
@@ -31,6 +32,7 @@ import { bluetoothAudioService } from '@/services/bluetooth-audio.service';
import { usePushNotifications } from '@/services/push-notification';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
+import { useCommandStore } from '@/stores/command/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
@@ -166,6 +168,12 @@ export default function TabLayout() {
await useSignalRStore.getState().connectUpdateHub();
await useSignalRStore.getState().connectGeolocationHub();
+ // Hydrate incident-command boards from the server Sync Bundle (best-effort; offline-safe)
+ useCommandStore
+ .getState()
+ .syncFromServer()
+ .catch(() => {});
+
hasInitialized.current = true;
// Initialize Bluetooth and Audio services (native-only)
@@ -200,7 +208,13 @@ export default function TabLayout() {
try {
// Refresh data
- await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles(), useWeatherAlertsStore.getState().fetchActiveAlerts()]);
+ await Promise.all([
+ useCoreStore.getState().fetchConfig(),
+ useCallsStore.getState().fetchCalls(),
+ useRolesStore.getState().fetchRoles(),
+ useWeatherAlertsStore.getState().fetchActiveAlerts(),
+ useCommandStore.getState().syncFromServer(),
+ ]);
} catch (error) {
logger.warn({
message: 'Failed to refresh data on app resume',
@@ -270,20 +284,12 @@ export default function TabLayout() {
}
}, [isActive, appState, refreshDataFromBackground, initializeApp]);
- // Force drawer open in landscape (guard with functional update to avoid unnecessary re-render)
- useEffect(() => {
- if (isLandscape) {
- setIsOpen((prev) => (prev ? prev : true));
- }
- }, [isLandscape]);
-
// Get user ID and config for notifications
const config = useCoreStore((state) => state.config);
- const activeUnitId = useCoreStore((state) => state.activeUnitId);
const rights = securityStore((state) => state.rights);
// Compute Novu readiness once for consistent gating across the render
- const novuReady = !!(activeUnitId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode);
+ const novuReady = !!(userId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode);
// Cache the last known-good Novu config so NovuProvider stays mounted stably
// even if a transient state update briefly nullifies one of the values.
const lastNovuConfig = useRef<{
@@ -294,7 +300,7 @@ export default function TabLayout() {
} | null>(null);
if (novuReady) {
lastNovuConfig.current = {
- subscriberId: `${rights?.DepartmentCode}_Unit_${activeUnitId}`,
+ subscriberId: `${rights?.DepartmentCode}_User_${userId}`,
applicationIdentifier: config!.NovuApplicationId,
backendUrl: config!.NovuBackendApiUrl,
socketUrl: config!.NovuSocketUrl,
@@ -341,15 +347,18 @@ export default function TabLayout() {
// Memoize per-screen tab bar icon renderers to prevent new functions every render
const mapIcon = useCallback(({ color }: { color: string }) => , []);
const callsIcon = useCallback(({ color }: { color: string }) => , []);
+ const incidentsIcon = useCallback(({ color }: { color: string }) => , []);
+ const commandIcon = useCallback(({ color }: { color: string }) => , []);
const routesIcon = useCallback(({ color }: { color: string }) => , []);
const weatherAlertsIcon = useCallback(({ color }: { color: string }) => , []);
const settingsIcon = useCallback(({ color }: { color: string }) => , []);
// Memoize header left/right renders
- const headerLeftMap = useCallback(() => , [isLandscape]);
+ const headerLeftMap = useCallback(() => , []);
+ const headerLeftBack = useCallback(() => , []);
const headerRightNotification = useCallback(
- () => ,
- [config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode]
+ () => ,
+ [config, handleOpenNotifications, userId, rights?.DepartmentCode]
);
// Memoize per-screen options to prevent new objects every render
@@ -370,26 +379,38 @@ export default function TabLayout() {
headerShown: true as const,
tabBarIcon: callsIcon,
tabBarButtonTestID: 'calls-tab' as const,
+ headerLeft: headerLeftMap,
headerRight: headerRightNotification,
}),
- [t, callsIcon, headerRightNotification]
+ [t, callsIcon, headerLeftMap, headerRightNotification]
);
- // routes + weather-alerts are kept as routes but HIDDEN from the IC shell tab bar (href: null).
- // They are apparatus/Unit-centric and still referenced by the map home + maps components, so the
- // route files remain to avoid broken navigation; physical removal can follow in a toolchain-verified pass.
- const routesOptions = useMemo(
+ const commandOptions = useMemo(
() => ({
- href: null,
- title: t('tabs.routes'),
+ title: t('tabs.command_board'),
+ headerShown: true as const,
+ tabBarIcon: commandIcon,
+ tabBarButtonTestID: 'command-tab' as const,
+ headerLeft: headerLeftMap,
+ headerRight: headerRightNotification,
+ }),
+ [t, commandIcon, headerLeftMap, headerRightNotification]
+ );
+
+ const incidentsOptions = useMemo(
+ () => ({
+ title: t('tabs.incidents'),
headerShown: true as const,
tabBarIcon: routesIcon,
tabBarButtonTestID: 'routes-tab' as const,
+ headerLeft: headerLeftMap,
headerRight: headerRightNotification,
}),
- [t, routesIcon, headerRightNotification]
+ [t, routesIcon, headerLeftMap, headerRightNotification]
);
+ // weather-alerts is kept (relevant to IC scene safety) but HIDDEN from the tab bar (href: null);
+ // it is reachable from the map-home weather banner. IC shell tabs: Map, Calls, Settings.
const weatherAlertsOptions = useMemo(
() => ({
href: null,
@@ -397,19 +418,37 @@ export default function TabLayout() {
headerShown: true as const,
tabBarIcon: weatherAlertsIcon,
tabBarButtonTestID: 'weather-alerts-tab' as const,
+ headerLeft: headerLeftMap,
headerRight: headerRightNotification,
}),
- [t, weatherAlertsIcon, headerRightNotification]
+ [t, weatherAlertsIcon, headerLeftMap, headerRightNotification]
);
+ // POI detail renders inside the tab shell (app header + tab bar stay visible) but has no tab
+ // bar entry of its own; the header shows a back button instead of the drawer menu.
+ const poiDetailOptions = useMemo(
+ () => ({
+ href: null,
+ title: t('routes.poi_detail'),
+ headerShown: true as const,
+ headerLeft: headerLeftBack,
+ headerRight: headerRightNotification,
+ }),
+ [t, headerLeftBack, headerRightNotification]
+ );
+
+ // settings stays routable (sidebar menu link) but is hidden from the tab bar.
const settingsOptions = useMemo(
() => ({
+ href: null,
title: t('tabs.settings'),
headerShown: true as const,
tabBarIcon: settingsIcon,
tabBarButtonTestID: 'settings-tab' as const,
+ headerLeft: headerLeftMap,
+ headerRight: headerRightNotification,
}),
- [t, settingsIcon]
+ [t, settingsIcon, headerLeftMap, headerRightNotification]
);
if (isFirstTime) {
@@ -433,38 +472,42 @@ export default function TabLayout() {
) : null}
- {/* Drawer and sidebar only render after init to avoid heavy re-renders during store settling */}
+ {/* Drawer renders after init to avoid heavy re-renders during store settling.
+ The menu is always a hamburger-toggled drawer — no persistent panel on tablets. */}
{isInitComplete ? (
- isLandscape ? (
-
-
-
- ) : (
-
-
-
-
-
-
-
-
- )
+
+
+
+
+
+
+
+
) : null}
{/* Main content area */}
-
+
+ {/* Offline / pending-sync status strip */}
+ {isInitComplete ? : null}
- {/* routes + weather-alerts are registered so their route files resolve, but hidden from the
- tab bar (href: null in their options). IC shell shows: Map, Calls, Settings. */}
+
+
+ {/* routes, weather-alerts, and settings are registered so their route files resolve, but hidden
+ from the tab bar (href: null in their options) — reachable via the sidebar menu.
+ IC shell shows: Map, Calls, Command Board. */}
+ {/* weather-alerts is registered so its route file resolves, but hidden from the
+ tab bar (href: null). IC shell shows: Map, Calls, Settings. */}
+
+
{/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */}
@@ -495,17 +538,13 @@ export default function TabLayout() {
interface CreateDrawerMenuButtonProps {
setIsOpen: (isOpen: boolean) => void;
- isLandscape: boolean;
}
-const CreateDrawerMenuButton = ({ setIsOpen, isLandscape }: CreateDrawerMenuButtonProps) => {
- if (isLandscape) {
- return null;
- }
-
+const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => {
return (
{
setIsOpen(true);
}}
@@ -515,18 +554,36 @@ const CreateDrawerMenuButton = ({ setIsOpen, isLandscape }: CreateDrawerMenuButt
);
};
+const CreateHeaderBackButton = () => {
+ return (
+ {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace('/');
+ }
+ }}
+ >
+
+
+ );
+};
+
const CreateNotificationButton = ({
config,
setIsNotificationsOpen,
- activeUnitId,
+ userId,
departmentCode,
}: {
config: GetConfigResultData | null;
setIsNotificationsOpen: () => void;
- activeUnitId: string | null;
+ userId: string | null;
departmentCode: string | undefined;
}) => {
- if (!activeUnitId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl || !departmentCode) {
+ if (!userId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl || !departmentCode) {
return null;
}
diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx
index d9acaa3..cc0d62e 100644
--- a/src/app/(app)/calls.tsx
+++ b/src/app/(app)/calls.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Pressable, RefreshControl, View } from 'react-native';
import { CallCard } from '@/components/calls/call-card';
+import { StartCommandSheet } from '@/components/command/start-command-sheet';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
import { Box } from '@/components/ui/box';
@@ -14,9 +15,12 @@ import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
import { useAnalytics } from '@/hooks/use-analytics';
+import { logger } from '@/lib/logging';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { useCallsStore } from '@/stores/calls/store';
+import { useCommandStore } from '@/stores/command/store';
import { securityStore } from '@/stores/security/store';
+import { useToastStore } from '@/stores/toast/store';
export default function Calls() {
const calls = useCallsStore((state) => state.calls);
@@ -27,9 +31,43 @@ export default function Calls() {
const fetchCallDispatches = useCallsStore((state) => state.fetchCallDispatches);
const callDispatches = useCallsStore((state) => state.callDispatches);
const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls);
+ const commandBoards = useCommandStore((state) => state.boards);
+ const activeBoardCallId = useCommandStore((state) => state.activeCallId);
+ const showToast = useToastStore((state) => state.showToast);
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
+ const [templatePickerCall, setTemplatePickerCall] = useState(null);
+
+ const startCommandForCall = useCallback(
+ async (call: CallResultData, commandDefinitionId: number | null) => {
+ try {
+ await useCommandStore.getState().startCommand(call.CallId, commandDefinitionId);
+ showToast('success', t('command.start_success'));
+ router.push('/command');
+ } catch (error) {
+ logger.error({
+ message: 'Failed to start command for call',
+ context: { error, callId: call.CallId },
+ });
+ showToast('error', t('command.start_error'));
+ }
+ },
+ [showToast, t]
+ );
+
+ // Opens an existing board directly; for a new command, offer the department's
+ // command templates (ICS structures, event/security staffing plans, ...) first.
+ const handleStartCommand = useCallback(
+ (call: CallResultData) => {
+ if (useCommandStore.getState().boards[call.CallId]) {
+ startCommandForCall(call, null);
+ return;
+ }
+ setTemplatePickerCall(call);
+ },
+ [startCommandForCall]
+ );
// Fetch data when screen comes into focus
useFocusEffect(
@@ -78,7 +116,9 @@ export default function Calls() {
return ;
}
- if (error) {
+ // Only surface a fetch error when there is no cached data to show —
+ // offline the list keeps rendering the last synced calls.
+ if (error && filteredCalls.length === 0) {
return ;
}
@@ -88,7 +128,14 @@ export default function Calls() {
data={filteredCalls}
renderItem={({ item }: { item: CallResultData }) => (
router.push(`/call/${item.CallId}`)}>
- p.Id === item.Priority)} dispatches={callDispatches[item.CallId]} />
+ p.Id === item.Priority)}
+ dispatches={callDispatches[item.CallId]}
+ isCommandCall={item.CallId === activeBoardCallId}
+ hasCommand={!!commandBoards[item.CallId]}
+ onStartCommand={() => handleStartCommand(item)}
+ />
)}
keyExtractor={(item: CallResultData) => item.CallId}
@@ -119,6 +166,13 @@ export default function Calls() {
{/* Main content */}
{renderContent()}
+ {/* Command template picker for starting a new board */}
+ setTemplatePickerCall(null)}
+ onStart={(commandDefinitionId) => (templatePickerCall ? startCommandForCall(templatePickerCall, commandDefinitionId) : undefined)}
+ />
+
{/* FAB button for creating new call - only show if user has permission */}
{canUserCreateCalls ? (
diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx
new file mode 100644
index 0000000..02e24bc
--- /dev/null
+++ b/src/app/(app)/command.tsx
@@ -0,0 +1,693 @@
+import { router } from 'expo-router';
+import { ClipboardList, CloudOff, ExternalLink, MapPin, RefreshCw, Trash2, UserCog, XCircle } from 'lucide-react-native';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ScrollView, useWindowDimensions } from 'react-native';
+
+import { AddAssignmentSheet } from '@/components/command/add-assignment-sheet';
+import { AddLaneSheet } from '@/components/command/add-lane-sheet';
+import { AddResourceSheet } from '@/components/command/add-resource-sheet';
+import { type AssignableResourceOption, AssignResourceSheet } from '@/components/command/assign-resource-sheet';
+import { CommandSection } from '@/components/command/command-section';
+import { LandscapeStructureBoard } from '@/components/command/landscape-structure-board';
+import { ObjectivesSection } from '@/components/command/objectives-section';
+import { PersonnelResourceCard, UnitResourceCard } from '@/components/command/resource-cards';
+import { StructureSection } from '@/components/command/structure-section';
+import { SceneClock, TimelineSection } from '@/components/command/timeline-section';
+import { TimersSection } from '@/components/command/timers-section';
+import { TransferCommandSheet } from '@/components/command/transfer-command-sheet';
+import { VoiceSection } from '@/components/command/voice-section';
+import ZeroState from '@/components/common/zero-state';
+import { View } from '@/components/ui';
+import { AlertDialog, AlertDialogBackdrop, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader } from '@/components/ui/alert-dialog';
+import { Badge, BadgeText } from '@/components/ui/badge';
+import { Box } from '@/components/ui/box';
+import { Button, ButtonIcon, ButtonText } from '@/components/ui/button';
+import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
+import { Heading } from '@/components/ui/heading';
+import { HStack } from '@/components/ui/hstack';
+import { Icon } from '@/components/ui/icon';
+import { Pressable } from '@/components/ui/pressable';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { getIncidentRoleName, getParBadgeAction } from '@/lib/incident-command-utils';
+import { isWeb } from '@/lib/platform';
+import { getTimeAgoUtc } from '@/lib/utils';
+import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels';
+import { useCoreStore } from '@/stores/app/core-store';
+import { useCallsStore } from '@/stores/calls/store';
+import { type AssignmentOutcome } from '@/stores/command/store';
+import { useCommandStore } from '@/stores/command/store';
+import { useRolesStore } from '@/stores/roles/store';
+import { useToastStore } from '@/stores/toast/store';
+import { useUnitsStore } from '@/stores/units/store';
+
+/** One-line truncation: numberOfLines leaks to the DOM through the styling pipeline on web, so use CSS ellipsis there. */
+const oneLine = isWeb ? ({ isTruncated: true } as const) : ({ numberOfLines: 1 } as const);
+
+export default function CommandBoard() {
+ const { t } = useTranslation();
+ const { height: viewportHeight, width: viewportWidth } = useWindowDimensions();
+ const boards = useCommandStore((state) => state.boards);
+ const activeBoardCallId = useCommandStore((state) => state.activeCallId);
+ const switchCommand = useCommandStore((state) => state.switchCommand);
+ const endCommand = useCommandStore((state) => state.endCommand);
+ const refreshBoard = useCommandStore((state) => state.refreshBoard);
+ const isRefreshing = useCommandStore((state) => state.isRefreshing);
+ const assignRole = useCommandStore((state) => state.assignRole);
+ const removeRole = useCommandStore((state) => state.removeRole);
+ const addAdHocUnit = useCommandStore((state) => state.addAdHocUnit);
+ const releaseAdHocUnitEntry = useCommandStore((state) => state.releaseAdHocUnitEntry);
+ const addAdHocPersonnel = useCommandStore((state) => state.addAdHocPersonnel);
+ const releaseAdHocPersonnelEntry = useCommandStore((state) => state.releaseAdHocPersonnelEntry);
+ const refreshAccountability = useCommandStore((state) => state.refreshAccountability);
+ const addNode = useCommandStore((state) => state.addNode);
+ const deleteNode = useCommandStore((state) => state.deleteNode);
+ const assignResourceToNode = useCommandStore((state) => state.assignResourceToNode);
+ const moveResourceAssignment = useCommandStore((state) => state.moveResourceAssignment);
+ const releaseResourceAssignment = useCommandStore((state) => state.releaseResourceAssignment);
+ const addObjective = useCommandStore((state) => state.addObjective);
+ const completeObjectiveEntry = useCommandStore((state) => state.completeObjectiveEntry);
+ const startTimer = useCommandStore((state) => state.startTimer);
+ const acknowledgeTimer = useCommandStore((state) => state.acknowledgeTimer);
+ const transferIncidentCommand = useCommandStore((state) => state.transferIncidentCommand);
+ const fetchTimeline = useCommandStore((state) => state.fetchTimeline);
+ const createVoiceChannel = useCommandStore((state) => state.createVoiceChannel);
+ const fetchVoiceChannels = useCommandStore((state) => state.fetchVoiceChannels);
+ const closeVoiceChannels = useCommandStore((state) => state.closeVoiceChannels);
+ const fetchTransmissionLog = useCommandStore((state) => state.fetchTransmissionLog);
+ const recordTransmission = useCommandStore((state) => state.recordTransmission);
+
+ const activeCall = useCoreStore((state) => state.activeCall);
+ const activePriority = useCoreStore((state) => state.activePriority);
+ const calls = useCallsStore((state) => state.calls);
+ const users = useRolesStore((state) => state.users);
+ const fetchUsers = useRolesStore((state) => state.fetchUsers);
+ const unitRoles = useRolesStore((state) => state.roles);
+ const units = useUnitsStore((state) => state.units);
+ const unitCurrentStatuses = useUnitsStore((state) => state.unitCurrentStatuses);
+ const fetchUnits = useUnitsStore((state) => state.fetchUnits);
+ const showToast = useToastStore((state) => state.showToast);
+
+ // Advisory requirement violations warn; forced ones were rejected server-side and error.
+ const notifyAssignmentOutcome = useCallback(
+ (outcome: AssignmentOutcome | null) => {
+ if (outcome?.blocked) {
+ showToast('error', outcome.blocked);
+ } else if (outcome?.warning) {
+ showToast('warning', outcome.warning);
+ }
+ },
+ [showToast]
+ );
+
+ const [isAssignmentSheetOpen, setIsAssignmentSheetOpen] = useState(false);
+ const [isResourceSheetOpen, setIsResourceSheetOpen] = useState(false);
+ const [isLaneSheetOpen, setIsLaneSheetOpen] = useState(false);
+ const [assignTargetNodeId, setAssignTargetNodeId] = useState(null);
+ /** Pending "already assigned elsewhere — move it?" confirmation. */
+ const [moveConflict, setMoveConflict] = useState<{ assignmentId: string; resourceName: string; fromLane: string; toLane: string; targetNodeId: string } | null>(null);
+ const [isTransferSheetOpen, setIsTransferSheetOpen] = useState(false);
+
+ const boardList = useMemo(() => Object.values(boards), [boards]);
+ const boardState = activeBoardCallId ? boards[activeBoardCallId] : undefined;
+ const isLandscapeBoard = viewportWidth > viewportHeight && Math.min(viewportWidth, viewportHeight) >= 600;
+
+ // Unit and personnel rosters back the resource pool — load once when a board is open
+ useEffect(() => {
+ if (boardState && units.length === 0) {
+ fetchUnits();
+ }
+ }, [boardState, units.length, fetchUnits]);
+
+ useEffect(() => {
+ if (boardState && users.length === 0) {
+ fetchUsers();
+ }
+ }, [boardState, users.length, fetchUsers]);
+
+ // Incident log + voice channels load alongside the board
+ const boardCallId = boardState?.callId;
+ useEffect(() => {
+ if (boardCallId) {
+ fetchTimeline(boardCallId);
+ fetchVoiceChannels(boardCallId);
+ fetchTransmissionLog(boardCallId);
+ }
+ }, [boardCallId, fetchTimeline, fetchVoiceChannels, fetchTransmissionLog]);
+
+ const personName = useCallback(
+ (userId: string) => {
+ const user = users.find((u) => u.UserId === userId);
+ return user ? `${user.FirstName} ${user.LastName}` : userId;
+ },
+ [users]
+ );
+
+ const boardLabel = useCallback(
+ (callId: string) => {
+ const call = calls.find((c) => c.CallId === callId);
+ return call ? `#${call.Number}` : `#${callId}`;
+ },
+ [calls]
+ );
+
+ const handleViewCall = useCallback(() => {
+ if (activeBoardCallId) {
+ router.push(`/call/${activeBoardCallId}`);
+ }
+ }, [activeBoardCallId]);
+
+ const handleEndCommand = useCallback(() => {
+ if (activeBoardCallId) {
+ endCommand(activeBoardCallId);
+ }
+ }, [activeBoardCallId, endCommand]);
+
+ const handleRefresh = useCallback(() => {
+ if (activeBoardCallId) {
+ refreshBoard(activeBoardCallId);
+ refreshAccountability(activeBoardCallId);
+ fetchTimeline(activeBoardCallId);
+ fetchVoiceChannels(activeBoardCallId);
+ fetchTransmissionLog(activeBoardCallId);
+ }
+ }, [activeBoardCallId, refreshBoard, refreshAccountability, fetchTimeline, fetchVoiceChannels, fetchTransmissionLog]);
+
+ const handleTransferCommand = useCallback(
+ async (toUserId: string) => {
+ if (!activeBoardCallId) {
+ return;
+ }
+ const ok = await transferIncidentCommand(activeBoardCallId, toUserId);
+ showToast(ok ? 'success' : 'error', ok ? t('command.transfer_success') : t('command.transfer_error'));
+ },
+ [activeBoardCallId, transferIncidentCommand, showToast, t]
+ );
+
+ const handleGoToCalls = useCallback(() => {
+ router.push('/calls');
+ }, []);
+
+ // Resolve a resource assignment (kind + id) to a display name using the loaded rosters
+ const resolveResourceName = useCallback(
+ (kind: number, resourceId: string) => {
+ if (kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit) {
+ return units.find((u) => u.UnitId === resourceId)?.Name ?? resourceId;
+ }
+ if (kind === ResourceAssignmentKind.RealPersonnel || kind === ResourceAssignmentKind.LinkedDeptPersonnel) {
+ const user = users.find((u) => u.UserId === resourceId);
+ return user ? `${user.FirstName} ${user.LastName}` : resourceId;
+ }
+ const boardEntry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
+ if (kind === ResourceAssignmentKind.AdHocPersonnel) {
+ return boardEntry?.adHocPersonnel.find((person) => person.IncidentAdHocPersonnelId === resourceId)?.Name ?? resourceId;
+ }
+ return boardEntry?.adHocUnits.find((u) => u.IncidentAdHocUnitId === resourceId)?.Name ?? resourceId;
+ },
+ [units, users, boards, activeBoardCallId]
+ );
+
+ const resourceOptions = useMemo(() => {
+ const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
+ const activeAssignments = (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn);
+ const assignedNodeOf = (kind: ResourceAssignmentKind, id: string) => activeAssignments.find((a) => a.ResourceKind === kind && a.ResourceId === id)?.CommandStructureNodeId;
+
+ const unitOptions = units.map((u) => ({
+ kind: ResourceAssignmentKind.RealUnit,
+ id: u.UnitId,
+ name: u.Name,
+ detail: [u.Type, u.GroupName].filter(Boolean).join(' • '),
+ statusLabel: unitCurrentStatuses.find((s) => s.UnitId === u.UnitId)?.State,
+ assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealUnit, u.UnitId),
+ }));
+ const personnelOptions = users.map((u) => ({
+ kind: ResourceAssignmentKind.RealPersonnel,
+ id: u.UserId,
+ name: `${u.FirstName} ${u.LastName}`,
+ detail: [u.GroupName, u.Status].filter(Boolean).join(' • '),
+ chips: u.Roles ?? [],
+ assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealPersonnel, u.UserId),
+ }));
+ const adHocOptions = (entry?.adHocUnits ?? []).map((u) => ({
+ kind: ResourceAssignmentKind.AdHocUnit,
+ id: u.IncidentAdHocUnitId,
+ name: u.Name,
+ detail: [u.Type, u.ExternalAgencyName].filter(Boolean).join(' • '),
+ assignedNodeId: assignedNodeOf(ResourceAssignmentKind.AdHocUnit, u.IncidentAdHocUnitId),
+ }));
+ const adHocPersonnelOptions = (entry?.adHocPersonnel ?? []).map((person) => ({
+ kind: ResourceAssignmentKind.AdHocPersonnel,
+ id: person.IncidentAdHocPersonnelId,
+ name: person.Name,
+ detail: [person.Role, person.ExternalAgencyName].filter(Boolean).join(' • '),
+ assignedNodeId: assignedNodeOf(ResourceAssignmentKind.AdHocPersonnel, person.IncidentAdHocPersonnelId),
+ }));
+ return [...unitOptions, ...personnelOptions, ...adHocOptions, ...adHocPersonnelOptions];
+ }, [units, users, unitCurrentStatuses, boards, activeBoardCallId]);
+
+ // Department units/personnel tracked on this incident — every active assignment pointing at a
+ // Resgrid resource, whether it sits in a lane or in the unassigned pool (empty node id).
+ const deptAssignments = useMemo(() => {
+ const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
+ const unitKinds = [ResourceAssignmentKind.RealUnit, ResourceAssignmentKind.LinkedDeptUnit, ResourceAssignmentKind.RealPersonnel, ResourceAssignmentKind.LinkedDeptPersonnel];
+ return (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn && unitKinds.includes(a.ResourceKind));
+ }, [boards, activeBoardCallId]);
+
+ const isUnitKind = useCallback((kind: number) => kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit, []);
+
+ const trackedUnitIds = useMemo(() => deptAssignments.filter((a) => isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]);
+ const trackedUserIds = useMemo(() => deptAssignments.filter((a) => !isUnitKind(a.ResourceKind)).map((a) => a.ResourceId), [deptAssignments, isUnitKind]);
+
+ const laneName = useCallback(
+ (nodeId?: string | null) => {
+ if (!nodeId) {
+ return t('command.unassigned');
+ }
+ const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
+ return entry?.board?.Nodes.find((n) => n.CommandStructureNodeId === nodeId)?.Name ?? t('command.unassigned');
+ },
+ [boards, activeBoardCallId, t]
+ );
+
+ // Track a department resource on the incident: an assignment with no lane (the unassigned pool)
+ const handleAddDeptUnit = useCallback(
+ async (unitId: string) => {
+ if (!activeBoardCallId) {
+ return;
+ }
+ const outcome = await assignResourceToNode(activeBoardCallId, '', ResourceAssignmentKind.RealUnit, unitId);
+ notifyAssignmentOutcome(outcome);
+ },
+ [activeBoardCallId, assignResourceToNode, showToast]
+ );
+
+ const handleAddDeptPersonnel = useCallback(
+ async (userId: string) => {
+ if (!activeBoardCallId) {
+ return;
+ }
+ const outcome = await assignResourceToNode(activeBoardCallId, '', ResourceAssignmentKind.RealPersonnel, userId);
+ notifyAssignmentOutcome(outcome);
+ },
+ [activeBoardCallId, assignResourceToNode, showToast]
+ );
+
+ const handleAssignResourceSave = useCallback(
+ async (kind: ResourceAssignmentKind, resourceId: string) => {
+ if (!activeBoardCallId || !assignTargetNodeId) {
+ return;
+ }
+ const targetNodeId = assignTargetNodeId;
+ setAssignTargetNodeId(null);
+
+ const entry = boards[activeBoardCallId];
+ const existing = (entry?.board?.Assignments ?? []).find((a) => !a.ReleasedOn && a.ResourceKind === kind && a.ResourceId === resourceId);
+
+ if (existing) {
+ // Already in this lane — the picker disables these rows; nothing to do.
+ if (existing.CommandStructureNodeId === targetNodeId) {
+ return;
+ }
+ // Sitting in the unassigned pool — moving it into a lane needs no confirmation.
+ if (!existing.CommandStructureNodeId) {
+ const outcome = await moveResourceAssignment(activeBoardCallId, existing.ResourceAssignmentId, targetNodeId);
+ notifyAssignmentOutcome(outcome);
+ return;
+ }
+ // In another lane — ask before moving.
+ setMoveConflict({
+ assignmentId: existing.ResourceAssignmentId,
+ resourceName: resolveResourceName(kind, resourceId),
+ fromLane: laneName(existing.CommandStructureNodeId),
+ toLane: laneName(targetNodeId),
+ targetNodeId,
+ });
+ return;
+ }
+
+ const outcome = await assignResourceToNode(activeBoardCallId, targetNodeId, kind, resourceId);
+ notifyAssignmentOutcome(outcome);
+ },
+ [activeBoardCallId, assignTargetNodeId, boards, assignResourceToNode, moveResourceAssignment, resolveResourceName, laneName, showToast]
+ );
+
+ const handleConfirmMove = useCallback(async () => {
+ if (!moveConflict || !activeBoardCallId) {
+ setMoveConflict(null);
+ return;
+ }
+ const { assignmentId, targetNodeId } = moveConflict;
+ setMoveConflict(null);
+ const outcome = await moveResourceAssignment(activeBoardCallId, assignmentId, targetNodeId);
+ notifyAssignmentOutcome(outcome);
+ }, [moveConflict, activeBoardCallId, moveResourceAssignment, showToast]);
+
+ const handleMoveResource = useCallback(
+ async (assignmentId: string, targetNodeId: string) => {
+ if (!activeBoardCallId) {
+ return;
+ }
+ const outcome = await moveResourceAssignment(activeBoardCallId, assignmentId, targetNodeId);
+ notifyAssignmentOutcome(outcome);
+ },
+ [activeBoardCallId, moveResourceAssignment, showToast]
+ );
+
+ if (!boardState) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const activeRoles = (boardState.board?.Roles ?? []).filter((r) => !r.RemovedOn);
+ const accountability = boardState.board?.Accountability ?? [];
+ const summaryCall = activeCall?.CallId === boardState.callId ? activeCall : (calls.find((c) => c.CallId === boardState.callId) ?? null);
+ const summaryPriority = activeCall?.CallId === boardState.callId ? activePriority : null;
+
+ return (
+
+
+
+
+ {/* Board switcher — the IC may be running several incidents at once */}
+ {boardList.length > 1 ? (
+
+
+ {boardList.map((b) => (
+ switchCommand(b.callId)}
+ >
+ {boardLabel(b.callId)}
+
+ ))}
+
+
+ ) : null}
+
+ {/* Active call summary — compact one-liner so command info gets the screen */}
+
+
+
+ {boardLabel(boardState.callId)}
+ {summaryCall ? (
+
+ {summaryCall.Name}
+
+ ) : null}
+
+
+ {boardState.isProvisional ? (
+
+ {t('command.provisional_badge')}
+
+ ) : null}
+ {summaryPriority ? (
+
+ {summaryPriority.Name}
+
+ ) : null}
+ {/* Master scene timer — elapsed time since the call was logged */}
+
+
+
+
+ {summaryCall ? (
+
+ {summaryCall.Address ? (
+ <>
+
+
+ {summaryCall.Address}
+
+ >
+ ) : (
+
+ )}
+ {boardState.board?.Command?.CurrentCommanderUserId ? (
+
+ {`${t('command.current_commander')}: ${personName(boardState.board.Command.CurrentCommanderUserId)}`}
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {/* Command structure lanes (Division/Group/Branch/...) with assigned resources */}
+ {isLandscapeBoard ? (
+ setIsLaneSheetOpen(true)}
+ onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)}
+ onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)}
+ onMoveResource={handleMoveResource}
+ onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)}
+ resolveResourceName={resolveResourceName}
+ viewportHeight={viewportHeight}
+ viewportWidth={viewportWidth}
+ />
+ ) : (
+ setIsLaneSheetOpen(true)}
+ onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)}
+ onDeleteLane={(nodeId) => deleteNode(boardState.callId, nodeId)}
+ onMoveResource={handleMoveResource}
+ onReleaseResource={(assignmentId) => releaseResourceAssignment(boardState.callId, assignmentId)}
+ resolveResourceName={resolveResourceName}
+ />
+ )}
+
+ {/* PAR / benchmark reminder timers with live countdowns */}
+ startTimer(boardState.callId, name, seconds)} onAcknowledge={(timerId) => acknowledgeTimer(boardState.callId, timerId)} />
+
+ {/* Tactical & command PTT channels + transmission log */}
+ createVoiceChannel(boardState.callId, name)}
+ onCloseChannels={() => closeVoiceChannels(boardState.callId)}
+ onTransmission={(channelId, startedOn, endedOn) => recordTransmission(boardState.callId, channelId, startedOn, endedOn)}
+ />
+
+ {/* Tactical objectives / benchmarks */}
+ addObjective(boardState.callId, name, type)}
+ onComplete={(objectiveId) => completeObjectiveEntry(boardState.callId, objectiveId)}
+ />
+
+ {/* ICS role assignments — synced with IncidentRoles API */}
+ setIsAssignmentSheetOpen(true)}
+ testID="command-roles-section"
+ >
+ {activeRoles.map((assignment) => (
+
+
+ {getIncidentRoleName(t, assignment.RoleType)}
+ {personName(assignment.UserId)}
+
+
+ {assignment.IncidentRoleAssignmentId.startsWith('local-') ? : null}
+ removeRole(boardState.callId, assignment.IncidentRoleAssignmentId)} className="p-2" testID={`assignment-remove-${assignment.IncidentRoleAssignmentId}`}>
+
+
+
+
+ ))}
+
+
+ {/* Incident resources — department units/personnel plus external (ad-hoc) entries */}
+ setIsResourceSheetOpen(true)}
+ testID="command-resources-section"
+ >
+ {deptAssignments.map((assignment) =>
+ isUnitKind(assignment.ResourceKind) ? (
+ releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)}
+ removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`}
+ roles={unitRoles.filter((role) => role.UnitId === assignment.ResourceId)}
+ status={unitCurrentStatuses.find((s) => s.UnitId === assignment.ResourceId)}
+ testID={`resource-dept-${assignment.ResourceAssignmentId}`}
+ unit={units.find((u) => u.UnitId === assignment.ResourceId)}
+ />
+ ) : (
+ releaseResourceAssignment(boardState.callId, assignment.ResourceAssignmentId)}
+ person={users.find((u) => u.UserId === assignment.ResourceId)}
+ removeTestID={`resource-dept-remove-${assignment.ResourceAssignmentId}`}
+ testID={`resource-dept-${assignment.ResourceAssignmentId}`}
+ />
+ )
+ )}
+ {boardState.adHocPersonnel.map((person) => (
+
+
+ {person.Name}
+ {[person.Role, person.ExternalAgencyName].filter(Boolean).join(' — ') || t('command.resource_person')}
+
+
+ {person.IncidentAdHocPersonnelId.startsWith('local-') ? : null}
+ releaseAdHocPersonnelEntry(boardState.callId, person.IncidentAdHocPersonnelId)} className="p-2" testID={`resource-person-remove-${person.IncidentAdHocPersonnelId}`}>
+
+
+
+
+ ))}
+ {boardState.adHocUnits.map((unit) => (
+
+
+ {unit.Name}
+ {unit.Type ? {unit.Type} : null}
+
+
+ {unit.IncidentAdHocUnitId.startsWith('local-') ? : null}
+ releaseAdHocUnitEntry(boardState.callId, unit.IncidentAdHocUnitId)} className="p-2" testID={`resource-remove-${unit.IncidentAdHocUnitId}`}>
+
+
+
+
+ ))}
+
+
+ {/* Accountability / PAR — computed server-side from check-in timers (read-only) */}
+
+
+
+ {t('command.accountability_section')}
+ ({accountability.length})
+
+
+
+
+ {accountability.length === 0 ? (
+ {t('command.no_accountability')}
+ ) : (
+
+ {accountability.map((entry) => (
+
+
+ {entry.FullName}
+ {entry.LastCheckIn ? `${t('command.last_check_in')}: ${getTimeAgoUtc(entry.LastCheckIn)}` : t('command.no_check_in_yet')}
+
+
+ {entry.Status === 'Green' ? t('command.par_green') : entry.Status === 'Warning' ? t('command.par_warning') : t('command.par_critical')}
+
+
+ ))}
+
+ )}
+
+
+ {/* Auto-logged, time-stamped incident log */}
+ fetchTimeline(boardState.callId)} />
+
+
+
+ setIsAssignmentSheetOpen(false)} onSave={(roleType, userId) => assignRole(boardState.callId, roleType, userId)} />
+ setIsResourceSheetOpen(false)}
+ units={units}
+ personnel={users}
+ unitCurrentStatuses={unitCurrentStatuses}
+ trackedUnitIds={trackedUnitIds}
+ trackedUserIds={trackedUserIds}
+ onAddUnit={handleAddDeptUnit}
+ onAddPersonnel={handleAddDeptPersonnel}
+ onSaveExternal={(kind, name, detail, agency) => (kind === 'person' ? addAdHocPersonnel(boardState.callId, name, detail, agency) : addAdHocUnit(boardState.callId, name, detail))}
+ />
+ setIsLaneSheetOpen(false)} onSave={(name, nodeType, color, limits) => addNode(boardState.callId, name, nodeType, color, limits)} />
+ setIsTransferSheetOpen(false)}
+ personnel={users}
+ currentCommanderUserId={boardState.board?.Command?.CurrentCommanderUserId}
+ onTransfer={handleTransferCommand}
+ />
+ setAssignTargetNodeId(null)}
+ options={resourceOptions}
+ resolveLaneName={laneName}
+ targetNodeId={assignTargetNodeId}
+ onSave={handleAssignResourceSave}
+ />
+
+ {/* "Already assigned to another lane" confirmation */}
+ setMoveConflict(null)}>
+
+
+
+ {t('command.move_conflict_title')}
+
+
+
+ {moveConflict ? t('command.move_conflict_message', { resource: moveConflict.resourceName, lane: moveConflict.fromLane, target: moveConflict.toLane }) : ''}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/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 && (
-