Skip to content

Commit a9c4c74

Browse files
parameshjavapkorrakuti-mvrkclaude
authored
feat(dashboard): "This Month" contributions tab + restyled sort icons (#31)
Co-authored-by: Paramesh Korrakuti <pkorrakuti@mavvrik.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 08f5e3f commit a9c4c74

5 files changed

Lines changed: 329 additions & 2 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
'use client'
2+
3+
import { useMemo, useState } from 'react'
4+
import { formatRupees } from '@/lib/format'
5+
import { TableExportMenu } from '@/components/table-export'
6+
import { PrDataTable, type PrColumn } from '@/components/ui/pr/data-table'
7+
import type { Cell } from '@/lib/table-export'
8+
9+
export type CurrentMonthRow = {
10+
id: string
11+
transaction_id: string
12+
amount: number | string
13+
transaction_date: string
14+
member_name?: string | null
15+
bank_transaction_id?: string | null
16+
}
17+
18+
/** Flattened fields baked onto each row so the DataTable can sort / filter /
19+
* globally search on flat string/number values. */
20+
type CurrentMonthRowAug = CurrentMonthRow & {
21+
_date_ts: number
22+
_amount: number
23+
_member: string
24+
_search_blob: string
25+
}
26+
27+
function formatDate(iso: string): string {
28+
const d = new Date(iso)
29+
const dd = String(d.getUTCDate()).padStart(2, '0')
30+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
31+
const yyyy = d.getUTCFullYear()
32+
return `${dd}-${mm}-${yyyy}`
33+
}
34+
35+
export function CurrentMonthContributionsTable({
36+
rows,
37+
}: {
38+
rows: CurrentMonthRow[]
39+
}) {
40+
const augmented = useMemo<CurrentMonthRowAug[]>(
41+
() =>
42+
rows.map((r) => {
43+
const member = r.member_name ?? ''
44+
return {
45+
...r,
46+
_date_ts: new Date(r.transaction_date).getTime(),
47+
_amount: Number(r.amount) || 0,
48+
_member: member,
49+
_search_blob: [
50+
member,
51+
r.transaction_id,
52+
r.bank_transaction_id ?? '',
53+
formatDate(r.transaction_date),
54+
String(r.amount),
55+
].join(' '),
56+
}
57+
}),
58+
[rows],
59+
)
60+
61+
// The DataTable reports its current filtered+sorted rows here so the export +
62+
// footer summary reflect what's on screen. `null` until the first
63+
// onValueChange fires → fall back to the full set.
64+
const [processed, setProcessed] = useState<CurrentMonthRowAug[] | null>(null)
65+
const [searchQuery, setSearchQuery] = useState('')
66+
const visible = processed ?? augmented
67+
68+
const total = visible.reduce((s, r) => s + r._amount, 0)
69+
70+
const exportColumns = ['#', 'Person name', 'Contribution (₹)', 'Date', 'Transaction ID']
71+
const exportRows: Cell[][] = visible.map((t, i) => [
72+
i + 1,
73+
t._member,
74+
t._amount,
75+
formatDate(t.transaction_date),
76+
t.transaction_id,
77+
])
78+
const exportFooter: Cell[] = ['', '', total, '', 'Total']
79+
80+
const columns: PrColumn<CurrentMonthRowAug>[] = [
81+
{
82+
field: 'id',
83+
header: '#',
84+
align: 'right',
85+
bodyClassName: 'w-10 text-right tabular-nums text-gray-500',
86+
body: (_t, { rowIndex }) => rowIndex + 1,
87+
},
88+
{
89+
field: '_member',
90+
header: 'Person name',
91+
sortable: true,
92+
dataType: 'text',
93+
bodyClassName: 'font-medium text-gray-900',
94+
body: (t) => t.member_name ?? <span className="text-gray-400"></span>,
95+
},
96+
{
97+
field: '_amount',
98+
header: 'Contribution',
99+
sortable: true,
100+
align: 'right',
101+
dataType: 'numeric',
102+
bodyClassName: 'whitespace-nowrap text-right font-semibold tabular-nums text-gray-900',
103+
body: (t) => formatRupees(t.amount),
104+
},
105+
{
106+
field: '_date_ts',
107+
header: 'Date',
108+
sortable: true,
109+
dataType: 'numeric',
110+
bodyClassName: 'whitespace-nowrap text-gray-600',
111+
body: (t) => formatDate(t.transaction_date),
112+
},
113+
{
114+
field: 'transaction_id',
115+
header: 'Transaction ID',
116+
sortable: true,
117+
bodyClassName: 'whitespace-nowrap font-mono text-xs text-gray-500',
118+
body: (t) => (
119+
<span>
120+
<span>{t.transaction_id}</span>
121+
{t.bank_transaction_id && (
122+
<span className="ml-2 text-[11px] text-gray-400" title="Bank reference">
123+
{t.bank_transaction_id}
124+
</span>
125+
)}
126+
</span>
127+
),
128+
},
129+
]
130+
131+
const exportMenu = (
132+
<TableExportMenu
133+
filename="current-month-contributions"
134+
title="Current month contributions"
135+
columns={exportColumns}
136+
rows={exportRows}
137+
footer={exportFooter}
138+
/>
139+
)
140+
141+
return (
142+
<div className="overflow-clip rounded-2xl border border-gray-200 bg-white">
143+
<PrDataTable<CurrentMonthRowAug>
144+
value={augmented}
145+
columns={columns}
146+
dataKey="id"
147+
emptyMessage={
148+
searchQuery
149+
? `No matches for "${searchQuery}"`
150+
: 'No contributions recorded this month yet'
151+
}
152+
globalFilterFields={rows.length > 0 ? ['_search_blob'] : undefined}
153+
globalSearchPlaceholder="Search rows…"
154+
header={rows.length > 0 ? exportMenu : undefined}
155+
onValueChange={setProcessed}
156+
onGlobalFilterChange={setSearchQuery}
157+
/>
158+
159+
{visible.length > 0 && (
160+
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50/30 px-5 py-3 text-xs text-gray-500">
161+
<span>
162+
Showing <span className="font-medium text-gray-900">{visible.length}</span>{' '}
163+
{visible.length === 1 ? 'contribution' : 'contributions'}
164+
{augmented.length !== visible.length && (
165+
<span className="text-gray-400"> · filtered from {augmented.length}</span>
166+
)}
167+
</span>
168+
<span className="font-medium text-gray-400">
169+
Total{' '}
170+
<span className="ml-1 tabular-nums text-gray-900">{formatRupees(total)}</span>
171+
</span>
172+
</div>
173+
)}
174+
</div>
175+
)
176+
}

src/app/(app)/dashboard/dashboard-tabs.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import { useState, type ReactNode } from 'react'
44
import { RefreshButton } from '@/components/ui/refresh-button'
55
import { PrTabStrip } from '@/components/ui/pr/tabs'
66

7-
type Tab = 'inflow' | 'matrix' | 'members' | 'eligibility'
7+
type Tab = 'inflow' | 'matrix' | 'members' | 'eligibility' | 'thismonth'
88

99
const DASHBOARD_TABS: { value: Tab; label: string }[] = [
1010
{ value: 'inflow', label: 'Monthly Inflow' },
1111
{ value: 'matrix', label: 'Member × Month' },
1212
{ value: 'members', label: 'Total Contributions' },
1313
{ value: 'eligibility', label: 'Donation Eligibility' },
14+
{ value: 'thismonth', label: 'This Month' },
1415
]
1516

1617
/**
@@ -37,10 +38,12 @@ export function DashboardTabs({
3738
matrixChart,
3839
membersChart,
3940
eligibilityChart,
41+
thisMonthChart,
4042
inflowSection,
4143
matrixSection,
4244
membersSection,
4345
eligibilitySection,
46+
thisMonthSection,
4447
footer,
4548
}: {
4649
initialTab: Tab
@@ -49,10 +52,12 @@ export function DashboardTabs({
4952
matrixChart: ReactNode
5053
membersChart: ReactNode
5154
eligibilityChart: ReactNode
55+
thisMonthChart: ReactNode
5256
inflowSection: ReactNode
5357
matrixSection: ReactNode
5458
membersSection: ReactNode
5559
eligibilitySection: ReactNode
60+
thisMonthSection: ReactNode
5661
footer?: ReactNode
5762
}) {
5863
const [tab, setTab] = useState<Tab>(initialTab)
@@ -97,13 +102,15 @@ export function DashboardTabs({
97102
<div hidden={tab !== 'matrix'}>{mounted.has('matrix') ? matrixChart : null}</div>
98103
<div hidden={tab !== 'members'}>{mounted.has('members') ? membersChart : null}</div>
99104
<div hidden={tab !== 'eligibility'}>{mounted.has('eligibility') ? eligibilityChart : null}</div>
105+
<div hidden={tab !== 'thismonth'}>{mounted.has('thismonth') ? thisMonthChart : null}</div>
100106
</section>
101107

102108
<section>
103109
<div hidden={tab !== 'inflow'}>{mounted.has('inflow') ? inflowSection : null}</div>
104110
<div hidden={tab !== 'matrix'}>{mounted.has('matrix') ? matrixSection : null}</div>
105111
<div hidden={tab !== 'members'}>{mounted.has('members') ? membersSection : null}</div>
106112
<div hidden={tab !== 'eligibility'}>{mounted.has('eligibility') ? eligibilitySection : null}</div>
113+
<div hidden={tab !== 'thismonth'}>{mounted.has('thismonth') ? thisMonthSection : null}</div>
107114
{footer && <div>{footer}</div>}
108115
</section>
109116
</>

src/app/(app)/dashboard/page.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getDashboardMemberTotals,
2020
getDashboardMemberMonthMatrix,
2121
getDashboardTransactions,
22+
getCurrentMonthContributions,
2223
getDashboardEligibilitySummary,
2324
getDashboardEligibilityLedger,
2425
type DashboardTxn,
@@ -30,6 +31,7 @@ import { getTotalPendingPrincipal } from '@/lib/actions/loans'
3031
import { Admonition } from '@/components/ui/admonition'
3132
import { SubmitPaymentForm } from './submit-payment-form'
3233
import { DashboardTabs } from './dashboard-tabs'
34+
import { CurrentMonthContributionsTable } from './current-month-contributions-table'
3335
import { EligibilityMonthlyChart } from './eligibility-monthly-chart'
3436
import { getSocialContributionReserve } from '@/lib/actions/exits'
3537

@@ -50,7 +52,7 @@ const MONTH_LABELS = [
5052
'Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec',
5153
]
5254

53-
type TabKey = 'inflow' | 'matrix' | 'members' | 'eligibility'
55+
type TabKey = 'inflow' | 'matrix' | 'members' | 'eligibility' | 'thismonth'
5456

5557
export default async function DashboardPage({
5658
searchParams,
@@ -75,6 +77,8 @@ export default async function DashboardPage({
7577
? 'matrix'
7678
: tabParam === 'eligibility'
7779
? 'eligibility'
80+
: tabParam === 'thismonth'
81+
? 'thismonth'
7882
: 'inflow'
7983

8084
const supabase = await createClient()
@@ -157,6 +161,15 @@ export default async function DashboardPage({
157161
const todayIST = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Kolkata' }))
158162
const currentYear = todayIST.getFullYear()
159163
const currentMonthIdx = todayIST.getMonth() // 0-indexed
164+
165+
// "This Month" tab — every contribution made this calendar month (IST) by an
166+
// active member. The month key is computed here (not inside the cached read)
167+
// because Cache Components forbids reading the clock in a `'use cache'` scope.
168+
const currentMonthIso = `${currentYear}-${String(currentMonthIdx + 1).padStart(2, '0')}`
169+
const currentMonthLabel = `${MONTH_LABELS_LONG[currentMonthIdx]} ${currentYear}`
170+
const currentMonthRows = (await getCurrentMonthContributions(currentMonthIso)).map(toTxnRow)
171+
const currentMonthTotal = currentMonthRows.reduce((s, r) => s + Number(r.amount || 0), 0)
172+
160173
const eligibilityMonthlyData = buildEligibilityMonthlyData(
161174
rowsByMonth,
162175
eligibilityLedger,
@@ -304,6 +317,18 @@ export default async function DashboardPage({
304317
</div>
305318
</div>
306319
}
320+
thisMonthChart={
321+
<div>
322+
<h2 className="text-base font-semibold text-gray-900">
323+
Contributions · {currentMonthLabel}
324+
</h2>
325+
<p className="text-xs text-gray-500">
326+
{currentMonthRows.length}{' '}
327+
{currentMonthRows.length === 1 ? 'contribution' : 'contributions'} from active
328+
members this month · {formatRupees(currentMonthTotal)}
329+
</p>
330+
</div>
331+
}
307332
matrixSection={null}
308333
inflowSection={
309334
<div>
@@ -334,6 +359,7 @@ export default async function DashboardPage({
334359
</div>
335360
}
336361
eligibilitySection={<DonationEligibilityLedger years={eligibilityYears} />}
362+
thisMonthSection={<CurrentMonthContributionsTable rows={currentMonthRows} />}
337363
membersSection={
338364
<div>
339365
<div className="mb-3 flex items-start justify-between gap-3">

src/components/ui/pr/data-table.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,67 @@ type PrDataTableProps<T extends Record<string, unknown>> = {
109109
paginatorRight?: ReactNode
110110
}
111111

112+
/**
113+
* Sort indicator: two stacked rounded chevrons, both shown faded by default,
114+
* with the active direction highlighted in the brand blue and slightly bolder
115+
* stroke. Replaces PrimeReact's default single-glyph sort icon (a double
116+
* up/down arrow that swaps to one arrow when active).
117+
*
118+
* Rendered via the DataTable `sortIcon` render prop — which hands us
119+
* `{ sortOrder, sorted }` — so we own the markup and style it directly with
120+
* Tailwind, sidestepping the unlayered primeicons font (its `::before` glyph
121+
* can't be overridden from the `components` cascade layer).
122+
*/
123+
function SortIcon({
124+
sortOrder,
125+
sorted,
126+
}: {
127+
sortOrder?: number | null
128+
sorted?: boolean
129+
}) {
130+
const asc = sorted && sortOrder === 1
131+
const desc = sorted && sortOrder === -1
132+
return (
133+
<span
134+
aria-hidden="true"
135+
className="p-sortable-column-icon ml-1.5 inline-flex flex-col items-center justify-center gap-[1px] leading-none transition-colors"
136+
>
137+
<svg
138+
width="9"
139+
height="6"
140+
viewBox="0 0 10 6"
141+
fill="none"
142+
stroke="currentColor"
143+
strokeWidth={asc ? 2.25 : 1.75}
144+
strokeLinecap="round"
145+
strokeLinejoin="round"
146+
className={
147+
'transition-all duration-150 ' +
148+
(asc ? 'text-blue-600' : 'text-gray-300')
149+
}
150+
>
151+
<path d="M1.5 4.75 5 1.25 8.5 4.75" />
152+
</svg>
153+
<svg
154+
width="9"
155+
height="6"
156+
viewBox="0 0 10 6"
157+
fill="none"
158+
stroke="currentColor"
159+
strokeWidth={desc ? 2.25 : 1.75}
160+
strokeLinecap="round"
161+
strokeLinejoin="round"
162+
className={
163+
'transition-all duration-150 ' +
164+
(desc ? 'text-blue-600' : 'text-gray-300')
165+
}
166+
>
167+
<path d="M1.5 1.25 5 4.75 8.5 1.25" />
168+
</svg>
169+
</span>
170+
)
171+
}
172+
112173
/** Pick a sensible default match mode when a column enables filtering. */
113174
function defaultMatchMode(col: PrColumn<unknown>): FilterMatchMode {
114175
if (col.filterMatchMode) return col.filterMatchMode
@@ -244,6 +305,10 @@ export function PrDataTable<T extends Record<string, unknown>>({
244305
currentPageReportTemplate="{first}–{last} of {totalRecords}"
245306
paginatorLeft={paginatorLeft}
246307
paginatorRight={paginatorRight}
308+
// DataTables.net-style stacked-triangle sort indicator (see SortIcon).
309+
sortIcon={(options) => (
310+
<SortIcon sortOrder={options.sortOrder} sorted={options.sorted} />
311+
)}
247312
// Third click on a sorted column clears the sort (asc → desc → none).
248313
removableSort
249314
// Compact density — Lara's default cell/header padding is large; `small`

0 commit comments

Comments
 (0)