diff --git a/apps/admin-desktop/package.json b/apps/admin-desktop/package.json index 6689d6a8..8476157e 100644 --- a/apps/admin-desktop/package.json +++ b/apps/admin-desktop/package.json @@ -14,6 +14,8 @@ "dependencies": { "@bantayog/shared-types": "workspace:*", "@bantayog/shared-ui": "workspace:*", + "@bantayog/shared-validators": "workspace:*", + "@tanstack/react-query": "^5.99.2", "firebase": "^12.12.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -27,7 +29,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "happy-dom": "^15.11.0", - "vitest": "^4.1.4", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" } } diff --git a/apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx b/apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx new file mode 100644 index 00000000..eb0404d7 --- /dev/null +++ b/apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('../app/firebase', () => ({ db: {} })) +vi.mock('@bantayog/shared-ui', () => ({ + useAuth: () => ({ + claims: { municipalityId: 'daet', role: 'municipal_admin' }, + signOut: vi.fn(), + }), +})) + +const { mockGetCountFromServer, mockGetDocs, mockGetDoc, mockDoc, mockWhere } = vi.hoisted(() => ({ + mockGetCountFromServer: vi.fn(), + mockGetDocs: vi.fn(), + mockGetDoc: vi.fn(), + mockDoc: vi.fn(() => ({})), + mockWhere: vi.fn(() => ({})), +})) + +vi.mock('firebase/firestore', () => ({ + getFirestore: vi.fn(() => ({})), + getCountFromServer: mockGetCountFromServer, + collection: vi.fn(() => ({})), + query: vi.fn(() => ({})), + where: mockWhere, + orderBy: vi.fn(() => ({})), + limit: vi.fn(() => ({})), + getDocs: mockGetDocs, + getDoc: mockGetDoc, + doc: mockDoc, +})) + +import { AnalyticsDashboardPage } from '../pages/AnalyticsDashboardPage' + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {children} +} + +describe('AnalyticsDashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetCountFromServer.mockResolvedValue({ data: () => ({ count: 42 }) }) + mockGetDocs.mockResolvedValue({ docs: [] }) + mockGetDoc.mockResolvedValue({ exists: () => false }) + mockWhere.mockReturnValue({}) + }) + + it('renders the live active-incidents count', async () => { + render(, { wrapper }) + expect(await screen.findByText('42')).toBeInTheDocument() + }) + + it('shows a loading state while analytics count is fetching', () => { + mockGetCountFromServer.mockImplementationOnce( + () => + new Promise(() => { + /* never resolves */ + }), + ) + render(, { wrapper }) + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + + it('shows trend loading state while snapshot data is fetching', async () => { + mockGetDocs.mockImplementationOnce( + () => + new Promise(() => { + /* never resolves */ + }), + ) + render(, { wrapper }) + expect(await screen.findByText('Loading trend…')).toBeInTheDocument() + }) + + it("scopes data to the caller's municipalityId for muni admins", async () => { + render(, { wrapper }) + expect(await screen.findByText(/daet/i)).toBeInTheDocument() + // Verify the live-count query included the municipalityId filter. + const hasMuniFilter = (mockWhere.mock.calls as unknown[][]).some( + (args) => args[0] === 'municipalityId' && args[1] === '==' && args[2] === 'daet', + ) + expect(hasMuniFilter).toBe(true) + }) + + it('renders a trend chart when snapshots are present', async () => { + mockGetDocs.mockResolvedValueOnce({ + docs: [ + { + id: '2026-04-20', + data: () => ({ + reportsByStatus: { verified: 5, closed: 2 }, + }), + }, + ], + }) + mockGetDoc.mockResolvedValueOnce({ + exists: () => true, + data: () => ({ reportsByStatus: { verified: 5, closed: 2 } }), + }) + render(, { wrapper }) + expect(await screen.findByLabelText('7-day trend chart')).toBeInTheDocument() + expect(screen.getByLabelText(/2026-04-20: 7 reports/)).toBeInTheDocument() + }) +}) diff --git a/apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx b/apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx new file mode 100644 index 00000000..bbfc4877 --- /dev/null +++ b/apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('../app/firebase', () => ({ db: {} })) + +const mockPreview = vi.hoisted(() => vi.fn()) +const mockSend = vi.hoisted(() => vi.fn()) +const mockEscalate = vi.hoisted(() => vi.fn()) + +vi.mock('../services/callables', () => ({ + callables: { + massAlertReachPlanPreview: mockPreview, + sendMassAlert: mockSend, + requestMassAlertEscalation: mockEscalate, + }, +})) + +import { MassAlertModal } from '../pages/MassAlertModal' + +const DIRECT_PLAN = { + route: 'direct', + fcmCount: 200, + smsCount: 150, + segmentCount: 1, + unicodeWarning: false, +} +const NDRRMC_PLAN = { + route: 'ndrrmc_escalation', + fcmCount: 6000, + smsCount: 2000, + segmentCount: 1, + unicodeWarning: false, +} + +describe('MassAlertModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPreview.mockResolvedValue(DIRECT_PLAN) + mockSend.mockResolvedValue({ requestId: 'req-1' }) + mockEscalate.mockResolvedValue({ requestId: 'req-2' }) + }) + + it('shows GSM-7 indicator and correct segment count for ASCII message', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'ALERT: Typhoon warning') + expect(screen.getByText(/GSM-7/i)).toBeInTheDocument() + }) + + it('shows UCS-2 warning when message contains unicode characters', async () => { + const user = userEvent.setup() + mockPreview.mockResolvedValue({ ...DIRECT_PLAN, unicodeWarning: true }) + render() + await user.type(screen.getByLabelText(/message/i), 'Alerto sa ñ lugar') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + expect(await screen.findByText(/⚠ UCS-2 \(multi-byte\)/i)).toBeInTheDocument() + }) + + it('shows Preview Reach button', () => { + render() + expect(screen.getByRole('button', { name: /preview reach/i })).toBeInTheDocument() + }) + + it('shows fcmCount and smsCount after preview loads', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test alert') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + expect(await screen.findByText(/200/)).toBeInTheDocument() + expect(screen.getByText(/150/)).toBeInTheDocument() + }) + + it('shows Direct Send badge when route is direct', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + expect(await screen.findByText(/direct/i)).toBeInTheDocument() + }) + + it('shows NDRRMC Escalation badge when route is ndrrmc_escalation', async () => { + mockPreview.mockResolvedValue(NDRRMC_PLAN) + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + expect(await screen.findByText(/NDRRMC escalation required/i)).toBeInTheDocument() + }) + + it('disables Send button when route is ndrrmc_escalation', async () => { + mockPreview.mockResolvedValue(NDRRMC_PLAN) + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + await screen.findByText(/NDRRMC escalation required/i) + expect(screen.getByRole('button', { name: /^send alert$/i })).toBeDisabled() + }) + + it('shows Request NDRRMC Escalation button when route is ndrrmc_escalation', async () => { + mockPreview.mockResolvedValue(NDRRMC_PLAN) + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + expect( + await screen.findByRole('button', { name: /request ndrrmc escalation/i }), + ).toBeInTheDocument() + }) + + it('calls sendMassAlert on Send click (direct path)', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test alert') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + await screen.findByText(/200/) + await user.click(screen.getByRole('button', { name: /^send alert$/i })) + expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Test alert', + targetScope: expect.objectContaining({ municipalityIds: ['daet'] }), + }), + ) + }) + + it('calls requestMassAlertEscalation on escalation CTA click', async () => { + mockPreview.mockResolvedValue(NDRRMC_PLAN) + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/message/i), 'Test') + await user.click(screen.getByRole('button', { name: /preview reach/i })) + await user.click(await screen.findByRole('button', { name: /request ndrrmc escalation/i })) + expect(mockEscalate).toHaveBeenCalledTimes(1) + expect(mockEscalate).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Test', + targetScope: expect.objectContaining({ municipalityIds: ['daet'] }), + }), + ) + }) +}) diff --git a/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx index 9d0898ec..5810b676 100644 --- a/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx +++ b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx @@ -33,7 +33,7 @@ vi.mock('../hooks/useMuniReports', () => ({ })) vi.mock('../hooks/usePendingHandoffs', () => ({ - usePendingHandoffs: () => [], + usePendingHandoffs: () => ({ handoffs: [], error: null }), })) vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () =>
detail
})) diff --git a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx index 127ffc15..d77ff58a 100644 --- a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx +++ b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx @@ -28,7 +28,7 @@ vi.mock('../services/callables', () => ({ })) vi.mock('../hooks/usePendingHandoffs', () => ({ - usePendingHandoffs: () => [], + usePendingHandoffs: () => ({ handoffs: [], error: null }), })) vi.mock('../pages/ReportDetailPanel', () => ({ diff --git a/apps/admin-desktop/src/hooks/useMuniReports.ts b/apps/admin-desktop/src/hooks/useMuniReports.ts index ab455a43..980f8310 100644 --- a/apps/admin-desktop/src/hooks/useMuniReports.ts +++ b/apps/admin-desktop/src/hooks/useMuniReports.ts @@ -10,6 +10,7 @@ import { } from 'firebase/firestore' import { db } from '../app/firebase' import type { ReportStatus, Severity } from '@bantayog/shared-types' +import { ACTIVE_REPORT_STATUSES } from '@bantayog/shared-types' export interface MuniReportRow { reportId: string @@ -22,8 +23,6 @@ export interface MuniReportRow { municipalityLabel: string } -const ACTIVE_STATUSES: ReportStatus[] = ['new', 'awaiting_verify', 'verified', 'assigned'] - export function useMuniReports(municipalityId: string | undefined) { const [limitCount, setLimitCount] = useState(100) const [reports, setReports] = useState([]) @@ -48,7 +47,7 @@ export function useMuniReports(municipalityId: string | undefined) { const q = query( collection(db, 'report_ops'), where('municipalityId', '==', municipalityId), - where('status', 'in', ACTIVE_STATUSES), + where('status', 'in', ACTIVE_REPORT_STATUSES), orderBy('createdAt', 'desc'), limit(limitCount + 1), ) diff --git a/apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx b/apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx new file mode 100644 index 00000000..a0dae363 --- /dev/null +++ b/apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx @@ -0,0 +1,106 @@ +import { useQuery } from '@tanstack/react-query' +import { + collection, + query, + where, + getCountFromServer, + getDocs, + getDoc, + doc, + orderBy, + limit, +} from 'firebase/firestore' +import { db } from '../app/firebase' +import { useAuth } from '@bantayog/shared-ui' +import { ACTIVE_REPORT_STATUSES } from '@bantayog/shared-types' + +export function AnalyticsDashboardPage() { + const { claims } = useAuth() + const municipalityId = + typeof claims?.municipalityId === 'string' ? claims.municipalityId : undefined + + const { data: activeCount, isLoading } = useQuery({ + queryKey: ['analytics', 'activeCount', municipalityId], + queryFn: async () => { + const q = query( + collection(db, 'report_ops'), + ...(municipalityId ? [where('municipalityId', '==', municipalityId)] : []), + where('status', 'in', ACTIVE_REPORT_STATUSES), + ) + const snap = await getCountFromServer(q) + return snap.data().count + }, + refetchInterval: 30_000, + }) + + const { data: snapshots, isLoading: isSnapshotsLoading } = useQuery({ + queryKey: ['analytics', 'snapshots', municipalityId], + queryFn: async () => { + const q = query(collection(db, 'analytics_snapshots'), orderBy('__name__', 'desc'), limit(7)) + const dateDocs = await getDocs(q) + const scopeId = municipalityId ?? 'province' + const rows = await Promise.all( + dateDocs.docs.map(async (d) => { + const summaryRef = doc(db, 'analytics_snapshots', d.id, scopeId, 'summary') + const summarySnap = await getDoc(summaryRef) + return summarySnap.exists() ? { date: d.id, ...summarySnap.data() } : null + }), + ) + return rows.filter( + (r): r is { date: string; reportsByStatus?: Record } => r !== null, + ) + }, + refetchInterval: 60_000, + }) + + const maxSnapshotTotal = + snapshots && snapshots.length > 0 + ? Math.max( + 1, + ...snapshots.map((s) => + Object.values(s.reportsByStatus ?? {}).reduce((a, v) => a + v, 0), + ), + ) + : 1 + + if (isLoading) return

Loading analytics…

+ + return ( +
+

Analytics · {municipalityId ?? 'Province'}

+
+

Live Active Incidents

+

{activeCount ?? '—'}

+
+
+

7-Day Trend

+ {isSnapshotsLoading ? ( +

Loading trend…

+ ) : snapshots && snapshots.length > 0 ? ( + + {snapshots.map( + (s: { date: string; reportsByStatus?: Record }, i: number) => { + const statusMap = s.reportsByStatus ?? {} + const total = Object.values(statusMap).reduce((acc, v) => acc + v, 0) + const barH = Math.round((total / maxSnapshotTotal) * 70) + return ( + + ) + }, + )} + + ) : ( +

No snapshot data yet.

+ )} +
+
+ ) +} diff --git a/apps/admin-desktop/src/pages/MassAlertModal.tsx b/apps/admin-desktop/src/pages/MassAlertModal.tsx new file mode 100644 index 00000000..53511bef --- /dev/null +++ b/apps/admin-desktop/src/pages/MassAlertModal.tsx @@ -0,0 +1,186 @@ +import { useRef, useState } from 'react' +import { detectEncoding } from '@bantayog/shared-validators' +import { callables } from '../services/callables' + +interface ReachPlan { + route: 'direct' | 'ndrrmc_escalation' + fcmCount: number + smsCount: number + segmentCount: number + unicodeWarning: boolean +} + +interface Props { + municipalityId: string + onClose: () => void +} + +export function MassAlertModal({ municipalityId, onClose }: Props) { + const sendKeyRef = useRef(crypto.randomUUID()) + const escalateKeyRef = useRef(crypto.randomUUID()) + const [message, setMessage] = useState('') + const [reachPlan, setReachPlan] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [pagasaSignalRef, setPagasaSignalRef] = useState('') + const [notes, setNotes] = useState('') + + const normalizedMessage = message.trim() + const normalizedPagasaSignalRef = pagasaSignalRef.trim() + const normalizedNotes = notes.trim() + + const encoding = message ? detectEncoding(message).encoding : 'GSM-7' + + const handlePreview = () => { + if (!normalizedMessage) { + setError('Message cannot be empty') + return + } + setError(null) + setLoading(true) + void (async () => { + try { + const plan = await callables.massAlertReachPlanPreview({ + targetScope: { municipalityIds: [municipalityId] }, + message: normalizedMessage, + }) + setReachPlan(plan) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Preview failed') + } finally { + setLoading(false) + } + })() + } + + const handleSend = () => { + if (!reachPlan?.route || reachPlan.route !== 'direct') { + setError('Direct send is not available for this alert scope') + return + } + setError(null) + setLoading(true) + void (async () => { + try { + await callables.sendMassAlert({ + reachPlan, + message: normalizedMessage, + targetScope: { municipalityIds: [municipalityId] }, + idempotencyKey: sendKeyRef.current, + }) + onClose() + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Send failed') + } finally { + setLoading(false) + } + })() + } + + const handleEscalate = () => { + if (normalizedNotes.length > 2000) { + setError('Notes must be 2000 characters or fewer') + return + } + setError(null) + setLoading(true) + void (async () => { + try { + await callables.requestMassAlertEscalation({ + message: normalizedMessage, + targetScope: { municipalityIds: [municipalityId] }, + evidencePack: { + linkedReportIds: [], + ...(normalizedPagasaSignalRef ? { pagasaSignalRef: normalizedPagasaSignalRef } : {}), + ...(normalizedNotes ? { notes: normalizedNotes } : {}), + }, + idempotencyKey: escalateKeyRef.current, + }) + onClose() + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Escalation failed') + } finally { + setLoading(false) + } + })() + } + + return ( + +

Issue Mass Alert

+

+ Every surface that references this flow must say "Escalation submitted to NDRRMC" + — never "Alert sent via ECBS." +

+ {error &&

{error}

} + +