Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions src/app/(app)/admin/loans/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -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)}`,
}
Expand Down
16 changes: 3 additions & 13 deletions src/app/(app)/dashboard/loans/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -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)}`,
}
Expand Down
157 changes: 40 additions & 117 deletions src/components/loans-list-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand All @@ -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<string, string> = {
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<string, string> = {
active: 'Active',
paid: 'Paid',
write_off: 'Write off',
}
const STATUS_RANK: Record<string, number> = {
active: 0, // surface active first when sorted asc
paid: 1,
write_off: 2,
}
const TYPE_PILL: Record<string, string> = {
personal: 'bg-gray-50 text-gray-700 ring-gray-200',
medical: 'bg-violet-50 text-violet-700 ring-violet-200',
Expand Down Expand Up @@ -104,37 +87,28 @@ export function LoansListTable({
const augmented = useMemo<LoansListRowAug[]>(
() =>
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<string, string>()
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.
Expand All @@ -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) =>
Expand Down Expand Up @@ -274,6 +246,16 @@ export function LoansListTable({
</span>
)
})()}
{/* 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' && (
<span
title="Written off as bad debt"
className="whitespace-nowrap rounded-full bg-rose-50 px-2 py-0.5 font-sans text-[11px] font-medium text-rose-700 ring-1 ring-rose-200"
>
Write off
</span>
)}
</span>
),
footer: 'Total',
Expand All @@ -293,21 +275,14 @@ export function LoansListTable({
sortable: true,
bodyClassName: 'whitespace-nowrap px-3 py-2.5',
body: (l) => (
<div className="flex items-center gap-1.5">
<span
className={
'rounded-full px-2 py-0.5 text-xs font-medium ring-1 ' +
(TYPE_PILL[l.loan_type] ?? TYPE_PILL.personal)
}
>
{l._type_label}
</span>
{l.repayment_model === 'emi' && (
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700 ring-1 ring-indigo-200">
EMI
</span>
)}
</div>
<span
className={
'inline-block rounded-full px-2 py-0.5 text-xs font-medium ring-1 ' +
(TYPE_PILL[l.loan_type] ?? TYPE_PILL.personal)
}
>
{l._type_label}
</span>
),
},
{
Expand All @@ -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) : <span className="text-gray-400">—</span>,
},
{
field: '_start_ts',
header: 'Start date',
Expand Down Expand Up @@ -348,69 +334,6 @@ export function LoansListTable({
},
] as PrColumn<LoansListRowAug>[])
: []),
{
field: '_status_label',
header: 'Status',
sortable: true,
sortField: '_status_rank',
filter: true,
filterField: '_status_label',
filterElement: ({ value, filterApplyCallback }) => (
<Dropdown
value={(value as string) ?? null}
options={statusOptions}
onChange={(e) => filterApplyCallback(e.value)}
placeholder="Any status"
showClear
className="w-full"
/>
),
bodyClassName: 'whitespace-nowrap px-3 py-2.5',
body: (l) => (
<span
className={
'inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium ring-1 ' +
(STATUS_PILL[l.status] ?? STATUS_PILL.active)
}
>
{l._status_label}
</span>
),
},
{
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 (
<span
className={
'whitespace-nowrap text-right tabular-nums ' +
(isClosedLoan
? 'text-gray-400'
: l.interest_due > 0
? 'font-medium text-amber-700'
: 'text-gray-500')
}
>
{isClosedLoan ? '—' : formatRupees(l.interest_due)}
</span>
)
},
bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right',
},
{
field: 'balance',
header: 'Outstanding',
Expand Down Expand Up @@ -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}
Expand Down
Loading