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
180 changes: 175 additions & 5 deletions apps/web/src/routes/admin/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ function BillConfigForm({ initial }: { initial: BillConfig }) {
)
const setLine = (i: number, patch: Partial<LineDraft>) =>
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: () =>
Expand All @@ -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'] }),
})
Expand Down Expand Up @@ -329,6 +330,23 @@ function BillConfigForm({ initial }: { initial: BillConfig }) {
{t('page.billing.addLine')}
</Button>
</div>
<div className="space-y-2 border-t pt-4">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={interestEnabled} onChange={(e) => setInterestEnabled(e.target.checked)} />
{t('page.billing.interestEnabled')}
</label>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label>{t('page.billing.interestRate')}</Label>
<Input className="w-24" inputMode="numeric" value={interestRate} onChange={(e) => setInterestRate(e.target.value)} disabled={!interestEnabled} />
</div>
<div className="space-y-1.5">
<Label>{t('page.billing.graceDays')}</Label>
<Input className="w-24" inputMode="numeric" value={graceDays} onChange={(e) => setGraceDays(e.target.value)} disabled={!interestEnabled} />
</div>
</div>
<p className="text-muted-foreground text-xs">{t('page.billing.interestHint')}</p>
</div>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? t('common.saving') : t('page.billing.saveConfig')}
</Button>
Expand All @@ -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 (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t('page.billing.interestTitle')}</CardTitle>
<Button
size="sm"
onClick={() => apply.mutate()}
disabled={apply.isPending || !preview.data?.enabled || rows.length === 0}
>
{t('page.billing.applyInterest')}
</Button>
</CardHeader>
<CardContent>
{!preview.data?.enabled ? (
<p className="text-muted-foreground text-sm">{t('page.billing.interestDisabled')}</p>
) : rows.length === 0 ? (
<p className="text-muted-foreground text-sm">{t('page.billing.noInterest')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.apartment')}</TableHead>
<TableHead className="text-right">{t('page.billing.accruedInterest')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.apartmentId}>
<TableCell className="font-medium">{r.apartment}</TableCell>
<TableCell className="text-right">{formatPaise(r.interest)}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="font-semibold">{t('page.reports.total')}</TableCell>
<TableCell className="text-right font-semibold">{formatPaise(total)}</TableCell>
</TableRow>
</TableBody>
</Table>
)}
{apply.isSuccess && (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
{t('page.billing.interestApplied')} ({apply.data?.created ?? 0})
</p>
)}
</CardContent>
</Card>
)
}

// 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 (
<Card>
<CardHeader>
<CardTitle>{t('page.billing.statementTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label>{t('common.apartment')}</Label>
<Select value={apartmentId} onValueChange={setApartmentId}>
<SelectTrigger className="w-40">
<SelectValue placeholder={t('page.billing.selectFlat')} />
</SelectTrigger>
<SelectContent>
{apartments.data?.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.tower}-{a.apartmentNo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('page.billing.from')}</Label>
<Input className="w-36" type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>{t('page.billing.to')}</Label>
<Input className="w-36" type="date" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
</div>
{statement.data && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.date')}</TableHead>
<TableHead>{t('common.description')}</TableHead>
<TableHead className="text-right">{t('page.reports.debit')}</TableHead>
<TableHead className="text-right">{t('page.reports.credit')}</TableHead>
<TableHead className="text-right">{t('page.billing.balance')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground text-xs">
{t('page.reports.opening')}
</TableCell>
<TableCell className="text-right">{formatPaise(statement.data.opening)}</TableCell>
</TableRow>
{statement.data.entries.map((e, i) => (
<TableRow key={`${e.ref ?? ''}-${i}`}>
<TableCell>{e.date.slice(0, 10)}</TableCell>
<TableCell>{e.description}</TableCell>
<TableCell className="text-right">{e.debit ? formatPaise(e.debit) : ''}</TableCell>
<TableCell className="text-right">{e.credit ? formatPaise(e.credit) : ''}</TableCell>
<TableCell className="text-right">{formatPaise(e.balance)}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={4} className="font-semibold">
{t('page.reports.closing')}
</TableCell>
<TableCell className="text-right font-semibold">{formatPaise(statement.data.closing)}</TableCell>
</TableRow>
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}

function BillingPage() {
const { t } = useT()
return (
Expand All @@ -369,7 +537,9 @@ function BillingPage() {
<GenerateBillsForm />
</CardContent>
</Card>
<InterestCard />
<DuesCard />
<StatementCard />
<BillsCard />
</div>
)
Expand Down
32 changes: 32 additions & 0 deletions packages/shared/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const commonEn: Dict = {
'common.priority': 'Priority',
'common.title': 'Title',
'common.description': 'Description',
'common.date': 'Date',
}
const commonHi: Dict = {
'nav.houseHelp': 'घरेलू सहायक',
Expand All @@ -44,6 +45,7 @@ const commonHi: Dict = {
'common.priority': 'प्राथमिकता',
'common.title': 'शीर्षक',
'common.description': 'विवरण',
'common.date': 'दिनांक',
}

const webEn: Dict = {
Expand Down Expand Up @@ -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 ₹',
Expand Down Expand Up @@ -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': 'राशि ₹',
Expand Down
Loading