diff --git a/.claude/escalations/.gitkeep b/.claude/escalations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.lint-baselines.json b/.lint-baselines.json new file mode 100644 index 00000000..419b64b5 --- /dev/null +++ b/.lint-baselines.json @@ -0,0 +1,12 @@ +{ + "@bantayog/shared-types": 0, + "@bantayog/citizen-pwa": 0, + "@bantayog/shared-sms-parser": 0, + "@bantayog/responder-app": 0, + "@bantayog/shared-ui": 0, + "@bantayog/functions": 0, + "@bantayog/admin-desktop": 0, + "@bantayog/shared-validators": 0, + "@bantayog/shared-data": 0, + "@bantayog/e2e-tests": 0 +} diff --git a/apps/admin-desktop/package.json b/apps/admin-desktop/package.json index fdf2845d..6689d6a8 100644 --- a/apps/admin-desktop/package.json +++ b/apps/admin-desktop/package.json @@ -8,7 +8,8 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "lint": "eslint src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@bantayog/shared-types": "workspace:*", @@ -21,6 +22,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx new file mode 100644 index 00000000..9d0898ec --- /dev/null +++ b/apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const mockInitiateHandoff = vi.hoisted(() => vi.fn()) + +vi.mock('../app/firebase', () => ({ db: {} })) + +vi.mock('@bantayog/shared-ui', () => ({ + useAuth: () => ({ + claims: { municipalityId: 'daet', role: 'municipal_admin' }, + signOut: vi.fn(), + }), +})) + +vi.mock('../services/callables', () => ({ + callables: { + verifyReport: vi.fn(), + rejectReport: vi.fn(), + initiateShiftHandoff: mockInitiateHandoff, + acceptShiftHandoff: vi.fn(), + }, +})) + +vi.mock('../hooks/useMuniReports', () => ({ + useMuniReports: () => ({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }), +})) + +vi.mock('../hooks/usePendingHandoffs', () => ({ + usePendingHandoffs: () => [], +})) + +vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () =>
detail
})) +vi.mock('../pages/DispatchModal', () => ({ DispatchModal: () =>
dispatch
})) +vi.mock('../pages/CloseReportModal', () => ({ CloseReportModal: () =>
close
})) + +import { TriageQueuePage } from '../pages/TriageQueuePage' + +describe('ShiftHandoffModal', () => { + beforeEach(() => { + mockInitiateHandoff.mockResolvedValue({ success: true, handoffId: 'h-new-1' }) + }) + + it('renders Start Handoff button in header', () => { + render() + expect(screen.getByRole('button', { name: /start handoff/i })).toBeInTheDocument() + }) + + it('opens ShiftHandoffModal on Start Handoff click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /start handoff/i })) + expect(screen.getByRole('dialog', { name: /shift handoff/i })).toBeInTheDocument() + }) + + it('calls initiateShiftHandoff on Initiate click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /start handoff/i })) + const notesField = screen.getByLabelText(/notes/i) + await user.type(notesField, 'End of day shift') + await user.click(screen.getByRole('button', { name: /initiate/i })) + expect(mockInitiateHandoff).toHaveBeenCalledWith( + expect.objectContaining({ notes: 'End of day shift' }), + ) + }) +}) + +describe('Incoming handoff banner', () => { + it('shows no banner when no pending handoffs', () => { + render() + expect(screen.queryByRole('button', { name: /accept handoff/i })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin-desktop/src/__tests__/triage-queue.test.tsx b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx new file mode 100644 index 00000000..127ffc15 --- /dev/null +++ b/apps/admin-desktop/src/__tests__/triage-queue.test.tsx @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const mockUseMuniReports = vi.fn() + +vi.mock('../hooks/useMuniReports', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + useMuniReports: (...args: unknown[]) => mockUseMuniReports(...args), +})) + +vi.mock('../app/firebase', () => ({ + db: {}, +})) + +vi.mock('@bantayog/shared-ui', () => ({ + useAuth: () => ({ + claims: { municipalityId: 'daet', role: 'municipal_admin' }, + signOut: vi.fn(), + }), +})) + +vi.mock('../services/callables', () => ({ + callables: { + verifyReport: vi.fn(), + rejectReport: vi.fn(), + }, +})) + +vi.mock('../hooks/usePendingHandoffs', () => ({ + usePendingHandoffs: () => [], +})) + +vi.mock('../pages/ReportDetailPanel', () => ({ + ReportDetailPanel: () =>
detail
, +})) +vi.mock('../pages/DispatchModal', () => ({ + DispatchModal: () =>
dispatch
, +})) +vi.mock('../pages/CloseReportModal', () => ({ + CloseReportModal: () =>
close
, +})) + +import { TriageQueuePage } from '../pages/TriageQueuePage' + +describe('TriageQueuePage', () => { + beforeEach(() => { + mockUseMuniReports.mockReturnValue({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + }) + + it('renders Load More button when hasMore is true', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: true, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument() + }) + + it('does not render Load More button when hasMore is false', () => { + render() + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument() + }) + + it('calls loadMore when Load More is clicked', () => { + const loadMore = vi.fn() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: true, + loadMore, + loading: false, + error: null, + }) + render() + fireEvent.click(screen.getByRole('button', { name: /load more/i })) + expect(loadMore).toHaveBeenCalledTimes(1) + }) + + it('shows Showing X count', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: true, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByText(/showing 2/i)).toBeInTheDocument() + }) + + it('renders severity from severity field, not severityDerived', () => { + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + expect(screen.getByText(/high/i)).toBeInTheDocument() + }) + + it('pressing j selects the next report in the list', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('j') + expect(screen.getByText('detail')).toBeInTheDocument() + }) + + it('pressing k moves selection backward', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [ + { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' }, + { + reportId: 'r2', + status: 'new', + severity: 'medium', + createdAt: null, + municipalityLabel: '', + }, + ], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('jj') + await user.keyboard('k') + expect(screen.getByText('detail')).toBeInTheDocument() + }) + + it('keyboard shortcuts do not fire when a modal is open', async () => { + const user = userEvent.setup() + mockUseMuniReports.mockReturnValue({ + reports: [], + hasMore: false, + loadMore: vi.fn(), + loading: false, + error: null, + }) + render() + await user.keyboard('j') + await user.keyboard('k') + expect(screen.queryByText('detail')).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin-desktop/src/hooks/useMuniReports.ts b/apps/admin-desktop/src/hooks/useMuniReports.ts index 2f95d6ed..ab455a43 100644 --- a/apps/admin-desktop/src/hooks/useMuniReports.ts +++ b/apps/admin-desktop/src/hooks/useMuniReports.ts @@ -1,25 +1,44 @@ import { useEffect, useState } from 'react' -import { collection, onSnapshot, query, where, orderBy, limit, Timestamp } from 'firebase/firestore' +import { + collection, + onSnapshot, + query, + where, + orderBy, + limit, + type Timestamp, +} from 'firebase/firestore' import { db } from '../app/firebase' +import type { ReportStatus, Severity } from '@bantayog/shared-types' export interface MuniReportRow { reportId: string - status: string - severityDerived: string + status: ReportStatus + severity: Severity + reportType?: string + duplicateClusterId?: string + barangayId?: string createdAt: Timestamp municipalityLabel: string } +const ACTIVE_STATUSES: ReportStatus[] = ['new', 'awaiting_verify', 'verified', 'assigned'] + export function useMuniReports(municipalityId: string | undefined) { - const [rows, setRows] = useState([]) + const [limitCount, setLimitCount] = useState(100) + const [reports, setReports] = useState([]) + const [hasMore, setHasMore] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { if (!municipalityId) { queueMicrotask(() => { - setRows([]) + setReports([]) setLoading(false) + setLimitCount(100) + setHasMore(false) + setError(null) }) return } @@ -27,27 +46,32 @@ export function useMuniReports(municipalityId: string | undefined) { setLoading(true) }) const q = query( - collection(db, 'reports'), + collection(db, 'report_ops'), where('municipalityId', '==', municipalityId), - where('status', 'in', ['new', 'awaiting_verify', 'verified', 'assigned']), + where('status', 'in', ACTIVE_STATUSES), orderBy('createdAt', 'desc'), - limit(100), + limit(limitCount + 1), ) const unsub = onSnapshot( q, (snap) => { - setRows( - snap.docs.map((d) => { - const data = d.data() - return { - reportId: d.id, - status: String(data.status), - severityDerived: String(data.severityDerived ?? 'medium'), - createdAt: data.createdAt as Timestamp, - municipalityLabel: String(data.municipalityLabel ?? ''), - } - }), - ) + const all = snap.docs.map((d) => { + const data = d.data() + const row: MuniReportRow = { + reportId: d.id, + status: String(data.status) as ReportStatus, + severity: String(data.severity ?? 'medium') as Severity, + createdAt: data.createdAt as Timestamp, + municipalityLabel: String(data.municipalityLabel ?? ''), + } + if (data.reportType !== undefined) row.reportType = String(data.reportType) + if (data.duplicateClusterId !== undefined) + row.duplicateClusterId = String(data.duplicateClusterId) + if (data.barangayId !== undefined) row.barangayId = String(data.barangayId) + return row + }) + setHasMore(all.length > limitCount) + setReports(all.slice(0, limitCount)) setLoading(false) }, (err) => { @@ -56,7 +80,15 @@ export function useMuniReports(municipalityId: string | undefined) { }, ) return unsub - }, [municipalityId]) + }, [municipalityId, limitCount]) - return { rows, loading, error } + return { + reports, + hasMore, + loadMore: () => { + setLimitCount((n) => n + 100) + }, + loading, + error, + } } diff --git a/apps/admin-desktop/src/hooks/usePendingHandoffs.ts b/apps/admin-desktop/src/hooks/usePendingHandoffs.ts new file mode 100644 index 00000000..17b6ac19 --- /dev/null +++ b/apps/admin-desktop/src/hooks/usePendingHandoffs.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { Timestamp, collection, onSnapshot, query, where } from 'firebase/firestore' +import { db } from '../app/firebase' + +export interface PendingHandoff { + id: string + fromUid: string + createdAt: Timestamp + notes: string + activeIncidentIds: string[] +} + +export function usePendingHandoffs(municipalityId: string | undefined) { + const [handoffs, setHandoffs] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!municipalityId) { + queueMicrotask(() => { + setHandoffs([]) + setError(null) + }) + return + } + + // Clear before subscribing + queueMicrotask(() => { + setHandoffs([]) + setError(null) + }) + + const q = query( + collection(db, 'shift_handoffs'), + where('municipalityId', '==', municipalityId), + where('status', '==', 'pending'), + ) + return onSnapshot( + q, + (snap) => { + setHandoffs( + snap.docs.map((d) => { + const raw = d.data() + const activeIncidentIds = Array.isArray(raw.activeIncidentIds) + ? raw.activeIncidentIds.filter((id): id is string => typeof id === 'string') + : [] + return { + id: d.id, + fromUid: typeof raw.fromUid === 'string' ? raw.fromUid : '', + createdAt: raw.createdAt instanceof Timestamp ? raw.createdAt : Timestamp.now(), + notes: typeof raw.notes === 'string' ? raw.notes : '', + activeIncidentIds, + } + }), + ) + setError(null) + }, + (err) => { + setHandoffs([]) // Clear on error + setError(err.message) + }, + ) + }, [municipalityId]) + + return { handoffs, error } +} diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx index 80f60a8c..8d64cf34 100644 --- a/apps/admin-desktop/src/pages/TriageQueuePage.tsx +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -1,20 +1,37 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAuth } from '@bantayog/shared-ui' -import { useMuniReports } from '../hooks/useMuniReports' +import { useMuniReports, type MuniReportRow } from '../hooks/useMuniReports' import { ReportDetailPanel } from './ReportDetailPanel' import { DispatchModal } from './DispatchModal' import { CloseReportModal } from './CloseReportModal' import { callables } from '../services/callables' +import { usePendingHandoffs } from '../hooks/usePendingHandoffs' export function TriageQueuePage() { const { claims, signOut } = useAuth() const municipalityId = typeof claims?.municipalityId === 'string' ? claims.municipalityId : undefined - const { rows, loading, error } = useMuniReports(municipalityId) + const { reports, hasMore, loadMore, loading, error } = useMuniReports(municipalityId) const [selected, setSelected] = useState(null) const [dispatchForReportId, setDispatchForReportId] = useState(null) const [closeForReportId, setCloseForReportId] = useState(null) const [banner, setBanner] = useState(null) + const [handoffModalOpen, setHandoffModalOpen] = useState(false) + const [handoffNotes, setHandoffNotes] = useState('') + const [handoffLoading, setHandoffLoading] = useState(false) + const [rejectReason, setRejectReason] = useState('') + const [rejectingReportId, setRejectingReportId] = useState(null) + const [acceptingHandoffId, setAcceptingHandoffId] = useState(null) + const { handoffs: pendingHandoffs, error: handoffsError } = usePendingHandoffs(municipalityId) + const dialogRef = useRef(null) + + useEffect(() => { + if (handoffModalOpen) { + dialogRef.current?.showModal() + } else { + dialogRef.current?.close() + } + }, [handoffModalOpen]) const handleVerify = (reportId: string) => { void (async () => { @@ -27,35 +44,124 @@ export function TriageQueuePage() { })() } + const VALID_REJECT_REASONS = [ + 'obviously_false', + 'duplicate', + 'test_submission', + 'insufficient_detail', + ] as const + const handleReject = (reportId: string) => { - const reason = prompt( - 'Reject reason (obviously_false, duplicate, test_submission, insufficient_detail)?', - ) - if (!reason) return - void (async () => { - try { - await callables.rejectReport({ - reportId, - reason: reason as - | 'obviously_false' - | 'duplicate' - | 'test_submission' - | 'insufficient_detail', - idempotencyKey: crypto.randomUUID(), - }) - } catch (err: unknown) { - setBanner(err instanceof Error ? err.message : 'Reject failed') - } - })() + setRejectingReportId(reportId) + setRejectReason('') + } + + const confirmReject = async () => { + if (!rejectingReportId) return + if (!VALID_REJECT_REASONS.includes(rejectReason as (typeof VALID_REJECT_REASONS)[number])) { + setBanner('Invalid reject reason') + return + } + try { + await callables.rejectReport({ + reportId: rejectingReportId, + reason: rejectReason as (typeof VALID_REJECT_REASONS)[number], + idempotencyKey: crypto.randomUUID(), + }) + setRejectingReportId(null) + setRejectReason('') + } catch (err: unknown) { + setBanner(err instanceof Error ? err.message : 'Reject failed') + } } + const indexRef = useRef(-1) + const modalOpen = !!dispatchForReportId || !!closeForReportId || handoffModalOpen + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setDispatchForReportId(null) + setCloseForReportId(null) + setHandoffModalOpen(false) + return + } + if (modalOpen) return + if (e.key === 'j') { + const next = Math.min(indexRef.current + 1, reports.length - 1) + if (next >= 0) { + indexRef.current = next + setSelected(reports[next]?.reportId ?? null) + } + } else if (e.key === 'k') { + const prev = Math.max(indexRef.current - 1, 0) + if (prev >= 0 && reports.length > 0) { + indexRef.current = prev + setSelected(reports[prev]?.reportId ?? null) + } + } + } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + } + }, [modalOpen, reports]) + return (

Triage · {municipalityId ?? 'N/A'}

- + +
{banner &&
{banner}
} + {handoffsError &&
Handoffs error: {handoffsError}
} + {pendingHandoffs.length > 0 && ( +
+ {pendingHandoffs.length} pending handoff(s) awaiting acceptance. + {pendingHandoffs.map((h) => ( + + ))} +
+ )}

Queue

@@ -63,25 +169,67 @@ export function TriageQueuePage() {

Loading…

) : error ? (

Error: {error}

- ) : rows.length === 0 ? ( + ) : reports.length === 0 ? (

No active reports.

) : ( -
    - {rows.map((r) => ( -
  • - -
  • - ))} -
+ <> +

+ Showing {reports.length} + {hasMore ? '+' : ''} reports +

+
    + {reports.map((r: MuniReportRow, i: number) => ( +
  • + +
  • + ))} +
+ {hasMore && } + )}
- {selected && ( + {rejectingReportId ? ( +
+

Reject Report

+ + + + +
+ ) : selected ? ( - )} + ) : null}
{dispatchForReportId && ( )} + {handoffModalOpen && ( + { + setHandoffModalOpen(false) + }} + > +

Initiate Shift Handoff

+ +