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
parent
3247585e52
commit
18e47eb448
@ -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…
Reference in New Issue