fix teacher, parent and admin pages
This commit is contained in:
@@ -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 "Add New Batch".</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user