From 66744c482a1e1ad255d7de85cc333d168d841c7e Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Thu, 16 Jul 2026 20:45:20 +0530 Subject: [PATCH] feat(web): interest controls + per-flat statement UI (#95/#96) Completes the billing-page UI for M3.5. - Bill-config editor gains editable interest controls: enable toggle, annual rate %, grace days (replacing the pass-through). (#95) - Interest card: preview accrued interest per flat + one-click "Charge interest" (posts one-time interest bills, idempotent per period). (#95) - Statement card: pick a flat + date range -> dated running-balance statement (opening/closing, debits/credits per row). (#96) - ~20 i18n keys (en + hi) + common.date; parity green. - Validated: shared build, web check-types, vite build, i18n-parity, lint 0 errors. --- apps/web/src/routes/admin/billing.tsx | 180 +++++++++++++++++++++++++- packages/shared/src/i18n.ts | 32 +++++ 2 files changed, 207 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/admin/billing.tsx b/apps/web/src/routes/admin/billing.tsx index 41f7f85..28a07b9 100644 --- a/apps/web/src/routes/admin/billing.tsx +++ b/apps/web/src/routes/admin/billing.tsx @@ -288,6 +288,9 @@ function BillConfigForm({ initial }: { initial: BillConfig }) { ) const setLine = (i: number, patch: Partial) => setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) + const [interestEnabled, setInterestEnabled] = useState(initial.interestEnabled) + const [interestRate, setInterestRate] = useState(String(initial.interestRatePct)) + const [graceDays, setGraceDays] = useState(String(initial.gracePeriodDays)) const save = useMutation({ mutationFn: () => @@ -296,11 +299,9 @@ function BillConfigForm({ initial }: { initial: BillConfig }) { lineItems: lines .filter((l) => l.description.trim() && l.amount) .map((l) => ({ description: l.description.trim(), amount: rupeesToPaise(l.amount), taxRatePct: parseInt(l.taxRatePct) || 0 })), - // Interest-on-arrears settings (#95) are preserved here; the editable - // controls land with the M3.5 admin-UI pass. - interestEnabled: initial.interestEnabled, - interestRatePct: initial.interestRatePct, - gracePeriodDays: initial.gracePeriodDays, + interestEnabled, + interestRatePct: parseInt(interestRate) || 0, + gracePeriodDays: parseInt(graceDays) || 0, }), onSuccess: () => qc.invalidateQueries({ queryKey: ['bill-config'] }), }) @@ -329,6 +330,23 @@ function BillConfigForm({ initial }: { initial: BillConfig }) { {t('page.billing.addLine')} +
+ +
+
+ + setInterestRate(e.target.value)} disabled={!interestEnabled} /> +
+
+ + setGraceDays(e.target.value)} disabled={!interestEnabled} /> +
+
+

{t('page.billing.interestHint')}

+
@@ -355,6 +373,156 @@ function BillConfigCard() { ) } +// Interest on arrears (#95): preview accrued interest per flat, then charge it. +function InterestCard() { + const { t } = useT() + const qc = useQueryClient() + const preview = useQuery({ queryKey: ['interest-preview'], queryFn: () => apiClient.getInterestPreview() }) + const apply = useMutation({ + mutationFn: () => apiClient.applyInterest(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['interest-preview'] }) + qc.invalidateQueries({ queryKey: ['dues'] }) + qc.invalidateQueries({ queryKey: ['bills'] }) + }, + }) + const rows = preview.data?.rows ?? [] + const total = rows.reduce((s, r) => s + r.interest, 0) + + return ( + + + {t('page.billing.interestTitle')} + + + + {!preview.data?.enabled ? ( +

{t('page.billing.interestDisabled')}

