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>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user