You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/ImportApex.tsx

158 lines
7.2 KiB
TypeScript

import { useState } from 'react'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Notice, PageHeader,
Skeleton, StatCard, Stats, Toolbar, useToast,
} from '@sims/ui'
import { commitApexImport, getImportStatus, stageApexCsv } from '../api'
import { useData } from './Clients'
/**
* APEX cutover importer (D18 WS-B; owner-only). Stage the two CSV exports →
* every row carries its own problem list → verify the totals → Commit, which the
* server refuses while ANY problem remains. Re-stage as many times as needed;
* after the final commit APEX goes read-only (no double entry, spec §4).
*/
export function ImportApex() {
const toast = useToast()
const status = useData(getImportStatus, [])
const [clientsCsv, setClientsCsv] = useState<string | undefined>()
const [invoicesCsv, setInvoicesCsv] = useState<string | undefined>()
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
const [done, setDone] = useState<string | undefined>()
const readFile = (set: (t: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file === undefined) return
const reader = new FileReader()
reader.onload = () => set(String(reader.result ?? ''))
reader.onerror = () => toast.err(`Could not read ${file.name}`)
reader.readAsText(file)
}
const stage = () => {
if (busy) return
if (clientsCsv === undefined && invoicesCsv === undefined) { toast.err('Pick at least one CSV first'); return }
setBusy(true); setDone(undefined)
stageApexCsv({
...(clientsCsv !== undefined ? { clientsCsv } : {}),
...(invoicesCsv !== undefined ? { invoicesCsv } : {}),
})
.then(() => { toast.ok('Staged — review the report below'); status.reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const commit = () => {
setBusy(true)
commitApexImport()
.then((r) => {
setDone(`Committed: ${r.clients} clients, ${r.invoices} invoices`
+ (r.seeded !== null ? ` · INVOICE series seeded past ${r.seeded.fy} #${r.seeded.lastSeq}` : ''))
toast.ok('Import committed — APEX is now read-only reference')
status.reload()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => { setBusy(false); setConfirming(false) })
}
const s = status.data
const problems = (s?.report.clients.problems ?? 0) + (s?.report.invoices.problems ?? 0)
const staged = (s?.report.clients.staged ?? 0) + (s?.report.invoices.staged ?? 0)
return (
<div className="wf-page">
<PageHeader
title="APEX import"
desc="The one-time 300-client cutover: stage the CSVs, clear every problem, verify, commit. Trial-run freely — only Commit writes real data."
/>
<section>
<h2>1 · Stage the CSV exports</h2>
<Toolbar>
<label className="wf" style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
clients.csv
<input type="file" accept=".csv,text/csv" onChange={readFile(setClientsCsv)} />
{clientsCsv !== undefined && <Badge tone="ok">loaded</Badge>}
</label>
<label className="wf" style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
invoices.csv
<input type="file" accept=".csv,text/csv" onChange={readFile(setInvoicesCsv)} />
{invoicesCsv !== undefined && <Badge tone="ok">loaded</Badge>}
</label>
<Button tone="primary" onClick={stage}>{busy ? 'Staging…' : 'Stage'}</Button>
</Toolbar>
</section>
<section>
<h2>2 · Verification report</h2>
{status.error !== undefined ? <ErrorState message={status.error} onRetry={status.reload} />
: s === undefined ? <Skeleton rows={3} />
: staged === 0 ? <EmptyState>Nothing staged yet.</EmptyState> : (
<>
<Stats>
<StatCard label="Clients staged" value={String(s.report.clients.staged)}
{...(s.report.clients.problems > 0 ? { hint: `${s.report.clients.problems} with problems`, hintTone: 'err' as const } : { hint: 'clean' })} />
<StatCard label="Invoices staged" value={String(s.report.invoices.staged)}
{...(s.report.invoices.problems > 0 ? { hint: `${s.report.invoices.problems} with problems`, hintTone: 'err' as const } : { hint: 'clean' })} />
<StatCard label="Invoice value" value={formatINR(s.report.invoices.totalPaise)} hint="cross-check vs APEX" />
</Stats>
<p style={{ fontSize: 12, opacity: 0.75 }}>
First clients: {s.report.samples.firstClients.join(' · ') || '—'}<br />
Last invoices: {s.report.samples.lastInvoices.join(' · ') || '—'}
</p>
{s.problemClients.length > 0 && (
<>
<h3>Client rows with problems (first 50 of {s.report.clients.problems})</h3>
<DataTable
columns={[{ key: 'row', label: 'Row' }, { key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' }, { key: 'problems', label: 'Problems' }]}
rows={s.problemClients.map((p) => ({
row: String(p.row_no), code: p.code ?? '—', name: p.name ?? '—',
problems: (JSON.parse(p.problems) as string[]).join('; '),
}))}
/>
</>
)}
{s.problemInvoices.length > 0 && (
<>
<h3>Invoice rows with problems (first 50 of {s.report.invoices.problems})</h3>
<DataTable
columns={[{ key: 'row', label: 'Row' }, { key: 'client', label: 'Client code', mono: true }, { key: 'docNo', label: 'Doc no', mono: true }, { key: 'problems', label: 'Problems' }]}
rows={s.problemInvoices.map((p) => ({
row: String(p.row_no), client: p.client_code ?? '—', docNo: p.doc_no ?? '—',
problems: (JSON.parse(p.problems) as string[]).join('; '),
}))}
/>
</>
)}
</>
)}
</section>
<section>
<h2>3 · Commit</h2>
{problems > 0 && <Notice tone="err">{problems} staged row(s) still carry problems fix the CSVs and re-stage. Commit stays locked.</Notice>}
{done !== undefined && <Notice tone="ok">{done}</Notice>}
<Button
tone="primary"
onClick={() => { if (staged > 0 && problems === 0 && !busy) setConfirming(true) }}
>
{busy ? 'Working…' : `Commit import${staged > 0 ? ` (${staged} rows)` : ''}`}
</Button>
{confirming && (
<ConfirmDialog
open
onClose={() => setConfirming(false)}
onConfirm={commit}
title="Commit the APEX import"
body="This writes the staged clients and invoices for real and seeds the INVOICE series past the legacy numbers. After the final cutover, APEX goes read-only — no double entry. Commit?"
confirmLabel="Commit import"
tone="danger"
/>
)}
</section>
</div>
)
}