300 lines
12 KiB
TypeScript
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 & 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>
|
|
)
|
|
}
|