feat(import): APEX cutover web UI — stage/verify/commit over the existing importer (Phase 7, D18 WS-B)
- POST /import/stage (CSV text), GET /import/status (report + first-50 problem rows with honest totals), POST /import/commit — all owner-only, audited; commit still refuses while ANY staged row carries problems (existing guard) - /import page (ADMIN nav, owner-only): file pickers → problem tables → verification stats (incl. invoice-value cross-check) → confirm-gated Commit; re-stage freely, series seeding message surfaced - 2 route tests (403s for staff; bad→refuse→clean→commit round-trip incl. series seeding past legacy numbers); suite 344 green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
b5d252b7dc
commit
f30532b7d1
@ -0,0 +1,157 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
// apps/hq/test/import-routes.test.ts — go-live cluster WS-B: APEX import over HTTP (D18)
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { apiRouter } from '../src/api'
|
||||
|
||||
const CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\nAPX001,Acme Bank,,32,Kochi,,r@acme.in,active\n'
|
||||
const BAD_CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\n,No Code Bank,,32,,,,active\n'
|
||||
// Amounts are RUPEES in the APEX export; staging converts via fromRupees.
|
||||
// Current-FY doc date so the INVOICE series seeds past the legacy number.
|
||||
const INVOICES_CSV = 'client_code,doc_no,doc_date,taxable,tax,total,paid\nAPX001,INV/26-27-0042,2026-07-01,1000,180,1180,1\n'
|
||||
|
||||
function appWith() {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
|
||||
createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
|
||||
const app = express(); app.use(express.json({ limit: '5mb' })); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db))
|
||||
const server = app.listen(0)
|
||||
const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
return { db, server, baseUrl }
|
||||
}
|
||||
const login = async (baseUrl: string, email: string, password: string) =>
|
||||
((await (await fetch(`${baseUrl}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})).json()) as { token: string }).token
|
||||
const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` })
|
||||
|
||||
describe('APEX import routes (owner-only)', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
it('staff get 403 on every import route', async () => {
|
||||
const ctx = appWith(); servers.push(ctx.server)
|
||||
const t = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
|
||||
for (const [method, path] of [['POST', '/import/stage'], ['GET', '/import/status'], ['POST', '/import/commit']] as const) {
|
||||
const res = await fetch(`${ctx.baseUrl}${path}`, { method, headers: H(t), ...(method === 'POST' ? { body: '{}' } : {}) })
|
||||
expect(res.status).toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('stage → status → commit round-trip; problems lock the commit until re-staged clean', async () => {
|
||||
const ctx = appWith(); servers.push(ctx.server)
|
||||
const t = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
|
||||
// Stage a BAD clients file → problems reported, commit refused.
|
||||
const bad = await fetch(`${ctx.baseUrl}/import/stage`, {
|
||||
method: 'POST', headers: H(t), body: JSON.stringify({ clientsCsv: BAD_CLIENTS_CSV }),
|
||||
})
|
||||
const badJson = await bad.json() as { report: { clients: { problems: number } } }
|
||||
expect(bad.status).toBe(200)
|
||||
expect(badJson.report.clients.problems).toBeGreaterThan(0)
|
||||
const refused = await fetch(`${ctx.baseUrl}/import/commit`, { method: 'POST', headers: H(t), body: '{}' })
|
||||
expect(refused.status).toBe(400)
|
||||
// Re-stage clean + invoices → status shows zero problems → commit succeeds.
|
||||
const ok = await fetch(`${ctx.baseUrl}/import/stage`, {
|
||||
method: 'POST', headers: H(t), body: JSON.stringify({ clientsCsv: CLIENTS_CSV, invoicesCsv: INVOICES_CSV }),
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
const status = await (await fetch(`${ctx.baseUrl}/import/status`, { headers: H(t) })).json() as {
|
||||
report: { clients: { problems: number }; invoices: { problems: number } }
|
||||
problemClients: unknown[]
|
||||
}
|
||||
expect(status.report.clients.problems).toBe(0)
|
||||
expect(status.report.invoices.problems).toBe(0)
|
||||
const commit = await fetch(`${ctx.baseUrl}/import/commit`, { method: 'POST', headers: H(t), body: '{}' })
|
||||
const cj = await commit.json() as { result: { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null } }
|
||||
expect(commit.status).toBe(200)
|
||||
expect(cj.result.clients).toBe(1)
|
||||
expect(cj.result.invoices).toBe(1)
|
||||
expect(cj.result.seeded).not.toBeNull() // INVOICE series seeded past the legacy number
|
||||
// The imported rows are real now.
|
||||
const imported = ctx.db.prepare(`SELECT source, status FROM document WHERE doc_no='INV/26-27-0042'`).get()
|
||||
expect(imported).toMatchObject({ source: 'apex' })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue