Vite/React 19 console and enterprise PWA. Officer console screens, offline ledger and sync, data feeds and trip-wire rules screens with freshness chip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
171 lines
6.1 KiB
TypeScript
171 lines
6.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { officerSession } from '../../lib/api'
|
|
import type { FieldVisit, VisitState } from '../../lib/api'
|
|
import {
|
|
ActionChip,
|
|
Empty,
|
|
Field,
|
|
GhostBtn,
|
|
KpiTile,
|
|
Modal,
|
|
PrimaryBtn,
|
|
StatusPill,
|
|
inputCls,
|
|
} from '../../components/ui/bits'
|
|
import { todayIso } from '../../lib/demo/seed'
|
|
import { logOutcome, useVisits } from '../../lib/visitStore'
|
|
import { useScope } from './officerScope'
|
|
|
|
const FILTERS: { key: VisitState | 'ALL'; label: string }[] = [
|
|
{ key: 'ALL', label: 'All' },
|
|
{ key: 'PLANNED', label: 'Planned' },
|
|
{ key: 'OVERDUE', label: 'Overdue' },
|
|
{ key: 'DONE', label: 'Done' },
|
|
]
|
|
|
|
/**
|
|
* The field officer playbook: ranked interventions with due dates. Logging an
|
|
* outcome closes the loop the deck's process flow describes — the outcome and
|
|
* any override feed the next retraining cycle.
|
|
*/
|
|
export function VisitsPage() {
|
|
const navigate = useNavigate()
|
|
const { block } = useScope()
|
|
const officer = officerSession()?.fullName ?? 'Field Officer'
|
|
const { visits } = useVisits(block, officer)
|
|
const [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
|
|
const [logging, setLogging] = useState<FieldVisit | null>(null)
|
|
|
|
const rows = visits
|
|
.filter((v) => filter === 'ALL' || v.state === filter)
|
|
.sort((a, b) => a.priority - b.priority || a.dueDate.localeCompare(b.dueDate))
|
|
|
|
const done = visits.filter((v) => v.state === 'DONE').length
|
|
const overdue = visits.filter((v) => v.state === 'OVERDUE').length
|
|
const planned = visits.filter((v) => v.state === 'PLANNED').length
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap gap-3 mb-4">
|
|
<KpiTile label="Interventions in queue" value={visits.length} />
|
|
<KpiTile label="Planned" value={planned} tone="watch" />
|
|
<KpiTile label="Overdue" value={overdue} tone="red" />
|
|
<KpiTile label="Completed" value={done} tone="good" />
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5 mb-3">
|
|
{FILTERS.map((f) => (
|
|
<ActionChip key={f.key} active={filter === f.key} onClick={() => setFilter(f.key)}>
|
|
{f.label}
|
|
</ActionChip>
|
|
))}
|
|
<span className="text-[11px] text-slate-500 ml-1">{rows.length} shown</span>
|
|
</div>
|
|
|
|
<div className="bg-white border border-slate-200 rounded-[11px] overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="ul-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Priority</th>
|
|
<th>Enterprise</th>
|
|
<th>Recommended action</th>
|
|
<th>Block</th>
|
|
<th>Due</th>
|
|
<th>State</th>
|
|
<th>Outcome</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((v) => (
|
|
<tr key={v.visitId}>
|
|
<td>
|
|
<span className={`pill ${v.priority === 1 ? 'pill-bad' : 'pill-pend'}`}>
|
|
{v.priority === 1 ? 'Act first' : 'Routine'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<button
|
|
className="font-semibold text-forest hover:underline text-left"
|
|
onClick={() => navigate(`/officer/enterprises/${v.enterpriseId}`)}
|
|
>
|
|
{v.enterpriseName}
|
|
</button>
|
|
<span className="block font-mono text-[10px] text-slate-400">{v.enterpriseId}</span>
|
|
</td>
|
|
<td className="whitespace-normal max-w-[280px] text-slate-700">{v.action}</td>
|
|
<td className="text-slate-600">{v.block}</td>
|
|
<td className={v.state === 'OVERDUE' ? 'text-risk-red font-semibold' : 'text-slate-600'}>
|
|
{v.dueDate}
|
|
</td>
|
|
<td>
|
|
<StatusPill status={v.state} />
|
|
</td>
|
|
<td className="whitespace-normal max-w-[280px] text-slate-500">{v.outcome ?? '—'}</td>
|
|
<td className="text-right">
|
|
{v.state !== 'DONE' && <GhostBtn onClick={() => setLogging(v)}>Log outcome</GhostBtn>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{!rows.length && <Empty text="No visits in this state." />}
|
|
</div>
|
|
|
|
{logging && <LogOutcomeModal visit={logging} onClose={() => setLogging(null)} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const PRESET_OUTCOMES = [
|
|
'Stock and receivables verified against the ledger.',
|
|
'Instalment date moved to align with the earning season.',
|
|
'Working-capital top-up recommended to the branch.',
|
|
'Owner unavailable — revisit required.',
|
|
'No action needed; figures explained by a one-off event.',
|
|
]
|
|
|
|
function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () => void }) {
|
|
const [preset, setPreset] = useState(PRESET_OUTCOMES[0])
|
|
const [note, setNote] = useState('')
|
|
|
|
function save() {
|
|
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset)
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<Modal title={`Log outcome — ${visit.enterpriseName}`} onClose={onClose}>
|
|
<div className="text-[11.5px] text-slate-500 mb-4">
|
|
{visit.visitId} · {visit.action} · due {visit.dueDate}
|
|
</div>
|
|
|
|
<Field label="Outcome" className="mb-3.5">
|
|
<select className={inputCls} value={preset} onChange={(e) => setPreset(e.target.value)}>
|
|
{PRESET_OUTCOMES.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<Field
|
|
label="Note (optional)"
|
|
className="mb-5"
|
|
hint={`Recorded against ${todayIso()} and fed into the next retraining cycle.`}
|
|
>
|
|
<textarea className={`${inputCls} min-h-[70px]`} value={note} onChange={(e) => setNote(e.target.value)} />
|
|
</Field>
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<GhostBtn onClick={onClose}>Cancel</GhostBtn>
|
|
<PrimaryBtn onClick={save}>Mark done</PrimaryBtn>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|