diff --git a/src/app/(app)/admin/loans/page.tsx b/src/app/(app)/admin/loans/page.tsx index 2e7b392..b8436ea 100644 --- a/src/app/(app)/admin/loans/page.tsx +++ b/src/app/(app)/admin/loans/page.tsx @@ -1,11 +1,7 @@ import Link from 'next/link' import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' -import { - getLoans, - getInterestPerLakh, - getPendingInterestByLoan, -} from '@/lib/actions/loans' +import { getLoans, getInterestPerLakh } from '@/lib/actions/loans' import { LoansListTable, type LoansListRow } from '@/components/loans-list-table' import { LoansTabs, type LoansTabKey } from '@/components/loans-tabs' import { computeLoanFinancials, type LoanTxnInput } from '@/lib/loan-math' @@ -41,14 +37,13 @@ export default async function AdminLoansListPage({ past_due_count: number | null oldest_past_due_date: string | null } - const [{ data: txnsRaw }, pendingInterestByLoan, { data: emiBalRaw }] = await Promise.all([ + const [{ data: txnsRaw }, { data: emiBalRaw }] = await Promise.all([ loanIds.length ? supabase .from('transactions') .select('loan_id, amount, transaction_type, interest_source, transaction_date') .in('loan_id', loanIds) : Promise.resolve({ data: [] as unknown[] }), - getPendingInterestByLoan(loanIds), emiLoanIds.length ? supabase .from('loan_emi_balances') @@ -80,7 +75,6 @@ export default async function AdminLoansListPage({ const tableRows: LoansListRow[] = loans.map((l) => { const f = computeLoanFinancials(l, txnsByLoan.get(l.id) ?? [], interestPerLakh) - const accrualPending = pendingInterestByLoan.get(l.id) ?? 0 return { id: l.id, loan_number: l.loan_number, @@ -95,11 +89,7 @@ export default async function AdminLoansListPage({ overdue_count: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.count ?? 0 : 0, oldest_overdue_date: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.oldest ?? null : null, - paid_interest: f.paidInterestTotal, - // Interest due reflects the accrual ledger (loan_interest_accruals), - // not legacy on-the-fly math — keeps the list in lockstep with the - // Pending-interest panel on the detail page. - interest_due: f.isClosed ? 0 : accrualPending, + emi_amount: l.repayment_model === 'emi' ? l.emi_amount : null, balance: f.balance, detail_href: `/admin/loans/${encodeURIComponent(l.loan_number)}`, } diff --git a/src/app/(app)/dashboard/loans/page.tsx b/src/app/(app)/dashboard/loans/page.tsx index bbaa109..dfbcd1d 100644 --- a/src/app/(app)/dashboard/loans/page.tsx +++ b/src/app/(app)/dashboard/loans/page.tsx @@ -1,10 +1,6 @@ import Link from 'next/link' import { createClient } from '@/lib/supabase/server' -import { - getLoans, - getInterestPerLakh, - getPendingInterestByLoan, -} from '@/lib/actions/loans' +import { getLoans, getInterestPerLakh } from '@/lib/actions/loans' import { LoansListTable, type LoansListRow } from '@/components/loans-list-table' import { LoansTabs, type LoansTabKey } from '@/components/loans-tabs' import { computeLoanFinancials, type LoanTxnInput } from '@/lib/loan-math' @@ -49,14 +45,13 @@ export default async function LoansListPage({ const loanIds = loans.map((l) => l.id) const emiLoanIds = loans.filter((l) => l.repayment_model === 'emi').map((l) => l.id) - const [{ data: txnsRaw }, pendingInterestByLoan, { data: emiBalRaw }] = await Promise.all([ + const [{ data: txnsRaw }, { data: emiBalRaw }] = await Promise.all([ loanIds.length ? supabase .from('transactions') .select('loan_id, amount, transaction_type, interest_source, transaction_date') .in('loan_id', loanIds) : Promise.resolve({ data: [] as unknown[] }), - getPendingInterestByLoan(loanIds), emiLoanIds.length ? supabase .from('loan_emi_balances') @@ -88,7 +83,6 @@ export default async function LoansListPage({ const tableRows: LoansListRow[] = loans.map((l) => { const f = computeLoanFinancials(l, txnsByLoan.get(l.id) ?? [], interestPerLakh) - const accrualPending = pendingInterestByLoan.get(l.id) ?? 0 return { id: l.id, loan_number: l.loan_number, @@ -103,11 +97,7 @@ export default async function LoansListPage({ overdue_count: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.count ?? 0 : 0, oldest_overdue_date: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.oldest ?? null : null, - paid_interest: f.paidInterestTotal, - // Interest due reflects the accrual ledger (loan_interest_accruals), - // not legacy on-the-fly math — keeps the list in lockstep with the - // Pending-interest panel on the detail page. - interest_due: f.isClosed ? 0 : accrualPending, + emi_amount: l.repayment_model === 'emi' ? l.emi_amount : null, balance: f.balance, detail_href: `/dashboard/loans/${encodeURIComponent(l.loan_number)}`, } diff --git a/src/components/loans-list-table.tsx b/src/components/loans-list-table.tsx index 17dc9cc..48b323d 100644 --- a/src/components/loans-list-table.tsx +++ b/src/components/loans-list-table.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react' import Link from 'next/link' -import { Dropdown } from 'primereact/dropdown' import { formatRupees } from '@/lib/format' import { overdueParts, formatOverdueDuration } from '@/lib/due' import { getLoanDetail, type LoanDetailData } from '@/lib/actions/loans' @@ -30,8 +29,8 @@ export type LoansListRow = { overdue_count?: number /** Earliest past-due installment's due date (drives the overdue duration). */ oldest_overdue_date?: string | null - paid_interest: number - interest_due: number + /** Monthly installment for EMI loans; null for accrual-model loans. */ + emi_amount?: number | null balance: number detail_href: string } @@ -41,27 +40,11 @@ export type LoansListRow = { type LoansListRowAug = LoansListRow & { _start_ts: number _end_ts: number - _status_label: string - _status_rank: number + _emi: number _type_label: string _search_blob: string } -const STATUS_PILL: Record = { - active: 'bg-blue-50 text-blue-700 ring-blue-200', - paid: 'bg-emerald-50 text-emerald-700 ring-emerald-200', - write_off: 'bg-rose-50 text-rose-700 ring-rose-200', -} -const STATUS_LABEL: Record = { - active: 'Active', - paid: 'Paid', - write_off: 'Write off', -} -const STATUS_RANK: Record = { - active: 0, // surface active first when sorted asc - paid: 1, - write_off: 2, -} const TYPE_PILL: Record = { personal: 'bg-gray-50 text-gray-700 ring-gray-200', medical: 'bg-violet-50 text-violet-700 ring-violet-200', @@ -104,37 +87,28 @@ export function LoansListTable({ const augmented = useMemo( () => loans.map((l) => { - const statusLabel = STATUS_LABEL[l.status] ?? l.status const typeLabel = TYPE_LABEL[l.loan_type] ?? l.loan_type + const emi = Number(l.emi_amount ?? 0) return { ...l, _start_ts: new Date(l.start_date).getTime(), _end_ts: l.end_date ? new Date(l.end_date).getTime() : 0, - _status_label: statusLabel, - _status_rank: STATUS_RANK[l.status] ?? 99, + _emi: emi, _type_label: typeLabel, _search_blob: [ l.loan_number, l.member_name ?? '', String(l.principal_amount), + emi ? String(emi) : '', formatDate(l.start_date), - statusLabel, typeLabel, + l.status === 'write_off' ? 'Write off' : '', ].join(' '), } }), [loans], ) - // Distinct status labels present → drives the Status dropdown filter. - const statusOptions = useMemo(() => { - const seen = new Map() - for (const l of augmented) seen.set(l._status_label, l._status_label) - return Array.from(seen.values()) - .sort((a, b) => a.localeCompare(b)) - .map((label) => ({ label, value: label })) - }, [augmented]) - // The DataTable reports its current filtered+sorted rows here; export, the // count strip and the totals footer all derive from these so they reflect // what's on screen. `null` until the first onValueChange → full set. @@ -146,20 +120,18 @@ export function LoansListTable({ // --- Export (reflects the current filter + sort) ------------------------- const exportColumns = [ - 'Loan #', 'Member', 'Type', 'Principal (₹)', 'Start date', + 'Loan #', 'Member', 'Type', 'Principal (₹)', 'EMI (₹)', 'Start date', ...(showEndDate ? ['End date'] : []), - 'Status', 'Interest paid (₹)', 'Interest due (₹)', 'Outstanding (₹)', + 'Outstanding (₹)', ] const exportRows: Cell[][] = visible.map((l) => [ l.loan_number, l.member_name ?? '', l._type_label, l.principal_amount, + l._emi > 0 ? l._emi : '', formatDate(l.start_date), ...(showEndDate ? [formatDate(l.end_date ?? null)] : []), - l._status_label, - l.paid_interest, - l.status === 'paid' || l.status === 'write_off' ? '' : l.interest_due, l.balance, ]) const exportFooter: Cell[] = exportColumns.map((c, i) => @@ -274,6 +246,16 @@ export function LoansListTable({ ) })()} + {/* The Status column is gone, so the Past tab would otherwise show + paid and written-off loans identically — mark the write-offs. */} + {l.status === 'write_off' && ( + + Write off + + )} ), footer: 'Total', @@ -293,21 +275,14 @@ export function LoansListTable({ sortable: true, bodyClassName: 'whitespace-nowrap px-3 py-2.5', body: (l) => ( -
- - {l._type_label} - - {l.repayment_model === 'emi' && ( - - EMI - - )} -
+ + {l._type_label} + ), }, { @@ -319,6 +294,17 @@ export function LoansListTable({ bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right tabular-nums text-gray-700', body: (l) => formatRupees(l.principal_amount), }, + { + field: '_emi', + header: 'EMI', + sortable: true, + align: 'right', + dataType: 'numeric', + bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right tabular-nums text-gray-700', + // Accrual-model loans have no installment — show a dash rather than ₹0. + body: (l) => + l._emi > 0 ? formatRupees(l._emi) : , + }, { field: '_start_ts', header: 'Start date', @@ -348,69 +334,6 @@ export function LoansListTable({ }, ] as PrColumn[]) : []), - { - field: '_status_label', - header: 'Status', - sortable: true, - sortField: '_status_rank', - filter: true, - filterField: '_status_label', - filterElement: ({ value, filterApplyCallback }) => ( - filterApplyCallback(e.value)} - placeholder="Any status" - showClear - className="w-full" - /> - ), - bodyClassName: 'whitespace-nowrap px-3 py-2.5', - body: (l) => ( - - {l._status_label} - - ), - }, - { - field: 'paid_interest', - header: 'Interest paid', - sortable: true, - align: 'right', - dataType: 'numeric', - bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right tabular-nums text-gray-700', - body: (l) => formatRupees(l.paid_interest), - }, - { - field: 'interest_due', - header: 'Interest due', - sortable: true, - align: 'right', - dataType: 'numeric', - body: (l) => { - const isClosedLoan = l.status === 'paid' || l.status === 'write_off' - return ( - 0 - ? 'font-medium text-amber-700' - : 'text-gray-500') - } - > - {isClosedLoan ? '—' : formatRupees(l.interest_due)} - - ) - }, - bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right', - }, { field: 'balance', header: 'Outstanding', @@ -473,7 +396,7 @@ export function LoansListTable({ dataKey="id" emptyMessage={emptyMessage ?? 'No loans yet.'} globalFilterFields={loans.length > 0 ? ['_search_blob'] : undefined} - globalSearchPlaceholder="Search by loan #, member, status…" + globalSearchPlaceholder="Search by loan #, member, type…" header={loans.length > 0 ? exportMenu : undefined} onValueChange={setProcessed} onGlobalFilterChange={setSearchQuery}