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
Thomas Joise 5 days ago
parent b5d252b7dc
commit f30532b7d1

@ -559,6 +559,25 @@ export async function downloadModuleClientsCsv(moduleId: string, moduleCode: str
URL.revokeObjectURL(url)
}
// ---------- APEX import (D18 WS-B; owner-only) ----------
export interface ImportReport {
clients: { staged: number; problems: number }
invoices: { staged: number; problems: number; totalPaise: number }
samples: { firstClients: string[]; lastInvoices: string[] }
}
export interface ImportStatus {
report: ImportReport
problemClients: { row_no: number; code: string | null; name: string | null; problems: string }[]
problemInvoices: { row_no: number; client_code: string | null; doc_no: string | null; problems: string }[]
}
export const getImportStatus = (): Promise<ImportStatus> => apiFetch<ImportStatus>('/import/status')
export const stageApexCsv = (body: { clientsCsv?: string; invoicesCsv?: string }): Promise<ImportReport> =>
apiFetch<{ report: ImportReport }>('/import/stage', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.report)
export const commitApexImport = (): Promise<{ clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null }> =>
apiFetch<{ result: { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null } }>(
'/import/commit', { method: 'POST', body: '{}' },
).then((r) => r.result)
/** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? ''

@ -17,6 +17,7 @@ import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees'
import { Profile } from './pages/Profile'
import { ImportApex } from './pages/ImportApex'
import { Pipeline } from './pages/Pipeline'
import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
@ -38,6 +39,7 @@ function App() {
<Route path="/settings/template" element={<Navigate to="/settings?s=template" replace />} />
<Route path="/employees" element={<Employees />} />
<Route path="/profile" element={<Profile />} />
<Route path="/import" element={<ImportApex />} />
<Route path="/documents" element={<Documents />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />

@ -1,5 +1,6 @@
import {
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, Settings as SettingsIcon, UserCog, Users,
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, Settings as SettingsIcon,
Upload, UserCog, Users,
type LucideIcon,
} from 'lucide-react'
@ -29,6 +30,7 @@ export const NAV_GROUPS: NavGroup[] = [
label: 'Admin',
items: [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/import', label: 'APEX Import', icon: Upload, ownerOnly: true },
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
],
},

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

@ -52,6 +52,7 @@ import {
} from './repos-aws'
import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates'
import { renderPdf } from './pdf'
import { emailStatus } from './repos-email'
@ -938,6 +939,47 @@ export function apiRouter(
}
})
// ---------- APEX import (D18 WS-B; owner-only — the cutover is one-shot) ----------
r.post('/import/stage', requireAuth, requireOwner, (req, res) => {
try {
const body = req.body as { clientsCsv?: unknown; invoicesCsv?: unknown }
const out: { clientsStaged?: number; invoicesStaged?: number } = {}
if (typeof body.clientsCsv === 'string' && body.clientsCsv !== '') {
out.clientsStaged = stageCsv(db, 'clients', body.clientsCsv).staged
}
if (typeof body.invoicesCsv === 'string' && body.invoicesCsv !== '') {
out.invoicesStaged = stageCsv(db, 'invoices', body.invoicesCsv).staged
}
if (out.clientsStaged === undefined && out.invoicesStaged === undefined) {
res.status(400).json({ ok: false, error: 'Provide clientsCsv and/or invoicesCsv as CSV text' })
return
}
writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, out)
res.json({ ok: true, ...out, report: verificationReport(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.get('/import/status', requireAuth, requireOwner, (_req, res) => {
// Per-row problem lists (first 50 each, with honest totals — rule 8).
const problemClients = db.prepare(
`SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
).all()
const problemInvoices = db.prepare(
`SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
).all()
res.json({ ok: true, report: verificationReport(db), problemClients, problemInvoices })
})
r.post('/import/commit', requireAuth, requireOwner, (_req, res) => {
try {
const result = commitImport(db, staffId(res)) // refuses while ANY staged row has problems
writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, result)
res.json({ ok: true, result })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- dashboard ----------
// ?mine=1 = My Day (D18 WS-E): owner/manager narrow to their own book; staff
// are always scoped server-side regardless of the param.

@ -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…
Cancel
Save