Files
udyamlinc/src/features/officer/VisitsPage.tsx
T
LincPayandClaude Opus 5 f430f85202 chore: initial commit — UdyamLinc UI, phases A-E
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>
2026-08-06 13:30:24 +05:30

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>
)
}