179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import { ApiHttpError } from '../../api/http'
|
|
import { fetchDiscountApplyContext, reverseDiscountApplication } from '../../api/session'
|
|
import type { DiscountApplyParentRow, DiscountVoucherRow } from '../../api/types'
|
|
import { AcademicFilterBar } from './AcademicFilterBar'
|
|
|
|
/**
|
|
* Parity for a legacy `reverse_discount` admin view (not present in the CI tree we inspected).
|
|
* Uses the same parent/voucher context as “apply voucher”; backend should filter rows as needed.
|
|
*/
|
|
export function ReverseDiscountPage() {
|
|
const [searchParams] = useSearchParams()
|
|
const schoolYear = searchParams.get('school_year') ?? undefined
|
|
const semester = searchParams.get('semester') ?? undefined
|
|
|
|
const [vouchers, setVouchers] = useState<DiscountVoucherRow[]>([])
|
|
const [parents, setParents] = useState<DiscountApplyParentRow[]>([])
|
|
const [voucherId, setVoucherId] = useState('')
|
|
const [reason, setReason] = useState('')
|
|
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [msg, setMsg] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
fetchDiscountApplyContext({ school_year: schoolYear, semester })
|
|
.then((d) => {
|
|
if (cancelled) return
|
|
setVouchers(d.vouchers ?? [])
|
|
setParents(d.parents ?? [])
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!cancelled)
|
|
setError(e instanceof ApiHttpError ? e.message : 'Could not load data.')
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [schoolYear, semester])
|
|
|
|
function toggle(id: string) {
|
|
setSelected((s) => ({ ...s, [id]: !s[id] }))
|
|
}
|
|
|
|
async function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
const ids = Object.entries(selected)
|
|
.filter(([, on]) => on)
|
|
.map(([id]) => id)
|
|
if (ids.length === 0) {
|
|
setMsg('Select at least one parent.')
|
|
return
|
|
}
|
|
setBusy(true)
|
|
setMsg(null)
|
|
try {
|
|
await reverseDiscountApplication({
|
|
voucher_id: voucherId || undefined,
|
|
parent_ids: ids,
|
|
reason: reason.trim() || undefined,
|
|
school_year: schoolYear,
|
|
semester,
|
|
})
|
|
setMsg('Reverse request submitted.')
|
|
} catch (err: unknown) {
|
|
setMsg(err instanceof ApiHttpError ? err.message : 'Request failed.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<h1 className="h2">Reverse Discount</h1>
|
|
<p className="text-muted">
|
|
Remove or roll back a voucher application for selected parents. The API route is{' '}
|
|
<code>/api/v1/discounts/reverse</code>.
|
|
</p>
|
|
|
|
<AcademicFilterBar />
|
|
|
|
{loading ? <p className="text-muted">Loading…</p> : null}
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
{msg ? (
|
|
<div
|
|
className={`alert ${msg.includes('fail') || msg.includes('Select') ? 'alert-warning' : 'alert-success'}`}
|
|
>
|
|
{msg}
|
|
</div>
|
|
) : null}
|
|
|
|
<form onSubmit={onSubmit}>
|
|
<div className="mb-3">
|
|
<label className="form-label" htmlFor="reverse_voucher">
|
|
Voucher (optional filter)
|
|
</label>
|
|
<select
|
|
id="reverse_voucher"
|
|
className="form-select"
|
|
value={voucherId}
|
|
onChange={(ev) => setVoucherId(ev.target.value)}
|
|
>
|
|
<option value="">— Any / not specified —</option>
|
|
{vouchers.map((v) => (
|
|
<option key={String(v.id)} value={String(v.id)}>
|
|
{v.code}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<label className="form-label" htmlFor="reverse_reason">
|
|
Reason (optional)
|
|
</label>
|
|
<textarea
|
|
id="reverse_reason"
|
|
className="form-control"
|
|
rows={2}
|
|
value={reason}
|
|
onChange={(ev) => setReason(ev.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="d-flex gap-2 mb-3 justify-content-end">
|
|
<button type="submit" className="btn btn-warning" disabled={busy || loading}>
|
|
Reverse discount
|
|
</button>
|
|
<Link to="/app/administrator/discounts" className="btn btn-outline-secondary">
|
|
Back to list
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered">
|
|
<thead>
|
|
<tr>
|
|
<th>Select</th>
|
|
<th>School ID</th>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Has discount?</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{parents.map((parent) => {
|
|
const id = String(parent.id)
|
|
return (
|
|
<tr key={id}>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={Boolean(selected[id])}
|
|
onChange={() => toggle(id)}
|
|
/>
|
|
</td>
|
|
<td>{parent.school_id ?? 'N/A'}</td>
|
|
<td>{[parent.firstname, parent.lastname].filter(Boolean).join(' ') || '—'}</td>
|
|
<td>{parent.email ?? '—'}</td>
|
|
<td>{parent.has_discount ? 'Yes' : 'No'}</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|