feat(d26): functional-feature UIs — GST report, doc search, statement, bulk ops

- Reports: GST summary tab (FY month range, per-month CGST/SGST/IGST + total,
  emphasized TOTAL row).
- Documents: debounced free-text search feeding q.
- Client 360: printable account statement (Dr/Cr ledger + running/closing
  balance + advance, @media print isolation); buildStatement + 4 tests.
- Clients: owner-only bulk-edit selection -> set a field across many (POST
  /clients/bulk). Onboarding board: owner-only bulk 'mark done' on the pending
  drill-down (POST /projects/bulk-milestone). Both confirm + toast + refresh.
- api.ts: getGstSummary, bulkUpdateClients, bulkSetMilestone, documents q.
typecheck + web build clean, web tests 8/8, full suite 397 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 3247585e52
commit 18e47eb448

@ -254,6 +254,15 @@ export const patchClient = (id: string, body: ClientWrite): Promise<Client> =>
/** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */ /** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */
export const setClientOwner = (id: string, ownerId: string | null): Promise<Client> => export const setClientOwner = (id: string, ownerId: string | null): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}/owner`, { method: 'PATCH', body: JSON.stringify({ ownerId }) }).then((r) => r.client) apiFetch<{ client: Client }>(`/clients/${id}/owner`, { method: 'PATCH', body: JSON.stringify({ ownerId }) }).then((r) => r.client)
/**
* Owner-only (server re-checks): apply the same patch to many clients in one audited
* transaction (D26 cleanup at scale). Returns how many rows changed. `patch` reuses the
* ClientWrite shape (district / sector / stateCode / status are the useful bulk fields).
*/
export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{ updated: number }> =>
apiFetch<{ ok: boolean; updated: number }>('/clients/bulk', {
method: 'POST', body: JSON.stringify({ ids, patch }),
}).then((r) => ({ updated: r.updated }))
export const getModules = (): Promise<Module[]> => export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
@ -331,6 +340,18 @@ export interface PendingProject {
} }
export const getPendingProjects = (key: string): Promise<PendingProject[]> => export const getPendingProjects = (key: string): Promise<PendingProject[]> =>
apiFetch<{ projects: PendingProject[] }>(`/projects/pending/${encodeURIComponent(key)}`).then((r) => r.projects) apiFetch<{ projects: PendingProject[] }>(`/projects/pending/${encodeURIComponent(key)}`).then((r) => r.projects)
/**
* Owner-only (server re-checks): tick/untick one milestone across many projects in a
* single audited transaction (D26). Only projects that actually have the milestone are
* touched. `done=true` stamps `doneOn` (given date or today). Returns how many changed.
*/
export const bulkSetMilestone = (
clientModuleIds: string[], key: string, done: boolean, doneOn?: string,
): Promise<{ updated: number }> =>
apiFetch<{ ok: boolean; updated: number }>('/projects/bulk-milestone', {
method: 'POST',
body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }),
}).then((r) => ({ updated: r.updated }))
// ---------- ticket desk + client branches (D20 APEX parity) ---------- // ---------- ticket desk + client branches (D20 APEX parity) ----------
@ -400,14 +421,16 @@ export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> =>
/** One Documents-list row: the document plus the server-joined client name (no N+1). */ /** One Documents-list row: the document plus the server-joined client name (no N+1). */
export type DocListRow = Doc & { clientName: string } export type DocListRow = Doc & { clientName: string }
export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number } export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
/** Server-paginated document list with an honest total (rule 8); filters compose. */ /** Server-paginated document list with an honest total (rule 8); filters compose.
* `q` free-text-matches the document number or client name (D26 register search). */
export const getDocuments = ( export const getDocuments = (
opts: { type?: DocType; status?: DocStatus; clientId?: string; page?: number; pageSize?: number } = {}, opts: { type?: DocType; status?: DocStatus; clientId?: string; q?: string; page?: number; pageSize?: number } = {},
): Promise<DocumentsPage> => { ): Promise<DocumentsPage> => {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.type !== undefined) p.set('type', opts.type) if (opts.type !== undefined) p.set('type', opts.type)
if (opts.status !== undefined) p.set('status', opts.status) if (opts.status !== undefined) p.set('status', opts.status)
if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId)
if (opts.q !== undefined && opts.q !== '') p.set('q', opts.q)
if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.page !== undefined) p.set('page', String(opts.page))
if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize))
const q = p.toString() const q = p.toString()
@ -665,6 +688,14 @@ export interface ProfitabilityRow {
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
} }
export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number }
/** One GST-filing summary row (D26). `period` is 'YYYY-MM' per month, or 'TOTAL' on the
* footer row. Invoices add, credit notes subtract all amounts integer paise. */
export interface GstSummaryRow {
period: string
invoiceCount: number; creditNoteCount: number
taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number
taxPaise: number; totalPaise: number
}
export interface AwsUsageRow { export interface AwsUsageRow {
id: string; clientId: string; month: string id: string; clientId: string; month: string
storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string
@ -686,6 +717,11 @@ export const getModuleRevenue = (from?: string, to?: string): Promise<ModuleReve
apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows) apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows)
export const getProfitability = (from?: string, to?: string): Promise<ProfitabilityRow[]> => export const getProfitability = (from?: string, to?: string): Promise<ProfitabilityRow[]> =>
apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows) apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows)
/** GST filing summary per month over [from, to] (inclusive doc-date bounds), plus a
* TOTAL row. `from`/`to` are full 'YYYY-MM-DD' dates (the server compares doc_date). */
export const getGstSummary = (from?: string, to?: string): Promise<{ rows: GstSummaryRow[]; total: GstSummaryRow }> =>
apiFetch<{ ok: boolean; rows: GstSummaryRow[]; total: GstSummaryRow }>(`/reports/gst-summary${rangeQs(from, to)}`)
.then((r) => ({ rows: r.rows, total: r.total }))
export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> => export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> =>
apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`) apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`)
export const getClientAwsUsage = (clientId: string): Promise<AwsUsageRow[]> => export const getClientAwsUsage = (clientId: string): Promise<AwsUsageRow[]> =>

@ -320,3 +320,54 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.wf-page .composer-line { flex-direction: column; align-items: stretch; } .wf-page .composer-line { flex-direction: column; align-items: stretch; }
.wf-page .composer-line > * { width: 100%; } .wf-page .composer-line > * { width: 100%; }
} }
/* ============================================================================
Client account statement (D26) a printable overlay. On screen it reads like
paper (white card) in either theme; @media print isolates .statement-print so
only the statement, not the app chrome, reaches the page.
============================================================================ */
.statement-overlay {
position: fixed; inset: 0; z-index: 60; overflow: auto;
background: var(--bg-inset); padding: 24px 16px 48px;
}
.statement-toolbar { display: flex; gap: 8px; justify-content: flex-end; max-width: 820px; margin: 0 auto 16px; }
.statement-print {
max-width: 820px; margin: 0 auto; background: #fff; color: #14130f;
border: 1px solid var(--border); border-radius: 10px; padding: 34px 40px;
box-shadow: var(--shadow-lg);
}
.statement-head { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }
.statement-co { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.statement-co-meta { font-size: 12px; color: #55524a; margin-top: 2px; }
.statement-title { font-size: 15px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.statement-client {
display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 18px 0 6px;
padding: 12px 0; border-top: 1px solid #e5e2da; border-bottom: 1px solid #e5e2da; font-size: 13.5px;
}
.statement-lbl {
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: #7a766c; margin-right: 6px;
}
.statement-table { width: 100%; border-collapse: collapse; margin: 16px 0 8px; font-size: 13px; }
.statement-table th, .statement-table td { padding: 7px 10px; border-bottom: 1px solid #ece9e1; text-align: left; }
.statement-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #55524a; }
.statement-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.statement-table tfoot td { border-top: 2px solid #ddd9d0; border-bottom: none; }
.statement-table .mono { font-family: var(--mono); }
.statement-summary {
display: flex; justify-content: flex-end; gap: 32px; margin-top: 12px; font-size: 14px;
}
.statement-summary .statement-lbl { display: block; margin-bottom: 2px; }
.statement-note { margin-top: 22px; font-size: 11px; color: #7a766c; }
@media print {
/* Isolate the statement: hide the whole app (keeps layout, drops ink), then re-show
only the statement and pin it to the top-left of the page. */
body * { visibility: hidden; }
.statement-print, .statement-print * { visibility: visible; }
.statement-overlay { position: static; padding: 0; background: #fff; overflow: visible; }
.statement-print {
position: absolute; left: 0; top: 0; width: 100%; max-width: none;
margin: 0; padding: 0 4px; border: none; border-radius: 0; box-shadow: none;
}
.no-print { display: none !important; }
}

@ -13,13 +13,14 @@ import {
getRecurringPlans, deactivateRecurringPlan, getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction, getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets, getBranches, createBranch, patchBranch, getTickets,
getMilestones, setMilestone, addMilestone, getMilestones, setMilestone, addMilestone,
CLIENT_STATUSES, KIND_LABEL, CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api' } from '../api'
import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
@ -71,6 +72,7 @@ export function ClientDetail() {
const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>() const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>()
const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>()
const [confirm, setConfirm] = useState<Confirm | undefined>() const [confirm, setConfirm] = useState<Confirm | undefined>()
const [statementOpen, setStatementOpen] = useState(false)
const rawTab = sp.get('tab') ?? 'overview' const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
@ -167,6 +169,7 @@ export function ClientDetail() {
</select> </select>
<Button onClick={() => setTab('interactions')}>Log call</Button> <Button onClick={() => setTab('interactions')}>Log call</Button>
<Button onClick={() => setTab('payments')}>Record payment</Button> <Button onClick={() => setTab('payments')}>Record payment</Button>
<Button onClick={() => setStatementOpen(true)}>Statement</Button>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button> <Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</> </>
} }
@ -583,6 +586,115 @@ export function ClientDetail() {
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
/> />
)} )}
{statementOpen && (
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
)}
</div>
)
}
/**
* Printable account statement (D26). A full-screen overlay that reads like paper on
* screen in either theme; a `@media print` block (app.css) isolates `.statement-print`
* so only the statement not the app chrome hits the page. The ledger of invoices,
* credit notes and payments is ordered by date with a running balance; the closing
* balance and the unallocated advance both surface. Company header from the profile.
*/
function ClientStatement(props: { client: Client; ledger?: Ledger; onClose: () => void }) {
const company = useData(getCompanyProfile, [])
const co = company.data
const coName = co?.['company.name'] ?? 'SiMS HQ'
const coAddr = co?.['company.address'] ?? ''
const coGstin = co?.['company.gstin'] ?? ''
const led = props.ledger
const statement = led !== undefined ? buildStatement(led.documents, led.payments) : undefined
const printedOn = new Date().toISOString().slice(0, 10)
const balanceLabel = (paise: number): string =>
paise > 0 ? `${formatINR(paise)} Dr` : paise < 0 ? `${formatINR(-paise)} Cr` : formatINR(0)
return (
<div className="statement-overlay" role="dialog" aria-modal="true" aria-label="Client statement">
<div className="statement-toolbar no-print">
<Button onClick={props.onClose}>Close</Button>
<Button tone="primary" onClick={() => window.print()}>Print</Button>
</div>
<div className="statement-print">
<div className="statement-head">
<div>
<div className="statement-co">{coName}</div>
{coAddr !== '' && (
<div className="statement-co-meta" style={{ whiteSpace: 'pre-wrap' }}>{coAddr}</div>
)}
{coGstin !== '' && (
<div className="statement-co-meta">GSTIN {coGstin}</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div className="statement-title">Statement of Account</div>
<div className="statement-co-meta">As on {printedOn}</div>
</div>
</div>
<div className="statement-client">
<div><span className="statement-lbl">Client</span> <strong>{props.client.name}</strong></div>
<div><span className="statement-lbl">Code</span> <span className="mono">{props.client.code}</span></div>
{props.client.gstin !== undefined && props.client.gstin !== '' && (
<div><span className="statement-lbl">GSTIN</span> <span className="mono">{props.client.gstin}</span></div>
)}
</div>
{statement === undefined ? <Skeleton rows={5} />
: statement.rows.length === 0 ? <EmptyState>No invoices, credit notes or payments on record.</EmptyState>
: (
<table className="statement-table">
<thead>
<tr>
<th>Date</th><th>Particulars</th><th>Reference</th>
<th className="num">Debit</th><th className="num">Credit</th><th className="num">Balance</th>
</tr>
</thead>
<tbody>
{statement.rows.map((r, i) => (
<tr key={i}>
<td className="mono">{r.date}</td>
<td>{r.particulars}</td>
<td className="mono">{r.ref !== '' ? r.ref : '—'}</td>
<td className="num">{r.debitPaise > 0 ? formatINR(r.debitPaise) : ''}</td>
<td className="num">{r.creditPaise > 0 ? formatINR(r.creditPaise) : ''}</td>
<td className="num">{balanceLabel(r.balancePaise)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3}><strong>Totals</strong></td>
<td className="num"><strong>{formatINR(statement.totalDebitPaise)}</strong></td>
<td className="num"><strong>{formatINR(statement.totalCreditPaise)}</strong></td>
<td className="num"><strong>{balanceLabel(statement.closingPaise)}</strong></td>
</tr>
</tfoot>
</table>
)}
{statement !== undefined && (
<div className="statement-summary">
<div>
<span className="statement-lbl">Closing balance</span>
<strong>{balanceLabel(statement.closingPaise)}</strong>
</div>
{led !== undefined && (
<div>
<span className="statement-lbl">Advance on account</span>
<strong>{formatINR(led.advancePaise)}</strong>
</div>
)}
</div>
)}
<div className="statement-note">
Dr = amount owed to us · Cr = client in credit. Reflects issued invoices, credit
notes and recorded payments. This is a computer-generated statement.
</div>
</div>
</div> </div>
) )
} }

@ -1,7 +1,13 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar, type BadgeTone } from '@sims/ui' import {
import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus, type DocType } from '../api' Badge, Button, DataTable, Dialog, EmptyState, ErrorState, FilterChips, FormField, FormGrid,
Notice, PageHeader, Skeleton, Toolbar, useToast, type BadgeTone,
} from '@sims/ui'
import {
getClients, bulkUpdateClients, role, CLIENT_STATUSES,
type ClientStatus, type ClientWrite, type DocStatus, type DocType,
} from '../api'
import { ClientFormDialog } from '../components/ClientFormDialog' import { ClientFormDialog } from '../components/ClientFormDialog'
/** /**
@ -94,14 +100,36 @@ export function Clients() {
() => getClients(qDebounced, { district, sector }), [qDebounced, district, sector], () => getClients(qDebounced, { district, sector }), [qDebounced, district, sector],
) )
const [sp, setSp] = useSearchParams() const [sp, setSp] = useSearchParams()
const toast = useToast()
const isOwner = role() === 'owner'
const [creating, setCreating] = useState(sp.get('new') === '1') const [creating, setCreating] = useState(sp.get('new') === '1')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
// Bulk edit (D26, owner-only): opt-in selection mode → set one field across the picked set.
const [selecting, setSelecting] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkBusy, setBulkBusy] = useState(false)
useEffect(() => { useEffect(() => {
if (sp.get('new') === '1') { setCreating(true); setSp({}, { replace: true }) } if (sp.get('new') === '1') { setCreating(true); setSp({}, { replace: true }) }
}, [sp, setSp]) }, [sp, setSp])
const counts = new Map<string, number>() const counts = new Map<string, number>()
for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1) for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1)
const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter) const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter)
const toggleOne = (cid: string) => setSelected((prev) => {
const n = new Set(prev)
if (n.has(cid)) n.delete(cid); else n.add(cid)
return n
})
const exitSelect = () => { setSelecting(false); setSelected(new Set()) }
const applyBulk = (patch: ClientWrite) => {
if (bulkBusy || selected.size === 0) return
setBulkBusy(true)
bulkUpdateClients([...selected], patch)
.then((r) => { toast.ok(`Updated ${r.updated} client(s)`); setBulkOpen(false); exitSelect(); reload() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBulkBusy(false))
}
const why = [ const why = [
q !== '' ? ` matching “${q}` : '', q !== '' ? ` matching “${q}` : '',
statusFilter !== 'all' ? ` with status ${statusFilter}` : '', statusFilter !== 'all' ? ` with status ${statusFilter}` : '',
@ -137,6 +165,16 @@ export function Clients() {
]} ]}
/> />
<span className="spacer" /> <span className="spacer" />
{isOwner && (selecting ? (
<>
<Button onClick={() => setSelected(new Set(shown.map((c) => c.id)))}>Select all shown</Button>
<Button onClick={() => setSelected(new Set())} disabled={selected.size === 0}>Clear</Button>
<Button tone="primary" disabled={selected.size === 0} onClick={() => setBulkOpen(true)}>Bulk edit ({selected.size})</Button>
<Button onClick={exitSelect}>Cancel</Button>
</>
) : (
<Button onClick={() => setSelecting(true)}>Select</Button>
))}
<Badge>{shown.length} shown</Badge> <Badge>{shown.length} shown</Badge>
</Toolbar> </Toolbar>
<ClientFormDialog <ClientFormDialog
@ -149,12 +187,21 @@ export function Clients() {
: shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : ( : shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : (
<DataTable <DataTable
columns={[ columns={[
...(selecting ? [{ key: 'sel', label: '' }] : []),
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' }, { key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' }, { key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' }, { key: 'contact', label: 'Contact' },
]} ]}
onRowClick={(_row, i) => nav(`/clients/${shown[i]!.id}`)} onRowClick={(_row, i) => nav(`/clients/${shown[i]!.id}`)}
rows={shown.map((c) => ({ rows={shown.map((c) => ({
...(selecting ? {
sel: (
<input
type="checkbox" checked={selected.has(c.id)} aria-label={`Select ${c.name}`}
onClick={(e) => e.stopPropagation()} onChange={() => toggleOne(c.id)}
/>
),
} : {}),
code: c.code, name: c.name, code: c.code, name: c.name,
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>, status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
state: c.stateCode, state: c.stateCode,
@ -162,6 +209,87 @@ export function Clients() {
}))} }))}
/> />
)} )}
<BulkEditDialog
open={bulkOpen}
count={selected.size}
busy={bulkBusy}
onClose={() => { if (!bulkBusy) setBulkOpen(false) }}
onApply={applyBulk}
/>
</div> </div>
) )
} }
/**
* Owner-only bulk edit (D26): pick ONE field and a value to stamp across the selected
* clients. The dialog itself is the confirmation step (it names the field, value and
* count); text fields refuse an empty value so a stray Apply can't blank a column.
*/
function BulkEditDialog(props: {
open: boolean; count: number; busy: boolean
onClose: () => void; onApply: (patch: ClientWrite) => void
}) {
const [field, setField] = useState<'status' | 'district' | 'sector' | 'state'>('status')
const [status, setStatus] = useState<ClientStatus>('active')
const [text, setText] = useState('')
useEffect(() => {
if (props.open) { setField('status'); setStatus('active'); setText('') }
}, [props.open])
const trimmed = text.trim()
const textEmpty = field !== 'status' && trimmed === ''
const apply = () => {
if (props.busy || textEmpty) return
if (field === 'status') props.onApply({ status })
else if (field === 'district') props.onApply({ district: trimmed })
else if (field === 'sector') props.onApply({ sector: trimmed })
else props.onApply({ stateCode: trimmed })
}
const label = field === 'state' ? 'state code' : field
return (
<Dialog
open={props.open}
onClose={props.onClose}
title={`Bulk edit ${props.count} client${props.count === 1 ? '' : 's'}`}
footer={
<>
<Button pill onClick={props.onClose} disabled={props.busy}>Cancel</Button>
<Button pill tone="primary" disabled={props.busy || textEmpty} onClick={apply}>
{props.busy ? 'Applying…' : 'Apply'}
</Button>
</>
}
>
<FormGrid>
<FormField label="Field">
<select className="wf" value={field} onChange={(e) => setField(e.target.value as typeof field)}>
<option value="status">Status</option>
<option value="district">District</option>
<option value="sector">Sector</option>
<option value="state">State code</option>
</select>
</FormField>
<FormField label="Value">
{field === 'status'
? (
<select className="wf" value={status} onChange={(e) => setStatus(e.target.value as ClientStatus)}>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)
: (
<input
className="wf" value={text} onChange={(e) => setText(e.target.value)}
placeholder={field === 'state' ? 'e.g. 27' : ''}
/>
)}
</FormField>
</FormGrid>
<Notice tone="warn">
Sets {label} to {field === 'status' ? status : trimmed} for all {props.count} selected
client{props.count === 1 ? '' : 's'}. This cannot be undone in bulk.
</Notice>
</Dialog>
)
}

@ -7,7 +7,7 @@ import {
} from '@sims/ui' } from '@sims/ui'
import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api' import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
import { Pager } from '../components/Pager' import { Pager } from '../components/Pager'
import { useData, DOC_TONE, DOC_TYPE_TONE } from './Clients' import { useData, useDebounced, DOC_TONE, DOC_TYPE_TONE } from './Clients'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -34,7 +34,10 @@ export function Documents() {
const toast = useToast() const toast = useToast()
const [type, setType] = useState('all') const [type, setType] = useState('all')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [q, setQ] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Debounced so typing in the search box fires one server request ~300ms after typing stops.
const qDebounced = useDebounced(q)
// D18 WS-G: one-click proforma → invoice & send, straight from the register. // D18 WS-G: one-click proforma → invoice & send, straight from the register.
const [converting, setConverting] = useState<{ id: string; docNo: string } | undefined>() const [converting, setConverting] = useState<{ id: string; docNo: string } | undefined>()
@ -42,9 +45,10 @@ export function Documents() {
() => getDocuments({ () => getDocuments({
...(type !== 'all' ? { type: type as DocType } : {}), ...(type !== 'all' ? { type: type as DocType } : {}),
...(status !== '' ? { status: status as DocStatus } : {}), ...(status !== '' ? { status: status as DocStatus } : {}),
...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE, page, pageSize: PAGE_SIZE,
}), }),
[type, status, page], [type, status, qDebounced, page],
) )
const data = list.data const data = list.data
@ -52,6 +56,7 @@ export function Documents() {
const why = [ const why = [
type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '', type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '',
status !== '' ? ` with status ${status}` : '', status !== '' ? ` with status ${status}` : '',
qDebounced !== '' ? ` matching “${qDebounced}` : '',
].filter((x) => x !== '').join('') ].filter((x) => x !== '').join('')
return ( return (
@ -66,6 +71,11 @@ export function Documents() {
active={type} active={type}
onChange={(k) => { setType(k); setPage(1) }} onChange={(k) => { setType(k); setPage(1) }}
/> />
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Search number / client…"
aria-label="Search documents"
value={q} onChange={(e) => { setQ(e.target.value); setPage(1) }}
/>
<select <select
className="wf" style={{ maxWidth: 180 }} value={status} aria-label="Status" className="wf" style={{ maxWidth: 180 }} value={status} aria-label="Status"
onChange={(e) => { setStatus(e.target.value); setPage(1) }} onChange={(e) => { setStatus(e.target.value); setPage(1) }}

@ -1,9 +1,13 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Badge, Button, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, StatCard, Stats, Toolbar, Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, PageHeader, Skeleton,
StatCard, Stats, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { getPendingProjects, getProjectBoard, type MilestoneBoardRow, type PendingProject } from '../api' import {
bulkSetMilestone, getPendingProjects, getProjectBoard, role,
type MilestoneBoardRow, type PendingProject,
} from '../api'
import { useData } from './Clients' import { useData } from './Clients'
/** /**
@ -14,17 +18,40 @@ import { useData } from './Clients'
* back to its client 360°. Per-project ticking lives on the client's Modules tab. * back to its client 360°. Per-project ticking lives on the client's Modules tab.
*/ */
export function Projects() { export function Projects() {
const toast = useToast()
const isOwner = role() === 'owner'
const board = useData(getProjectBoard, []) const board = useData(getProjectBoard, [])
const [selected, setSelected] = useState<MilestoneBoardRow | undefined>() const [selected, setSelected] = useState<MilestoneBoardRow | undefined>()
const pending = useData<PendingProject[]>( const pending = useData<PendingProject[]>(
() => (selected !== undefined ? getPendingProjects(selected.key) : Promise.resolve([])), () => (selected !== undefined ? getPendingProjects(selected.key) : Promise.resolve([])),
[selected?.key], [selected?.key],
) )
// Bulk mark-done (D26, owner-only): pick pending projects in the drill-down and clear
// the milestone across all of them in one audited call.
const [picked, setPicked] = useState<Set<string>>(new Set())
const [confirmOpen, setConfirmOpen] = useState(false)
// A new drill-down subject → drop the previous selection.
useEffect(() => { setPicked(new Set()) }, [selected?.key])
const rows = board.data const rows = board.data
const cleared = rows?.filter((r) => r.pending === 0 && r.total > 0).length ?? 0 const cleared = rows?.filter((r) => r.pending === 0 && r.total > 0).length ?? 0
const attention = rows?.filter((r) => r.pending > 0).length ?? 0 const attention = rows?.filter((r) => r.pending > 0).length ?? 0
const togglePick = (cmId: string) => setPicked((prev) => {
const n = new Set(prev)
if (n.has(cmId)) n.delete(cmId); else n.add(cmId)
return n
})
// Returns the promise so ConfirmDialog shows the busy state and surfaces any error;
// it closes the dialog on resolve. A rejection stays open with the message shown.
const markDone = (): Promise<void> => {
if (selected === undefined || picked.size === 0) return Promise.resolve()
return bulkSetMilestone([...picked], selected.key, true).then((r) => {
toast.ok(`Marked ${r.updated} project${r.updated === 1 ? '' : 's'} done`)
setPicked(new Set()); board.reload(); pending.reload()
})
}
return ( return (
<div className="wf-page"> <div className="wf-page">
<PageHeader <PageHeader
@ -69,6 +96,13 @@ export function Projects() {
<Toolbar> <Toolbar>
<h3 style={{ margin: 0 }}>Pending: {selected.label}</h3> <h3 style={{ margin: 0 }}>Pending: {selected.label}</h3>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{isOwner && pending.data !== undefined && pending.data.length > 0 && (
<>
<Button onClick={() => setPicked(new Set(pending.data!.map((p) => p.clientModuleId)))}>Select all</Button>
<Button onClick={() => setPicked(new Set())} disabled={picked.size === 0}>Clear</Button>
<Button tone="primary" disabled={picked.size === 0} onClick={() => setConfirmOpen(true)}>Mark done ({picked.size})</Button>
</>
)}
{pending.data !== undefined && <Badge tone={pending.data.length > 0 ? 'warn' : 'ok'}>{pending.data.length} pending</Badge>} {pending.data !== undefined && <Badge tone={pending.data.length > 0 ? 'warn' : 'ok'}>{pending.data.length} pending</Badge>}
<Button onClick={() => setSelected(undefined)}>Close</Button> <Button onClick={() => setSelected(undefined)}>Close</Button>
</Toolbar> </Toolbar>
@ -78,15 +112,35 @@ export function Projects() {
: pending.data.length === 0 ? <EmptyState>Every active project has cleared {selected.label}.</EmptyState> : ( : pending.data.length === 0 ? <EmptyState>Every active project has cleared {selected.label}.</EmptyState> : (
<DataTable <DataTable
columns={[ columns={[
...(isOwner ? [{ key: 'sel', label: '' }] : []),
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'module', label: 'Module' }, { key: 'module', label: 'Module' },
]} ]}
rows={pending.data.map((p) => ({ rows={pending.data.map((p) => ({
...(isOwner ? {
sel: (
<input
type="checkbox" checked={picked.has(p.clientModuleId)}
aria-label={`Select ${p.clientName} · ${p.moduleCode}`}
onChange={() => togglePick(p.clientModuleId)}
/>
),
} : {}),
client: <Link to={`/clients/${p.clientId}`}>{p.clientName}</Link>, client: <Link to={`/clients/${p.clientId}`}>{p.clientName}</Link>,
module: <><span className="mono">{p.moduleCode}</span> · {p.moduleName}</>, module: <><span className="mono">{p.moduleCode}</span> · {p.moduleName}</>,
}))} }))}
/> />
)} )}
{confirmOpen && selected !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirmOpen(false)}
onConfirm={markDone}
title="Mark milestone done"
body={`Mark “${selected.label}” done for ${picked.size} selected project${picked.size === 1 ? '' : 's'}? This stamps today's date and clears them from this list.`}
confirmLabel="Mark done"
/>
)}
</> </>
)} )}
</div> </div>

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { DataTable, EmptyState, ErrorState, Field, FilterChips, Skeleton, PageHeader } from '@sims/ui' import { DataTable, EmptyState, ErrorState, Field, FilterChips, Skeleton, PageHeader } from '@sims/ui'
import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api' import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking, getGstSummary } from '../api'
import { useData } from './Clients' import { useData } from './Clients'
const inr = (p: number) => formatINR(p, { symbol: false }) const inr = (p: number) => formatINR(p, { symbol: false })
@ -11,14 +11,35 @@ const prevMonth = (): string => {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)).toISOString().slice(0, 7) return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)).toISOString().slice(0, 7)
} }
/** Current Indian financial year (AprMar) as { from, to } month strings ('YYYY-MM'). */
const currentFy = (): { from: string; to: string } => {
const d = new Date()
const y = d.getUTCFullYear()
const startYear = d.getUTCMonth() >= 3 ? y : y - 1 // month index 3 = April
return { from: `${startYear}-04`, to: `${startYear + 1}-03` }
}
/** 'YYYY-MM' → first day 'YYYY-MM-01'. */
const monthStart = (m: string): string => `${m}-01`
/** 'YYYY-MM' → last calendar day 'YYYY-MM-DD' (so a `doc_date <= to` bound spans the month). */
const monthEnd = (m: string): string => {
const [y, mo] = m.split('-').map(Number)
const last = new Date(Date.UTC(y!, mo!, 0)).getUTCDate()
return `${m}-${String(last).padStart(2, '0')}`
}
/** Reports — dues aging, module revenue, client profitability, and the AWS cost chart (spec §5§6). */ /** Reports — dues aging, module revenue, client profitability, and the AWS cost chart (spec §5§6). */
export function Reports() { export function Reports() {
const nav = useNavigate() const nav = useNavigate()
const [month, setMonth] = useState(prevMonth()) const [month, setMonth] = useState(prevMonth())
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues') const fy = currentFy()
// GST-summary range: defaults to the current financial year, both ends editable.
const [gstFrom, setGstFrom] = useState(fy.from)
const [gstTo, setGstTo] = useState(fy.to)
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'gst' | 'aws'>('dues')
const dues = useData(getDuesAging, []) const dues = useData(getDuesAging, [])
const revenue = useData(() => getModuleRevenue(), []) const revenue = useData(() => getModuleRevenue(), [])
const profit = useData(() => getProfitability(), []) const profit = useData(() => getProfitability(), [])
const gst = useData(() => getGstSummary(monthStart(gstFrom), monthEnd(gstTo)), [gstFrom, gstTo])
const ranking = useData(() => getAwsRanking(month), [month]) const ranking = useData(() => getAwsRanking(month), [month])
return ( return (
@ -32,6 +53,7 @@ export function Reports() {
{ key: 'dues', label: 'Dues aging' }, { key: 'dues', label: 'Dues aging' },
{ key: 'revenue', label: 'Module revenue' }, { key: 'revenue', label: 'Module revenue' },
{ key: 'profit', label: 'Profitability' }, { key: 'profit', label: 'Profitability' },
{ key: 'gst', label: 'GST summary' },
{ key: 'aws', label: 'AWS costs' }, { key: 'aws', label: 'AWS costs' },
]} ]}
/> />
@ -91,6 +113,49 @@ export function Reports() {
) )
)} )}
{report === 'gst' && (
<>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end', margin: '4px 0 4px' }}>
<Field label="From month"><input type="month" className="wf" value={gstFrom} max={gstTo} onChange={(e) => setGstFrom(e.target.value)} /></Field>
<Field label="To month"><input type="month" className="wf" value={gstTo} min={gstFrom} onChange={(e) => setGstTo(e.target.value)} /></Field>
</div>
{gst.error !== undefined ? <ErrorState message={gst.error} onRetry={gst.reload} />
: gst.data === undefined ? <Skeleton rows={6} />
: gst.data.rows.length === 0 ? <EmptyState>No invoices or credit notes in this range.</EmptyState>
: (
<DataTable
columns={[
{ key: 'period', label: 'Period', mono: true },
{ key: 'inv', label: 'Invoices', numeric: true },
{ key: 'cn', label: 'Credit notes', numeric: true },
{ key: 'taxable', label: 'Taxable', numeric: true },
{ key: 'cgst', label: 'CGST', numeric: true },
{ key: 'sgst', label: 'SGST', numeric: true },
{ key: 'igst', label: 'IGST', numeric: true },
{ key: 'tax', label: 'Tax', numeric: true },
{ key: 'total', label: 'Total', numeric: true },
]}
// The appended TOTAL row (the filing figure) is emphasised in bold below.
rows={[...gst.data.rows, gst.data.total].map((r, i) => {
const isTotal = i === gst.data!.rows.length
const cell = (v: string) => (isTotal ? <strong>{v}</strong> : v)
return {
period: cell(r.period === 'TOTAL' ? 'Total' : r.period),
inv: cell(String(r.invoiceCount)),
cn: cell(String(r.creditNoteCount)),
taxable: cell(inr(r.taxablePaise)),
cgst: cell(inr(r.cgstPaise)),
sgst: cell(inr(r.sgstPaise)),
igst: cell(inr(r.igstPaise)),
tax: cell(inr(r.taxPaise)),
total: cell(inr(r.totalPaise)),
}
})}
/>
)}
</>
)}
{report === 'aws' && ( {report === 'aws' && (
<> <>
<Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field> <Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>

@ -0,0 +1,78 @@
import type { Doc, Payment } from './api'
/**
* One line of a client account statement: an invoice (debit), a credit note (credit)
* or a payment (credit), with the running balance AFTER the line is applied. Kept as a
* pure data shape so the running-balance maths can be unit-tested without the DOM.
*/
export interface StatementRow {
date: string
kind: 'invoice' | 'credit_note' | 'payment'
particulars: string
ref: string
/** Charge that raises what the client owes (0 when this row is a credit). */
debitPaise: number
/** Credit that lowers what the client owes (0 when this row is a debit). */
creditPaise: number
/** Cumulative balance after this row — positive = owed by client, negative = in credit. */
balancePaise: number
}
export interface Statement {
rows: StatementRow[]
totalDebitPaise: number
totalCreditPaise: number
/** Final running balance: > 0 the client owes us, < 0 they are in credit (advance). */
closingPaise: number
}
/**
* Build a statement of account from the client's ledger. Only posted receivables move
* the balance: issued (numbered), non-cancelled INVOICEs are debits and CREDIT_NOTEs are
* credits; every payment is a credit (amount + TDS, matching how the ledger settles).
* Quotations, proformas, receipts and drafts are not account postings and are excluded.
* Rows are ordered by date, debits before credits on the same day (conventional).
*/
export function buildStatement(documents: Doc[], payments: Payment[]): Statement {
type Entry = { date: string; order: number; row: Omit<StatementRow, 'balancePaise'> }
const entries: Entry[] = []
for (const d of documents) {
if (d.docNo === null || d.status === 'cancelled') continue
if (d.docType === 'INVOICE') {
entries.push({
date: d.docDate, order: 0,
row: { date: d.docDate, kind: 'invoice', particulars: 'Invoice', ref: d.docNo, debitPaise: d.payablePaise, creditPaise: 0 },
})
} else if (d.docType === 'CREDIT_NOTE') {
entries.push({
date: d.docDate, order: 0,
row: { date: d.docDate, kind: 'credit_note', particulars: 'Credit note', ref: d.docNo, debitPaise: 0, creditPaise: d.payablePaise },
})
}
}
for (const p of payments) {
entries.push({
date: p.receivedOn, order: 1,
row: {
date: p.receivedOn, kind: 'payment',
particulars: `Payment (${p.mode})`, ref: p.reference,
debitPaise: 0, creditPaise: p.amountPaise + p.tdsPaise,
},
})
}
entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order))
let balance = 0
let totalDebitPaise = 0
let totalCreditPaise = 0
const rows: StatementRow[] = entries.map((e) => {
balance += e.row.debitPaise - e.row.creditPaise
totalDebitPaise += e.row.debitPaise
totalCreditPaise += e.row.creditPaise
return { ...e.row, balancePaise: balance }
})
return { rows, totalDebitPaise, totalCreditPaise, closingPaise: balance }
}

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { buildStatement } from '../src/statement'
import type { Doc, Payment } from '../src/api'
/** Minimal Doc factory — only the fields buildStatement reads matter. */
const doc = (over: Partial<Doc>): Doc => ({
id: 'd', docType: 'INVOICE', docNo: 'INV/1', fy: '2026-27', clientId: 'c',
docDate: '2026-04-01', status: 'sent', refDocId: null,
taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, roundOffPaise: 0, payablePaise: 0,
payload: { lines: [] }, source: 'manual', createdBy: 'u', createdAt: '', ...over,
})
const pay = (over: Partial<Payment>): Payment => ({
id: 'p', clientId: 'c', receivedOn: '2026-04-10', mode: 'bank', reference: '',
amountPaise: 0, tdsPaise: 0, createdBy: 'u', createdAt: '', ...over,
})
describe('buildStatement', () => {
it('runs a balance across invoice → payment → credit note', () => {
const s = buildStatement(
[
doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-04-01', payablePaise: 100_00 }),
doc({ id: 'b', docType: 'CREDIT_NOTE', docNo: 'CN/1', docDate: '2026-04-20', payablePaise: 30_00 }),
],
[pay({ id: 'x', receivedOn: '2026-04-10', amountPaise: 40_00 })],
)
expect(s.rows.map((r) => r.balancePaise)).toEqual([100_00, 60_00, 30_00])
expect(s.closingPaise).toBe(30_00) // client still owes ₹30
expect(s.totalDebitPaise).toBe(100_00)
expect(s.totalCreditPaise).toBe(70_00)
})
it('excludes quotations, proformas, receipts, drafts and cancelled docs', () => {
const s = buildStatement(
[
doc({ id: 'q', docType: 'QUOTATION', docNo: 'QT/1', payablePaise: 500_00 }),
doc({ id: 'pf', docType: 'PROFORMA', docNo: 'PI/1', payablePaise: 500_00 }),
doc({ id: 'rc', docType: 'RECEIPT', docNo: 'RC/1', payablePaise: 500_00 }),
doc({ id: 'dr', docType: 'INVOICE', docNo: null, payablePaise: 500_00 }), // unissued draft
doc({ id: 'cx', docType: 'INVOICE', docNo: 'INV/9', status: 'cancelled', payablePaise: 500_00 }),
],
[],
)
expect(s.rows).toHaveLength(0)
expect(s.closingPaise).toBe(0)
})
it('orders by date, debits before credits on the same day', () => {
const s = buildStatement(
[doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-05-05', payablePaise: 10_00 })],
[pay({ id: 'x', receivedOn: '2026-05-05', amountPaise: 4_00 })],
)
expect(s.rows.map((r) => r.kind)).toEqual(['invoice', 'payment'])
})
it('counts TDS as part of the payment credit and can close in credit (advance)', () => {
const s = buildStatement(
[doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-06-01', payablePaise: 100_00 })],
[pay({ id: 'x', receivedOn: '2026-06-02', amountPaise: 90_00, tdsPaise: 20_00 })],
)
expect(s.rows[1]!.creditPaise).toBe(110_00) // 90 + 20 TDS
expect(s.closingPaise).toBe(-10_00) // overpaid → client in credit
})
})
Loading…
Cancel
Save