Files
alrahma_web_client/src/pages/payment/ExtraChargesPage.tsx
T
2026-06-07 00:52:04 -04:00

300 lines
12 KiB
TypeScript

import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchChargeInvoicesForParent,
fetchExtraChargesPage,
submitExtraCharge,
type ExtraChargeRow,
} from '../../api/paymentManagement'
function fmtMoney(n: unknown): string {
const v = Number(n)
return Number.isFinite(v) ? v.toFixed(2) : '0.00'
}
/** Extra charges admin — `GET|POST /api/v1/administrator/charges` (JWT). SPA: `/app/admin/charges`. */
export function ExtraChargesPage() {
const navigate = useNavigate()
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const [rows, setRows] = useState<ExtraChargeRow[]>([])
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [activeYear, setActiveYear] = useState(schoolYear)
const [activeSem, setActiveSem] = useState(semester)
const [parents, setParents] = useState<Array<{ id: number; firstname?: string; lastname?: string }>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([])
const [modalOpen, setModalOpen] = useState(false)
const [selectedParentId, setSelectedParentId] = useState<string>('')
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: activeYear || schoolYear,
})
useEffect(() => {
setActiveYear(schoolYear)
setActiveSem(semester)
}, [schoolYear, semester])
const loadCharges = useCallback(() => {
setLoading(true)
setError(null)
fetchExtraChargesPage({
school_year: schoolYear || undefined,
semester: semester || undefined,
})
.then((d) => {
setRows(d.rows ?? [])
setSchoolYears(d.schoolYears ?? [])
setParents((d.parents ?? []) as typeof parents)
if (d.schoolYear) setActiveYear(String(d.schoolYear))
if (d.semester) setActiveSem(String(d.semester))
})
.catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Could not load charges.'),
)
.finally(() => setLoading(false))
}, [schoolYear, semester])
useEffect(() => {
loadCharges()
}, [loadCharges])
useEffect(() => {
if (!selectedParentId) {
setInvoiceOptions([])
return
}
fetchChargeInvoicesForParent(selectedParentId)
.then(setInvoiceOptions)
.catch(() => setInvoiceOptions([]))
}, [selectedParentId])
function onYearChange(e: React.ChangeEvent<HTMLSelectElement>) {
const y = e.target.value
setActiveYear(y)
const next = new URLSearchParams(searchParams)
if (y) next.set('school_year', y)
else next.delete('school_year')
navigate({ pathname, search: next.toString() })
}
async function onModalSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
if (activeYear) fd.set('school_year', activeYear)
if (activeSem) fd.set('semester', activeSem)
setMsg(null)
try {
await submitExtraCharge(fd)
setMsg('Charge saved.')
setModalOpen(false)
loadCharges()
} catch (err: unknown) {
setMsg(err instanceof ApiHttpError ? err.message : 'Save failed.')
}
}
let pageTotal = 0
for (const r of rows) pageTotal += Number(r.amount ?? 0)
return (
<div className="container-fluid py-4">
{msg ? <div className="alert alert-info">{msg}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-center mb-3 flex-wrap gap-3 align-items-center">
<h3 className="mb-0">
Add &amp; Deduct Charges
<span className="text-muted ms-2">
({activeYear || '—'} {activeSem || '—'})
</span>
</h3>
<div className="d-flex align-items-center gap-2">
<label className="form-label mb-0" htmlFor="schoolYearSel">
School year
</label>
<select
id="schoolYearSel"
className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }}
value={activeYear || defaultYear}
onChange={onYearChange}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-success" onClick={() => setModalOpen(true)}>
Add Charge
</button>
</div>
{loading ? <p className="text-muted">Loading</p> : null}
<div className="card shadow-sm">
<div className="card-body table-responsive">
<table className="table table-bordered table-striped align-middle w-100">
<thead className="table-light">
<tr>
<th>ID</th>
<th>Type</th>
<th>Title</th>
<th className="text-end">Amount</th>
<th>Parent</th>
<th>Status</th>
<th>Invoice</th>
<th>Due</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={8} className="text-center text-muted py-4">
No charges
</td>
</tr>
) : (
rows.map((r) => {
const ctype = String(r.charge_type ?? '').toLowerCase()
const amt = Number(r.amount ?? 0)
const isAdd = ctype === 'add' || amt > 0
const isDed = ctype === 'deduct' || amt < 0
const badge = isAdd ? 'bg-success' : isDed ? 'bg-danger' : 'bg-secondary'
const label = isAdd ? 'Add' : isDed ? 'Deduct' : ctype || '—'
const st = String(r.status ?? '')
const cls =
st === 'pending' ? 'bg-info' : st === 'applied' ? 'bg-success' : st === 'void' ? 'bg-danger' : 'bg-light'
return (
<tr key={String(r.id)}>
<td>#{String(r.id)}</td>
<td>
<span className={`badge ${badge}`}>{label}</span>
</td>
<td>
<div className="fw-semibold">{String(r.title ?? '')}</div>
{r.description ? (
<div className="small text-muted">{String(r.description)}</div>
) : null}
</td>
<td className="text-end">${fmtMoney(r.amount)}</td>
<td>{String(r.parent_name ?? `ID: ${r.parent_id ?? ''}`)}</td>
<td>
<span className={`badge ${cls}`}>{st}</span>
</td>
<td>
{r.invoice_number
? String(r.invoice_number)
: r.invoice_id
? `#${r.invoice_id}`
: '—'}
</td>
<td>{r.due_date ? String(r.due_date) : '—'}</td>
</tr>
)
})
)}
</tbody>
<tfoot className="table-light">
<tr>
<td colSpan={3} className="text-end fw-semibold">
Total Amount
</td>
<td className="text-end fw-semibold">${pageTotal.toFixed(2)}</td>
<td colSpan={4} />
</tr>
</tfoot>
</table>
</div>
</div>
{modalOpen ? (
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,.45)' }}>
<div className="modal-dialog modal-lg modal-dialog-scrollable">
<form className="modal-content" onSubmit={onModalSubmit}>
<div className="modal-header">
<h5 className="modal-title">Adjust Charge</h5>
<button type="button" className="btn-close" onClick={() => setModalOpen(false)} />
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Parent *</label>
<select
name="parent_id"
className="form-select"
required
value={selectedParentId}
onChange={(e) => setSelectedParentId(e.target.value)}
>
<option value=""> Select Parent </option>
{parents.map((p) => (
<option key={p.id} value={String(p.id)}>
{`${p.lastname ?? ''}, ${p.firstname ?? ''}`.trim() || `ID ${p.id}`}
</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label">Invoice</label>
<select name="invoice_id" className="form-select">
<option value=""></option>
{invoiceOptions.map((inv) => (
<option key={String(inv.id)} value={String(inv.id ?? '')}>
{inv.invoice_number ?? `#${inv.id}`}
</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label">Title *</label>
<input name="title" type="text" className="form-control" required />
</div>
<div className="col-md-6">
<label className="form-label">Description *</label>
<input name="description" type="text" className="form-control" required />
</div>
<div className="col-md-3">
<label className="form-label">Amount *</label>
<input name="amount" type="number" step="0.01" className="form-control" required />
</div>
<div className="col-md-3">
<label className="form-label">Type *</label>
<select name="charge_type" className="form-select" required>
<option value="add">Add Charge</option>
<option value="deduct">Deduct Charge</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Due Date</label>
<input name="due_date" type="date" className="form-control" />
</div>
</div>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary">
Save
</button>
<button type="button" className="btn btn-secondary" onClick={() => setModalOpen(false)}>
Cancel
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}