+ ) : rows.length === 0 ? ( +

{t('page.billing.noInterest')}

+ ) : ( + + + + {t('common.apartment')} + {t('page.billing.accruedInterest')} + + + + {rows.map((r) => ( + + {r.apartment} + {formatPaise(r.interest)} + + ))} + + {t('page.reports.total')} + {formatPaise(total)} + + +
+ )} + {apply.isSuccess && ( +

+ {t('page.billing.interestApplied')} ({apply.data?.created ?? 0}) +

+ )} +
+
+ ) +} + +// Per-flat statement of account (#96): pick a flat + range, see a running balance. +function StatementCard() { + const { t } = useT() + const apartments = useQuery({ queryKey: ['apartments'], queryFn: () => apiClient.listApartments() }) + const [apartmentId, setApartmentId] = useState('') + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const statement = useQuery({ + queryKey: ['statement', apartmentId, from, to], + queryFn: () => apiClient.getApartmentStatement(apartmentId, from || undefined, to || undefined), + enabled: !!apartmentId, + }) + + return ( + + + {t('page.billing.statementTitle')} + + +
+
+ + +
+
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+
+ {statement.data && ( + + + + {t('common.date')} + {t('common.description')} + {t('page.reports.debit')} + {t('page.reports.credit')} + {t('page.billing.balance')} + + + + + + {t('page.reports.opening')} + + {formatPaise(statement.data.opening)} + + {statement.data.entries.map((e, i) => ( + + {e.date.slice(0, 10)} + {e.description} + {e.debit ? formatPaise(e.debit) : ''} + {e.credit ? formatPaise(e.credit) : ''} + {formatPaise(e.balance)} + + ))} + + + {t('page.reports.closing')} + + {formatPaise(statement.data.closing)} + + +
+ )} +
+
+ ) +} + function BillingPage() { const { t } = useT() return ( @@ -369,7 +537,9 @@ function BillingPage() { + + ) diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 0d55bd4..d23c079 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -25,6 +25,7 @@ const commonEn: Dict = { 'common.priority': 'Priority', 'common.title': 'Title', 'common.description': 'Description', + 'common.date': 'Date', } const commonHi: Dict = { 'nav.houseHelp': 'घरेलू सहायक', @@ -44,6 +45,7 @@ const commonHi: Dict = { 'common.priority': 'प्राथमिकता', 'common.title': 'शीर्षक', 'common.description': 'विवरण', + 'common.date': 'दिनांक', } const webEn: Dict = { @@ -335,6 +337,21 @@ const webEn: Dict = { 'page.visitors.deny': 'Deny', 'page.billing.configTitle': 'Bill configuration (recurring template)', 'page.billing.dueDay': 'Due day of month', + 'page.billing.interestEnabled': 'Charge interest on overdue dues', + 'page.billing.interestRate': 'Interest rate % p.a.', + 'page.billing.graceDays': 'Grace period (days)', + 'page.billing.interestHint': 'Simple interest, opt-in, waivable. Cap set by your registered bye-laws.', + 'page.billing.interestTitle': 'Interest on arrears', + 'page.billing.applyInterest': 'Charge interest', + 'page.billing.interestDisabled': 'Interest is off — enable it in the bill config above.', + 'page.billing.noInterest': 'No overdue interest to charge right now.', + 'page.billing.accruedInterest': 'Accrued interest', + 'page.billing.interestApplied': 'Interest charged', + 'page.billing.statementTitle': 'Flat statement of account', + 'page.billing.selectFlat': 'Select flat', + 'page.billing.from': 'From', + 'page.billing.to': 'To', + 'page.billing.balance': 'Balance', 'page.billing.recurringLines': 'Recurring line items', 'page.billing.lineItems': 'Line items', 'page.billing.amount': 'Amount ₹', @@ -757,6 +774,21 @@ const webHi: Dict = { 'page.visitors.deny': 'अस्वीकृत करें', 'page.billing.configTitle': 'बिल कॉन्फ़िगरेशन (आवर्ती टेम्पलेट)', 'page.billing.dueDay': 'महीने का देय दिन', + 'page.billing.interestEnabled': 'बकाया राशि पर ब्याज लगाएँ', + 'page.billing.interestRate': 'ब्याज दर % प्रति वर्ष', + 'page.billing.graceDays': 'छूट अवधि (दिन)', + 'page.billing.interestHint': 'साधारण ब्याज, वैकल्पिक, माफ़ी योग्य। सीमा आपके पंजीकृत उपनियमों अनुसार।', + 'page.billing.interestTitle': 'बकाया पर ब्याज', + 'page.billing.applyInterest': 'ब्याज लगाएँ', + 'page.billing.interestDisabled': 'ब्याज बंद है — ऊपर बिल कॉन्फ़िग में सक्षम करें।', + 'page.billing.noInterest': 'अभी लगाने के लिए कोई बकाया ब्याज नहीं।', + 'page.billing.accruedInterest': 'संचित ब्याज', + 'page.billing.interestApplied': 'ब्याज लगाया गया', + 'page.billing.statementTitle': 'फ्लैट खाता विवरण', + 'page.billing.selectFlat': 'फ्लैट चुनें', + 'page.billing.from': 'से', + 'page.billing.to': 'तक', + 'page.billing.balance': 'शेष', 'page.billing.recurringLines': 'आवर्ती मद', 'page.billing.lineItems': 'मद', 'page.billing.amount': 'राशि ₹',