fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
@@ -0,0 +1,509 @@
import { type DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
createReimbursementBatch,
downloadBatchCsv,
downloadReimbursementsExport,
fetchUnderProcessingWorkspace,
postAdminCheckUpload,
postBatchLock,
postBatchUpdate,
postMarkDonation,
type UnderProcessingWorkspace,
} from '../../api/reimbursements'
import './underProcessing.css'
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
type DragPayload =
| { source: 'table'; itemId: number; reimbursementId?: number | null }
| { source: 'batch'; itemId: number; fromBatchId: number; fromAdminId: number; reimbursementId?: number | null }
export function ReimbursementsUnderProcessingPage() {
const [ws, setWs] = useState<UnderProcessingWorkspace | null>(null)
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [receiptModalUrl, setReceiptModalUrl] = useState<string | null>(null)
const dragPayload = useRef<DragPayload | null>(null)
const load = useCallback(() => {
setErr(null)
fetchUnderProcessingWorkspace()
.then(setWs)
.catch((e: unknown) =>
setErr(e instanceof ApiHttpError ? e.message : 'Failed to load workspace.'),
)
}, [])
useEffect(() => {
load()
}, [load])
async function persistToBatch(
expenseId: number,
batchId: number,
adminId: number | null,
reimbursementId?: number | null,
) {
const form = new FormData()
form.set('expense_id', String(expenseId))
form.set('batch_id', String(batchId))
form.set('batch_number', String(batchId))
if (reimbursementId) form.set('reimbursement_id', String(reimbursementId))
if (adminId != null && adminId !== 0) form.set('admin_id', String(adminId))
else form.set('admin_id', '')
await postBatchUpdate(form)
load()
}
async function removeFromBatch(expenseId: number, reimbursementId?: number | null) {
const form = new FormData()
form.set('expense_id', String(expenseId))
form.set('batch_id', '0')
form.set('batch_number', '0')
form.set('admin_id', '')
if (reimbursementId) form.set('reimbursement_id', String(reimbursementId))
await postBatchUpdate(form)
load()
}
async function onMarkDonation(expenseId: number) {
if (!confirm('Mark this expense as a donation? It will be removed from reimbursement batches.')) return
const fd = new FormData()
fd.set('expense_id', String(expenseId))
try {
await postMarkDonation(fd)
load()
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Failed.')
}
}
async function onAddBatch() {
setBusy(true)
try {
await createReimbursementBatch()
load()
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Could not create batch.')
} finally {
setBusy(false)
}
}
async function onLockBatch(batchId: number) {
const fd = new FormData()
fd.set('batch_id', String(batchId))
try {
await postBatchLock(fd)
load()
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Lock failed.')
}
}
function onCheckUpload(batchId: number, adminId: number, file: File | null) {
if (!file) return
const fd = new FormData()
fd.set('batch_id', String(batchId))
fd.set('admin_id', String(adminId))
fd.set('check_file', file)
postAdminCheckUpload(fd)
.then(() => load())
.catch((e: unknown) => alert(e instanceof ApiHttpError ? e.message : 'Upload failed.'))
}
const pendingItems = ws?.pendingItems ?? []
const batches = ws?.existingBatches ?? []
const admins = ws?.adminUsers ?? []
return (
<div className="container-fluid mt-4 reimbursement-batch-page">
{err ? <div className="alert alert-danger">{err}</div> : null}
<div className="row reimb-layout">
<div className="reimb-col reimb-col-list">
<div className="card shadow-sm h-100">
<div className="card-header d-flex flex-wrap gap-2 align-items-center">
<div>
<h5 className="mb-0">Reimbursements Under Processing</h5>
<small className="text-muted">Drag items into a batch zone on the right.</small>
</div>
<div className="ms-auto d-flex flex-wrap gap-2">
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={onAddBatch}>
Add New Batch
</button>
<Link to="/app/administrator/reimbursements" className="btn btn-sm btn-outline-secondary">
Reimbursement Main
</Link>
<button
type="button"
className="btn btn-sm btn-outline-success"
onClick={() =>
downloadReimbursementsExport({ type: 'under_processing' }).catch(() =>
alert('Export failed.'),
)
}
>
Export CSV
</button>
</div>
</div>
<div className="card-body">
<div className="reimb-table-scroll">
<div className="table-responsive">
<table className="table table-bordered align-middle w-100 wrap-headers mb-0">
<thead className="table-light">
<tr>
<th>Expense Amount</th>
<th>Vendor</th>
<th>Description</th>
<th>Purchased By</th>
<th>Expense Receipt</th>
<th>Actions</th>
</tr>
</thead>
<tbody
className="reimb-draggable-table"
onDragOver={(e) => {
if (dragPayload.current?.source === 'batch') {
e.preventDefault()
e.currentTarget.classList.add('drag-over')
}
}}
onDragLeave={(e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('drag-over')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('drag-over')
const p = dragPayload.current
dragPayload.current = null
if (!p || p.source !== 'batch') return
void removeFromBatch(p.itemId, p.reimbursementId).catch((ex: unknown) =>
alert(ex instanceof ApiHttpError ? ex.message : 'Update failed.'),
)
}}
>
{pendingItems.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-muted py-4">
All reimbursements are currently assigned to a batch.
</td>
</tr>
) : (
pendingItems.map((item) => {
const expenseId = Number(item.expense_id ?? item.id)
const amt = Number(item.expense_amount ?? item.amount ?? 0)
const reimbId = item.reimbursement_id
? Number(item.reimbursement_id)
: null
const ru = item.receipt_url ? String(item.receipt_url) : ''
return (
<tr
key={expenseId}
draggable
onDragStart={(ev) => {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.setData('text/plain', String(expenseId))
dragPayload.current = {
source: 'table',
itemId: expenseId,
reimbursementId: reimbId,
}
;(ev.currentTarget as HTMLElement).classList.add('dragging')
}}
onDragEnd={(ev) => {
;(ev.currentTarget as HTMLElement).classList.remove('dragging')
}}
>
<td className="fw-semibold">{currency.format(amt)}</td>
<td>{String(item.vendor || 'N/A')}</td>
<td>{String(item.description || '-')}</td>
<td>{String(item.purchased_by || 'Unknown')}</td>
<td>
{ru ? (
<button
type="button"
className="btn btn-link btn-sm p-0"
onClick={() => setReceiptModalUrl(ru)}
>
View Receipt
</button>
) : (
<span className="text-muted">N/A</span>
)}
</td>
<td>
<button
type="button"
className="btn btn-sm btn-outline-success"
draggable={false}
onClick={() => onMarkDonation(expenseId)}
>
Mark as Donation
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div className="reimb-col reimb-col-batches">
<div className="card shadow-sm h-100">
<div className="card-header">
<h5 className="mb-0">Active Batches</h5>
<small className="text-muted">Each admin slot accepts drops from the table.</small>
</div>
<div className="card-body reimb-batches-scroll">
{batches.length === 0 ? (
<div className="alert alert-info py-2 px-3">No batches yet. Click &quot;Add New Batch&quot;.</div>
) : (
<div className="reimb-batches-grid">
{batches.map((batch) => {
const batchId = Number(batch.batchNumber ?? batch.batch_id ?? batch.batchId ?? 0)
const statusRaw = String(batch.status ?? '').toLowerCase().trim()
const locked = statusRaw !== '' && statusRaw !== 'open'
const slots = (Array.isArray(batch.slots) ? batch.slots : []) as Array<
Record<string, unknown>
>
const sequence = Number(batch.sequence ?? batch.yearly_batch_number ?? batchId)
return (
<div key={batchId} className={`card batch-card ${locked ? 'batch-locked' : ''}`}>
<div className="card-header batch-header d-flex flex-wrap justify-content-between gap-2">
<div>
<strong>{String(batch.label ?? `Batch #${sequence || batchId}`)}</strong>
<div className="small text-muted">ID {batchId}</div>
</div>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-sm btn-outline-success"
onClick={() =>
downloadBatchCsv(batchId).catch(() => alert('Download failed.'))
}
>
Download CSV
</button>
{!locked ? (
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => void onLockBatch(batchId)}
>
Submit Batch
</button>
) : null}
</div>
</div>
<div className="card-body">
{slots.length === 0 ? (
<BatchDropZone
locked={locked}
onDrop={(e) => {
e.preventDefault()
const p = dragPayload.current
dragPayload.current = null
if (!p || p.source !== 'table') return
void persistToBatch(
p.itemId,
batchId,
0,
p.reimbursementId,
).catch((ex: unknown) =>
alert(ex instanceof ApiHttpError ? ex.message : 'Update failed.'),
)
}}
/>
) : (
slots.map((slot, idx) => {
const adminId = Number(slot.admin_id ?? slot.adminId ?? 0)
const label = String(
slot.admin_name ?? slot.adminName ?? `Admin #${adminId}`,
)
const itemIds = (Array.isArray(slot.items) ? slot.items : []) as number[]
const details = (slot.items_detail as Array<Record<string, unknown>> | undefined) ?? []
return (
<div key={`${batchId}-${idx}`} className="mb-3">
<div className="small fw-semibold mb-1">{label}</div>
<BatchDropZone
locked={locked}
onDrop={(e) => {
e.preventDefault()
const p = dragPayload.current
dragPayload.current = null
if (!p) return
const reimb =
p.source === 'table'
? p.reimbursementId
: p.reimbursementId
if (p.source === 'table') {
void persistToBatch(
p.itemId,
batchId,
adminId,
reimb,
).catch((ex: unknown) =>
alert(
ex instanceof ApiHttpError ? ex.message : 'Update failed.',
),
)
} else {
void persistToBatch(
p.itemId,
batchId,
adminId,
reimb,
).catch((ex: unknown) =>
alert(
ex instanceof ApiHttpError ? ex.message : 'Update failed.',
),
)
}
}}
/>
<div className="d-flex flex-column gap-2 mt-2 ps-1">
{itemIds.map((eid) => {
const row =
details.find((x) => Number(x.expense_id ?? x.id) === eid) ?? {}
const ru = row.receipt_url ? String(row.receipt_url) : ''
const rmb = row.reimbursement_id
? Number(row.reimbursement_id)
: null
return (
<div
key={eid}
className="batch-item"
draggable={!locked}
onDragStart={() => {
dragPayload.current = {
source: 'batch',
itemId: eid,
fromBatchId: batchId,
fromAdminId: adminId,
reimbursementId: rmb,
}
}}
onDragEnd={() => {
/* clear only after drop */
}}
>
<div className="d-flex justify-content-between gap-2">
<div>
<div className="fw-semibold">
{String(row.description ?? 'Item')}
</div>
<div className="small text-muted">
{String(row.vendor ?? '')}
</div>
</div>
<div className="text-end">
<div>
{currency.format(
Number(row.amount ?? row.expense_amount ?? 0),
)}
</div>
{ru ? (
<button
type="button"
className="btn btn-link btn-sm p-0"
onClick={() => setReceiptModalUrl(ru)}
>
Receipt
</button>
) : null}
</div>
</div>
</div>
)
})}
</div>
{!locked ? (
<div className="mt-2">
<label className="form-label form-label-sm">Check file</label>
<input
type="file"
className="form-control form-control-sm"
accept=".pdf,image/*"
onChange={(e) =>
onCheckUpload(batchId, adminId, e.target.files?.[0] ?? null)
}
/>
</div>
) : null}
</div>
)
})
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
<div className="small text-muted mt-2">
Admins:{' '}
{admins.length
? admins.map((a) => `${a.firstname ?? ''} ${a.lastname ?? ''}`.trim()).join(', ')
: '—'}
</div>
{receiptModalUrl ? (
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,.35)' }}>
<div className="modal-dialog modal-xl modal-dialog-scrollable">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Expense Receipt</h5>
<button
type="button"
className="btn-close"
onClick={() => setReceiptModalUrl(null)}
/>
</div>
<div className="modal-body">
<iframe className="receipt-viewer" src={receiptModalUrl} title="Receipt" />
</div>
</div>
</div>
</div>
) : null}
</div>
)
}
function BatchDropZone(props: {
locked: boolean
onDrop: (e: DragEvent<HTMLDivElement>) => void
}) {
const [over, setOver] = useState(false)
if (props.locked) return null
return (
<div
className={`batch-dropzone ${over ? 'drag-over' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setOver(true)
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
setOver(false)
props.onDrop(e)
}}
>
<span className="text-muted small">Drop reimbursement row here</span>
</div>
)
}