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
45 changes: 45 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import type {
BillConfig,
UpdateBillConfig,
FinanceReport,
TrialBalance,
IncomeExpenditure,
BalanceSheet,
ReceiptsPayments,
AgingSummary,
VisitorTrends,
HouseHelpAnalytics,
MaintenanceAnalytics,
Expand Down Expand Up @@ -88,6 +93,19 @@ export type CollectionAnalytics = {
fullyPaidPct: number
}

// Statutory statements (#98) + aging (#100) — server responses wrap the shared
// shapes with their date context.
export type TrialBalanceReport = TrialBalance & { toDate: string | null }
export type IncomeExpenditureReport = IncomeExpenditure & { fromDate: string | null; toDate: string | null }
export type BalanceSheetReport = BalanceSheet & { toDate: string | null }
export type ReceiptsPaymentsReport = ReceiptsPayments & { fromDate: string | null; toDate: string | null }
export type AgingReport = {
asOf: string
buckets: AgingSummary
total: number
byApartment: { apartmentId: string; apartment: string; buckets: AgingSummary; outstanding: number }[]
}

// A parking slot with its resolved assigned-flat label (admin inventory view).
export type ParkingSlotRow = {
id: string
Expand Down Expand Up @@ -334,6 +352,33 @@ export const apiClient = {
updateBillConfig: (body: UpdateBillConfig) => api<BillConfig>('/bill-config', { method: 'PUT', body: json(body) }),
getFinanceReport: () => api<FinanceReport>('/reports/finance'),
getCollectionAnalytics: () => api<CollectionAnalytics>('/reports/collection-analytics'),

// Statutory statements (#98) + aging (#100)
getTrialBalance: (toDate?: string) =>
api<TrialBalanceReport>(`/reports/trial-balance${toDate ? `?toDate=${toDate}` : ''}`),
getIncomeExpenditure: (fromDate?: string, toDate?: string) => {
const q = new URLSearchParams()
if (fromDate) q.set('fromDate', fromDate)
if (toDate) q.set('toDate', toDate)
const qs = q.toString()
return api<IncomeExpenditureReport>(`/reports/income-expenditure${qs ? `?${qs}` : ''}`)
},
getBalanceSheet: (toDate?: string) =>
api<BalanceSheetReport>(`/reports/balance-sheet${toDate ? `?toDate=${toDate}` : ''}`),
getReceiptsPayments: (fromDate?: string, toDate?: string) => {
const q = new URLSearchParams()
if (fromDate) q.set('fromDate', fromDate)
if (toDate) q.set('toDate', toDate)
const qs = q.toString()
return api<ReceiptsPaymentsReport>(`/reports/receipts-payments${qs ? `?${qs}` : ''}`)
},
getAging: (asOf?: string) => api<AgingReport>(`/reports/aging${asOf ? `?asOf=${asOf}` : ''}`),
// Auth-fetch a report export (xlsx/csv/pdf/tally) and return a local object URL.
reportExportObjectUrl: async (path: string): Promise<string> => {
const res = await fetch(`${API_URL}${path}`, { headers: await authHeaders() })
if (!res.ok) throw new Error(`export failed (${res.status})`)
return URL.createObjectURL(await res.blob())
},
getVisitorTrends: (from?: string, to?: string) => {
const q = new URLSearchParams()
if (from) q.set('from', from)
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/routes/admin/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Building2, Megaphone, UserCheck, Users } from 'lucide-react'
import { Building2, IndianRupee, Landmark, Megaphone, TrendingUp, UserCheck, Users } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { formatPaise, collectionRatePct } from '@opensociety/shared'

import { apiClient } from '../../lib/api'
import { PageHeader } from '@/components/admin/ui'
Expand Down Expand Up @@ -47,8 +48,12 @@ function Overview() {
const visitors = useQuery({ queryKey: ['visitors'], queryFn: () => apiClient.listVisitors() })
const notices = useQuery({ queryKey: ['notices'], queryFn: () => apiClient.listNotices() })
const pending = useQuery({ queryKey: ['users', 'PENDING'], queryFn: () => apiClient.listUsers('PENDING') })
const finance = useQuery({ queryKey: ['finance-report'], queryFn: apiClient.getFinanceReport })
const dues = useQuery({ queryKey: ['dues'], queryFn: apiClient.listDues })

const pendingVisitors = visitors.data?.filter((v) => v.status === 'PENDING').length ?? 0
const outstanding = dues.data ? dues.data.reduce((s, r) => s + r.outstanding, 0) : null
const collectionRate = finance.data ? collectionRatePct(finance.data.totalBilled, finance.data.totalCollected) : null

return (
<div>
Expand Down Expand Up @@ -81,6 +86,27 @@ function Overview() {
<StatCard icon={Megaphone} label={t('nav.notices')} value={notices.data?.length ?? '—'} to="/admin/notices" />
</div>

<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
icon={IndianRupee}
label={t('overview.outstanding')}
value={outstanding === null ? '—' : formatPaise(outstanding)}
to="/admin/reports"
/>
<StatCard
icon={TrendingUp}
label={t('overview.collectionRate')}
value={collectionRate === null ? '—' : `${collectionRate}%`}
to="/admin/reports"
/>
<StatCard
icon={Landmark}
label={t('overview.cashBank')}
value={finance.data ? formatPaise(finance.data.cashBankBalance) : '—'}
to="/admin/reports"
/>
</div>

<Card className="mt-6">
<CardHeader>
<CardTitle>{t('overview.gettingStarted')}</CardTitle>
Expand Down
237 changes: 237 additions & 0 deletions apps/web/src/routes/admin/reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,244 @@ import { formatPaise, collectionRatePct, collectionReportToCsv, towerCollectionT
import { apiClient } from '../../lib/api'
import { useT } from '@/lib/i18n'
import { PageHeader, QueryState } from '@/components/admin/ui'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'

// Auth-fetch a report export and open it (the browser downloads attachments).
function ExportButtons({ path, formats = ['pdf', 'xlsx', 'csv'] }: { path: string; formats?: string[] }) {
const labels: Record<string, string> = { pdf: 'PDF', xlsx: 'Excel', csv: 'CSV' }
const open = async (fmt: string) => {
const url = await apiClient.reportExportObjectUrl(`${path}${path.includes('?') ? '&' : '?'}format=${fmt}`)
window.open(url, '_blank')
}
return (
<div className="flex gap-2">
{formats.map((f) => (
<Button key={f} size="sm" variant="outline" onClick={() => open(f)}>
{labels[f] ?? f}
</Button>
))}
</div>
)
}

// Statutory financial statements (#98) with xlsx/csv/pdf export (#99).
function StatementsSection() {
const { t } = useT()
const tb = useQuery({ queryKey: ['trial-balance'], queryFn: () => apiClient.getTrialBalance() })
const ie = useQuery({ queryKey: ['income-expenditure'], queryFn: () => apiClient.getIncomeExpenditure() })
const bs = useQuery({ queryKey: ['balance-sheet'], queryFn: () => apiClient.getBalanceSheet() })
const rp = useQuery({ queryKey: ['receipts-payments'], queryFn: () => apiClient.getReceiptsPayments() })

return (
<>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t('page.reports.statements')}</h2>
<Button size="sm" variant="outline" onClick={async () => window.open(await apiClient.reportExportObjectUrl('/reports/tally-export'), '_blank')}>
{t('page.reports.exportTally')}
</Button>
</div>

<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>
{t('page.reports.balanceSheet')}{' '}
{bs.data && (
<span className={bs.data.balanced ? 'text-emerald-600 text-xs' : 'text-destructive text-xs'}>
· {bs.data.balanced ? t('page.reports.balanced') : t('page.reports.notBalanced')}
</span>
)}
</CardTitle>
<ExportButtons path="/reports/balance-sheet" />
</CardHeader>
<CardContent>
{bs.data ? (
<Table>
<TableBody>
<SectionRows label={t('page.reports.assets')} rows={bs.data.assets} />
<TotalRow label={t('page.reports.total')} amount={bs.data.totalAssets} />
<SectionRows label={t('page.reports.liabilities')} rows={bs.data.liabilities} />
<SectionRows label={t('page.reports.funds')} rows={bs.data.funds} />
<MoneyRow label={t('page.reports.surplus')} amount={bs.data.currentSurplus} />
<TotalRow label={t('page.reports.total')} amount={bs.data.totalLiabilitiesAndFunds} />
</TableBody>
</Table>
) : (
<p className="text-muted-foreground text-sm">{t('page.reports.setupNeeded')}</p>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t('page.reports.incomeExpenditure')}</CardTitle>
<ExportButtons path="/reports/income-expenditure" />
</CardHeader>
<CardContent>
{ie.data && (
<Table>
<TableBody>
<SectionRows label={t('page.reports.income')} rows={ie.data.income} />
<MoneyRow label={t('page.reports.mutualIncome')} amount={ie.data.mutualIncome} />
<MoneyRow label={t('page.reports.taxableIncome')} amount={ie.data.nonMutualIncome} />
<SectionRows label={t('page.reports.expenditure')} rows={ie.data.expense} />
<TotalRow label={t('page.reports.surplus')} amount={ie.data.surplus} />
</TableBody>
</Table>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t('page.reports.receiptsPayments')}</CardTitle>
<ExportButtons path="/reports/receipts-payments" />
</CardHeader>
<CardContent>
{rp.data && (
<Table>
<TableBody>
<MoneyRow label={t('page.reports.opening')} amount={rp.data.openingBalance} />
<SectionRows label={t('page.reports.receipts')} rows={rp.data.receipts} />
<SectionRows label={t('page.reports.payments')} rows={rp.data.payments} />
<TotalRow label={t('page.reports.closing')} amount={rp.data.closingBalance} />
</TableBody>
</Table>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>
{t('page.reports.trialBalance')}{' '}
{tb.data && (
<span className={tb.data.balanced ? 'text-emerald-600 text-xs' : 'text-destructive text-xs'}>
· {tb.data.balanced ? t('page.reports.balanced') : t('page.reports.notBalanced')}
</span>
)}
</CardTitle>
<ExportButtons path="/reports/trial-balance" />
</CardHeader>
<CardContent>
{tb.data && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('page.reports.account')}</TableHead>
<TableHead className="text-right">{t('page.reports.debit')}</TableHead>
<TableHead className="text-right">{t('page.reports.credit')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tb.data.rows.map((r) => (
<TableRow key={r.code}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="text-right">{r.debit ? formatPaise(r.debit) : ''}</TableCell>
<TableCell className="text-right">{r.credit ? formatPaise(r.credit) : ''}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="font-semibold">{t('page.reports.total')}</TableCell>
<TableCell className="text-right font-semibold">{formatPaise(tb.data.totalDebit)}</TableCell>
<TableCell className="text-right font-semibold">{formatPaise(tb.data.totalCredit)}</TableCell>
</TableRow>
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
)
}

function SectionRows({ label, rows }: { label: string; rows: { code: string; name: string; amount: number }[] }) {
return (
<>
<TableRow>
<TableCell className="text-muted-foreground text-xs font-semibold uppercase" colSpan={2}>
{label}
</TableCell>
</TableRow>
{rows.map((r) => (
<TableRow key={r.code}>
<TableCell className="pl-6">{r.name}</TableCell>
<TableCell className="text-right">{formatPaise(r.amount)}</TableCell>
</TableRow>
))}
</>
)
}

function MoneyRow({ label, amount }: { label: string; amount: number }) {
return (
<TableRow>
<TableCell>{label}</TableCell>
<TableCell className="text-right">{formatPaise(amount)}</TableCell>
</TableRow>
)
}

function TotalRow({ label, amount }: { label: string; amount: number }) {
return (
<TableRow>
<TableCell className="font-semibold">{label}</TableCell>
<TableCell className="text-right font-semibold">{formatPaise(amount)}</TableCell>
</TableRow>
)
}

// Aging of outstanding dues (#100) with per-flat breakdown.
function AgingSection() {
const { t } = useT()
const q = useQuery({ queryKey: ['aging'], queryFn: () => apiClient.getAging() })
const buckets = (['0-30', '31-60', '61-90', '90+'] as const)
if (!q.data) return null
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t('page.reports.agingTitle')}</CardTitle>
<ExportButtons path="/bills/dues" formats={['xlsx', 'csv']} />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{buckets.map((b) => (
<div key={b} className="space-y-1">
<p className="text-muted-foreground text-xs">{b} days</p>
<p className="text-xl font-semibold">{formatPaise(q.data.buckets[b])}</p>
</div>
))}
</div>
{q.data.byApartment.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('page.reports.flat')}</TableHead>
{buckets.map((b) => (
<TableHead key={b} className="text-right">{b}</TableHead>
))}
<TableHead className="text-right">{t('page.reports.outstanding')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{q.data.byApartment.map((r) => (
<TableRow key={r.apartmentId}>
<TableCell className="font-medium">{r.apartment}</TableCell>
{buckets.map((b) => (
<TableCell key={b} className="text-right">{r.buckets[b] ? formatPaise(r.buckets[b]) : ''}</TableCell>
))}
<TableCell className="text-right font-semibold">{formatPaise(r.outstanding)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}

export const Route = createFileRoute('/admin/reports')({ component: ReportsPage })

function Stat({ label, value }: { label: string; value: string }) {
Expand Down Expand Up @@ -216,6 +451,8 @@ function ReportsPage() {
</>
)}
</QueryState>
<StatementsSection />
<AgingSection />
<AnalyticsSection />
</div>
)
Expand Down
Loading
Loading