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>
84 lines
3.8 KiB
TypeScript
84 lines
3.8 KiB
TypeScript
import { Card, KpiTile } from '../../components/ui/bits'
|
|
import { ForecastFan } from '../../components/charts/ForecastFan'
|
|
import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
|
|
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
|
|
import { inrCompact } from '../../lib/demo/seed'
|
|
import { useScope } from './officerScope'
|
|
|
|
/**
|
|
* Portfolio-level view of the same forecast the enterprise pages show one at a
|
|
* time: the h = 1…6 aggregate, how each sector pack is carrying risk, and how
|
|
* the book migrated between watchlist bands over the last six months.
|
|
*/
|
|
export function ForecastsPage() {
|
|
const { block } = useScope()
|
|
const aggregate = portfolioForecast(block)
|
|
const sectors = sectorRollup(block)
|
|
const migration = bandMigration(block)
|
|
|
|
const worst = aggregate.reduce((a, p) => (p.p10 - p.obligation < a.p10 - a.obligation ? p : a), aggregate[0])
|
|
const covered = aggregate.filter((p) => p.p10 >= p.obligation).length
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
<KpiTile label="Horizon" value="6 months" delta="P10 / P50 / P90" />
|
|
<KpiTile label="Months covered at P10" value={`${covered} / 6`} tone={covered >= 5 ? 'good' : 'watch'} />
|
|
<KpiTile label="Tightest month" value={worst.label} delta={`P10 ${inrCompact(worst.p10)} vs ${inrCompact(worst.obligation)} due`} tone="red" />
|
|
<KpiTile label="Sector packs live" value={sectors.length} delta="each modelled separately" />
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<Card title="Portfolio net cash position — mean of the book">
|
|
<div className="p-4">
|
|
<ForecastFan points={aggregate} height={260} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Band migration — last 6 months">
|
|
<div className="p-4">
|
|
<BandMigrationBar rows={migration} height={260} />
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="Sector risk packs" badge="dairy · poultry · food proc. · handicrafts · rural retail">
|
|
<div className="overflow-x-auto">
|
|
<table className="ul-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Sector pack</th>
|
|
<th className="text-right">Enterprises</th>
|
|
<th className="text-right">High risk</th>
|
|
<th className="text-right">Watch</th>
|
|
<th className="text-right">Standard</th>
|
|
<th className="text-right">Avg score</th>
|
|
<th>Mix</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sectors.map((s) => (
|
|
<tr key={s.sector}>
|
|
<td className="font-semibold text-slate-700">{s.label}</td>
|
|
<td className="text-right text-slate-600">{s.count}</td>
|
|
<td className="text-right text-risk-red font-semibold">{s.red}</td>
|
|
<td className="text-right text-amber font-semibold">{s.watch}</td>
|
|
<td className="text-right text-green font-semibold">{s.standard}</td>
|
|
<td className="text-right font-bold text-slate-700">{s.avgScore}</td>
|
|
<td className="w-[180px]">
|
|
<span className="flex h-[7px] rounded-full overflow-hidden bg-slate-100">
|
|
<span className="bg-risk-red" style={{ width: `${(s.red / Math.max(1, s.count)) * 100}%` }} />
|
|
<span className="bg-amber" style={{ width: `${(s.watch / Math.max(1, s.count)) * 100}%` }} />
|
|
<span className="bg-green" style={{ width: `${(s.standard / Math.max(1, s.count)) * 100}%` }} />
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|