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,108 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { createReimbursement, fetchReimbursementCreateContext } from '../../api/reimbursements'
export function ReimbursementCreatePage() {
const [searchParams] = useSearchParams()
const expenseId = searchParams.get('expense_id') ?? ''
const [users, setUsers] = useState<Array<{ id?: number | string; firstname?: string; lastname?: string }>>([])
const [method, setMethod] = useState('')
const [msg, setMsg] = useState<string | null>(null)
useEffect(() => {
fetchReimbursementCreateContext({ expense_id: expenseId || undefined })
.then((d) => setUsers(d.users ?? []))
.catch(() => {})
}, [expenseId])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const fd = new FormData(form)
if (expenseId) fd.set('expense_id', expenseId)
setMsg(null)
try {
await createReimbursement(fd)
setMsg('Reimbursement saved.')
form.reset()
setMethod('')
} catch (err: unknown) {
setMsg(err instanceof ApiHttpError ? err.message : 'Submit failed.')
}
}
const isCheck = method === 'Check'
return (
<div className="container mt-4">
<h1>Add Reimbursement</h1>
{msg ? <div className="alert alert-info">{msg}</div> : null}
<form method="post" encType="multipart/form-data" onSubmit={onSubmit}>
<input type="hidden" name="expense_id" value={expenseId} />
<div className="mb-3">
<label className="form-label">Amount</label>
<input type="number" step={0.01} name="amount" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Reimbursed To</label>
<select name="reimbursed_to" className="form-control" required defaultValue="">
<option value="">-- Select Person --</option>
{users.map((u) => {
const id = String(u.id ?? '')
const label = `${u.firstname ?? ''} ${u.lastname ?? ''}`.trim() || 'Unknown'
return (
<option key={id} value={id}>
{label}
</option>
)
})}
</select>
</div>
<div className="mb-3">
<label className="form-label">Reimbursement Method</label>
<select
name="reimbursement_method"
id="reimbursement_method"
className="form-control"
required
value={method}
onChange={(e) => setMethod(e.target.value)}
>
<option value="">-- Select Method --</option>
<option value="Cash">Cash</option>
<option value="Check">Check</option>
</select>
</div>
<div className={`mb-3 ${isCheck ? '' : 'd-none'}`}>
<label className="form-label">Check Number</label>
<input type="text" name="check_number" className="form-control" />
</div>
<div className={`mb-3 ${isCheck ? '' : 'd-none'}`}>
<label className="form-label">Check Receipt Image</label>
<input type="file" name="receipt" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">Description</label>
<textarea name="description" className="form-control" rows={3} />
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
<Link to="/app/administrator/reimbursements" className="btn btn-secondary ms-2">
Cancel
</Link>
</form>
</div>
)
}
@@ -0,0 +1,151 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchReimbursementForEdit, updateReimbursement } from '../../api/reimbursements'
export function ReimbursementEditPage() {
const { reimbursementId } = useParams<{ reimbursementId: string }>()
const id = reimbursementId ?? ''
const [users, setUsers] = useState<Array<{ id?: number | string; firstname?: string; lastname?: string }>>([])
const [reimb, setReimb] = useState<Record<string, unknown> | null>(null)
const [receiptUrl, setReceiptUrl] = useState<string | null>(null)
const [method, setMethod] = useState('')
const [loading, setLoading] = useState(true)
const [msg, setMsg] = useState<string | null>(null)
useEffect(() => {
if (!id) return
setLoading(true)
fetchReimbursementForEdit(id)
.then((d) => {
setUsers(d.users ?? [])
setReimb(d.reimbursement ?? null)
setReceiptUrl(d.receipt_url ?? null)
setMethod(String(d.reimbursement?.reimbursement_method ?? ''))
})
.catch(() => setMsg('Could not load reimbursement.'))
.finally(() => setLoading(false))
}, [id])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
setMsg(null)
try {
await updateReimbursement(id, fd)
setMsg('Saved.')
} catch (err: unknown) {
setMsg(err instanceof ApiHttpError ? err.message : 'Update failed.')
}
}
if (!id) {
return <div className="container mt-4">Missing reimbursement id.</div>
}
if (loading || !reimb) {
return (
<div className="container mt-4">
<p className="text-muted">{loading ? 'Loading…' : 'Not found.'}</p>
</div>
)
}
const rid = reimb.id
const isCheck = method === 'Check'
const recipient = String(reimb.reimbursed_to ?? '')
const amount = reimb.amount
const checkNum = String(reimb.check_number ?? '')
const desc = String(reimb.description ?? '')
const hasReceiptPath = Boolean(reimb.receipt_path)
return (
<div className="container mt-4">
<h2>Edit Reimbursement #{String(rid ?? '')}</h2>
{msg ? <div className="alert alert-info">{msg}</div> : null}
<form method="post" encType="multipart/form-data" onSubmit={onSubmit}>
<div className="mb-3">
<label className="form-label">Amount</label>
<input
type="number"
step={0.01}
name="amount"
className="form-control"
required
defaultValue={typeof amount === 'number' ? amount : String(amount ?? '')}
/>
</div>
<div className="mb-3">
<label className="form-label">Reimbursed To</label>
<select name="reimbursed_to" className="form-control" required defaultValue={recipient}>
{users.map((u) => {
const uid = String(u.id ?? '')
const label = `${u.firstname ?? ''} ${u.lastname ?? ''}`.trim() || 'Unknown'
return (
<option key={uid} value={uid}>
{label}
</option>
)
})}
</select>
</div>
<div className="mb-3">
<label className="form-label">Reimbursement Method</label>
<select
name="reimbursement_method"
id="reimbursement_method"
className="form-control"
required
value={method}
onChange={(e) => setMethod(e.target.value)}
>
<option value="Cash">Cash</option>
<option value="Check">Check</option>
</select>
</div>
<div className="mb-3" style={{ display: isCheck ? '' : 'none' }}>
<label className="form-label">Check Number</label>
<input type="text" name="check_number" className="form-control" defaultValue={checkNum} />
</div>
<div className="mb-3">
<label className="form-label">Receipt (optional to replace)</label>
{receiptUrl ? (
<div className="mb-2">
<a href={receiptUrl} target="_blank" rel="noopener noreferrer">
Current receipt
</a>
</div>
) : null}
<input type="file" name="receipt" className="form-control" accept=".jpg,.jpeg,.png,.webp,.gif,.pdf" />
{hasReceiptPath ? (
<div className="form-check mt-2">
<input className="form-check-input" type="checkbox" name="remove_receipt" value="1" id="rmr" />
<label className="form-check-label" htmlFor="rmr">
Remove existing receipt
</label>
</div>
) : null}
</div>
<div className="mb-3">
<label className="form-label">Description</label>
<textarea name="description" className="form-control" rows={3} defaultValue={desc} />
</div>
<button type="submit" className="btn btn-primary">
Save
</button>
<Link to="/app/administrator/reimbursements" className="btn btn-secondary ms-2">
Cancel
</Link>
</form>
</div>
)
}
@@ -0,0 +1,573 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
downloadBatchCsv,
downloadReimbursementsExport,
fetchReimbursementsIndex,
postBatchEmail,
type BatchDetailPayload,
type BatchSummaryRow,
type ReimbursementsIndexResponse,
} from '../../api/reimbursements'
function money(n: unknown): string {
const x = typeof n === 'number' ? n : parseFloat(String(n ?? 0))
return Number.isFinite(x) ? x.toFixed(2) : '0.00'
}
type AttachmentEntry = {
receipts?: Array<Record<string, unknown>>
checks?: Array<Record<string, unknown>>
}
function groupItemsByPurchaser(items: Array<Record<string, unknown>>) {
const map: Record<string, { total: number; items: Array<Record<string, unknown>> }> = {}
for (const item of items) {
let p = String(item.purchased_by ?? '').trim()
if (!p) p = 'Unknown'
if (!map[p]) map[p] = { total: 0, items: [] }
const amt = parseFloat(String(item.amount ?? 0)) || 0
map[p].total += amt
map[p].items.push(item)
}
return Object.keys(map)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
.map((k) => ({ purchaser: k, ...map[k] }))
}
export function ReimbursementsIndexPage() {
const [searchParams, setSearchParams] = useSearchParams()
const semester = searchParams.get('semester') ?? ''
const status = searchParams.get('status') ?? ''
const schoolYear = searchParams.get('school_year') ?? ''
const userId = searchParams.get('user_id') ?? ''
const [data, setData] = useState<ReimbursementsIndexResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [openBatches, setOpenBatches] = useState<Record<string, boolean>>({})
const [emailOpen, setEmailOpen] = useState(false)
const [emailBatchId, setEmailBatchId] = useState<number | null>(null)
const [emailTitle, setEmailTitle] = useState('')
const [recipientEmail, setRecipientEmail] = useState('')
const [emailMessage, setEmailMessage] = useState('')
const [receiptIds, setReceiptIds] = useState<number[]>([])
const [checkIds, setCheckIds] = useState<number[]>([])
const [emailStatus, setEmailStatus] = useState<string | null>(null)
function load() {
setError(null)
fetchReimbursementsIndex({
semester: semester || undefined,
status: status || undefined,
school_year: schoolYear || undefined,
user_id: userId || undefined,
})
.then(setData)
.catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Failed to load reimbursements.'),
)
}
useEffect(() => {
load()
}, [semester, status, schoolYear, userId])
const batchAttachments = data?.batchAttachments ?? {}
function applyFilter(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const next = new URLSearchParams()
const s = String(fd.get('semester') ?? '')
const st = String(fd.get('status') ?? '')
const sy = String(fd.get('school_year') ?? '')
const uid = String(fd.get('user_id') ?? '')
if (s) next.set('semester', s)
if (st) next.set('status', st)
if (sy) next.set('school_year', sy)
if (uid) next.set('user_id', uid)
setSearchParams(next)
}
const schoolYears = data?.schoolYears ?? []
const users = data?.users ?? []
const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[]
const batchDetails = data?.batchDetails ?? {}
const donationBatch = data?.donationBatch ?? null
const donationDetails = (data?.donationDetails ?? { items: [], checks: [] }) as BatchDetailPayload
const attachmentMeta = useMemo(() => batchAttachments, [batchAttachments])
function openEmailModal(batchId: number, title: string) {
setEmailBatchId(batchId)
setEmailTitle(title ? `Email ${title}` : 'Email Batch')
setRecipientEmail('')
setEmailMessage('')
setEmailStatus(null)
const entry = attachmentMeta[String(batchId)] as AttachmentEntry | undefined
const receipts = entry?.receipts ?? []
const checks = entry?.checks ?? []
setReceiptIds(
receipts
.map((r) => Number((r as { expense_id?: unknown }).expense_id))
.filter((n) => n > 0),
)
setCheckIds(
checks.map((c) => Number((c as { file_id?: unknown }).file_id)).filter((n) => n > 0),
)
setEmailOpen(true)
}
async function submitEmail(e: FormEvent) {
e.preventDefault()
if (emailBatchId == null) return
setEmailStatus('Sending…')
const fd = new FormData()
fd.set('batch_id', String(emailBatchId))
fd.set('recipient_email', recipientEmail)
fd.set('message', emailMessage)
fd.set('receipts', JSON.stringify(receiptIds))
fd.set('checks', JSON.stringify(checkIds))
try {
await postBatchEmail(fd)
setEmailStatus('Sent.')
setEmailOpen(false)
load()
} catch (err: unknown) {
setEmailStatus(err instanceof ApiHttpError ? err.message : 'Send failed.')
}
}
function toggleReceipt(expenseId: number, checked: boolean) {
setReceiptIds((prev) => {
const s = new Set(prev)
if (checked) s.add(expenseId)
else s.delete(expenseId)
return [...s]
})
}
function toggleCheck(fileId: number, checked: boolean) {
setCheckIds((prev) => {
const s = new Set(prev)
if (checked) s.add(fileId)
else s.delete(fileId)
return [...s]
})
}
return (
<div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Reimbursed Expenses</h2>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={applyFilter} className="row g-3 mb-4">
<div className="col-md-2">
<label className="form-label">Semester</label>
<select name="semester" className="form-select" defaultValue={semester}>
<option value="">All</option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-md-2">
<label className="form-label">Status</label>
<select name="status" className="form-select" defaultValue={status}>
<option value="">All</option>
{['Pending', 'Approved', 'Rejected', 'Paid'].map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYear}>
<option value="">All</option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>
{sy}
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Reimbursed To</label>
<select name="user_id" className="form-select" defaultValue={userId}>
<option value="">All</option>
{users.map((u) => (
<option key={String(u.id)} value={String(u.id)}>
{`${u.firstname ?? ''} ${u.lastname ?? ''}`.trim()}
</option>
))}
</select>
</div>
<div className="col-md-3 d-flex gap-2 justify-content-end align-items-end mb-3">
<button className="btn btn-primary" type="submit">
Filter
</button>
<button
type="button"
className="btn btn-success"
onClick={() =>
downloadReimbursementsExport({
semester: semester || undefined,
status: status || undefined,
school_year: schoolYear || undefined,
user_id: userId || undefined,
}).catch(() => alert('Export failed.'))
}
>
Export CSV
</button>
<Link to="/app/administrator/reimbursements/under-processing" className="btn btn-warning">
Reimbursement Under Processing
</Link>
</div>
</form>
{!batchSummaries.length && !donationBatch ? (
<p className="text-muted">No batch data for the current filters.</p>
) : (
<div className="mb-4">
<div className="d-flex align-items-center gap-2 mb-2">
<h5 className="mb-0">Submitted Batches</h5>
</div>
<div className="row g-3">
{batchSummaries.map((batch) => {
const bid = Number(batch.batch_id ?? 0)
const title = String(batch.title ?? `Batch #${bid}`)
const details = (batchDetails[String(bid)] ?? {
items: [],
checks: [],
}) as BatchDetailPayload
const items = details.items ?? []
const checks = details.checks ?? []
const isOpen = openBatches[String(bid)] ?? false
const groups = groupItemsByPurchaser(items)
return (
<div key={bid} className="col-12">
<div className="card shadow-sm h-100">
<button
type="button"
className="card-body text-start w-100 border-0 bg-white p-3"
onClick={() =>
setOpenBatches((o) => ({ ...o, [String(bid)]: !o[String(bid)] }))
}
>
<div className="d-flex justify-content-between align-items-start">
<div>
<h6 className="card-title mb-1">{title}</h6>
<div className="text-muted small">
Items: {Number(batch.items ?? 0)} Amount: ${money(batch.amount)}
{batch.closed_at ? <> Closed: {String(batch.closed_at)}</> : null}
</div>
</div>
<span className="badge bg-light text-dark">{isOpen ? 'Collapse' : 'Expand'}</span>
</div>
</button>
{isOpen ? (
<div className="px-3 pb-3">
{groups.length > 0 ? (
<div className="mt-2">
<div className="fw-semibold small text-muted mb-2">Items</div>
{groups.map((g) => (
<div
key={g.purchaser}
className="p-3 mb-3 rounded border bg-light"
style={{ borderColor: '#e5e7eb' }}
>
<div className="d-flex justify-content-between border-bottom pb-2 mb-2">
<div className="fw-semibold">{g.purchaser}</div>
<div className="text-muted small">Total: ${money(g.total)}</div>
</div>
<ul className="list-group list-group-flush">
{g.items.map((item, idx) => (
<li
key={idx}
className="list-group-item px-0 d-flex justify-content-between bg-transparent"
>
<div>
<div className="fw-semibold">
{String(item.description || 'No description')}
</div>
<div className="text-muted small">
{String(item.vendor || 'Vendor N/A')}
{item.receipt_url ? (
<>
{' '}
{' '}
<a
href={String(item.receipt_url)}
target="_blank"
rel="noopener noreferrer"
>
Receipt
</a>
</>
) : null}
</div>
</div>
<div className="fw-semibold">${money(item.amount)}</div>
</li>
))}
</ul>
</div>
))}
</div>
) : null}
{checks.length > 0 ? (
<div className="mt-3">
<div className="fw-semibold small text-muted mb-2">Uploaded Checks</div>
<ul className="list-group list-group-flush">
{checks.map((file, idx) => (
<li
key={idx}
className="list-group-item px-0 d-flex justify-content-between align-items-center"
>
<div>
<div className="fw-semibold">{String(file.admin ?? 'Admin')}</div>
<div className="text-muted small">
{String(file.original ?? file.filename ?? '')}
</div>
</div>
{file.url ? (
<a
className="btn btn-sm btn-outline-primary"
href={String(file.url)}
target="_blank"
rel="noopener noreferrer"
>
View
</a>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
<div className="card-footer bg-light d-flex justify-content-between align-items-center flex-wrap gap-2">
<span className="text-muted small">ID: {bid}</span>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => openEmailModal(bid, title)}
>
Send To
</button>
<button
type="button"
className="btn btn-sm btn-outline-success"
onClick={() =>
downloadBatchCsv(bid).catch(() => alert('Download failed.'))
}
>
Download CSV
</button>
</div>
</div>
</div>
</div>
)
})}
{donationBatch ? (
<div className="col-12">
<div className="card shadow-sm h-100 border-secondary">
<button
type="button"
className="card-body text-start w-100 border-0 bg-white p-3"
onClick={() =>
setOpenBatches((o) => ({ ...o, donation: !o['donation'] }))
}
>
<div className="d-flex justify-content-between align-items-start">
<div>
<h6 className="card-title mb-1">
{String(donationBatch.title ?? 'Donations')}
</h6>
<div className="text-muted small">
Items: {Number(donationBatch.items ?? 0)} Amount: $
{money(donationBatch.amount)}
</div>
</div>
<span className="badge bg-light text-dark">
{openBatches['donation'] ? 'Collapse' : 'Expand'}
</span>
</div>
</button>
{openBatches['donation'] ? (
<div className="px-3 pb-3">
{groupItemsByPurchaser(donationDetails.items ?? []).map((g) => (
<div
key={g.purchaser}
className="p-3 mb-3 rounded border bg-light"
style={{ borderColor: '#e5e7eb' }}
>
<div className="d-flex justify-content-between border-bottom pb-2 mb-2">
<div className="fw-semibold">{g.purchaser}</div>
<div className="text-muted small">Total: ${money(g.total)}</div>
</div>
<ul className="list-group list-group-flush">
{g.items.map((item, idx) => (
<li
key={idx}
className="list-group-item px-0 d-flex justify-content-between bg-transparent"
>
<div>
<div className="fw-semibold">
{String(item.description || 'No description')}
</div>
<div className="text-muted small">
{String(item.vendor || 'Vendor N/A')}
</div>
</div>
<div className="fw-semibold">${money(item.amount)}</div>
</li>
))}
</ul>
</div>
))}
</div>
) : null}
<div className="card-footer bg-light d-flex justify-content-between align-items-center">
<span className="text-muted small">Category: Donation</span>
<span className="text-muted small">Not reimbursable</span>
</div>
</div>
</div>
) : null}
</div>
</div>
)}
<div className="alert alert-secondary mb-0">
Individual reimbursement table is hidden. Use the submitted batch cards above or export CSV for
details.
</div>
{emailOpen ? (
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,.35)' }}>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content">
<form onSubmit={submitEmail}>
<div className="modal-header">
<h5 className="modal-title">{emailTitle}</h5>
<button type="button" className="btn-close" onClick={() => setEmailOpen(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label" htmlFor="batchEmailRecipient">
Recipient Email
</label>
<input
type="email"
id="batchEmailRecipient"
className="form-control"
required
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
placeholder="recipient@example.com"
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="batchEmailMessage">
Message (optional)
</label>
<textarea
id="batchEmailMessage"
className="form-control"
rows={4}
value={emailMessage}
onChange={(e) => setEmailMessage(e.target.value)}
/>
</div>
<div className="mb-3">
<div className="d-flex justify-content-between align-items-center mb-2">
<span className="fw-semibold">Attachments</span>
<small className="text-muted">Uncheck to omit from the email.</small>
</div>
<div className="list-group list-group-flush">
{(attachmentMeta[String(emailBatchId ?? '')] as AttachmentEntry | undefined)?.receipts?.map(
(item, idx) => {
const id = Number(item.expense_id)
const vendor = String(item.vendor ?? 'Vendor N/A')
return (
<label
key={`r-${idx}`}
className="list-group-item d-flex align-items-start gap-2"
>
<input
type="checkbox"
className="form-check-input mt-1"
checked={receiptIds.includes(id)}
onChange={(e) => toggleReceipt(id, e.target.checked)}
/>
<span>
<strong>{vendor}</strong>
<span className="text-muted small d-block">
{String(item.description ?? '')}
</span>
</span>
</label>
)
},
)}
{(attachmentMeta[String(emailBatchId ?? '')] as AttachmentEntry | undefined)?.checks?.map(
(file, idx) => {
const id = Number(file.file_id)
return (
<label
key={`c-${idx}`}
className="list-group-item d-flex align-items-start gap-2"
>
<input
type="checkbox"
className="form-check-input mt-1"
checked={checkIds.includes(id)}
onChange={(e) => toggleCheck(id, e.target.checked)}
/>
<span>
<strong>{String(file.admin ?? 'Check')}</strong>
<span className="text-muted small d-block">
{String(file.original_filename ?? file.filename ?? '')}
</span>
</span>
</label>
)
},
)}
</div>
<p className="text-muted small mt-2 mb-0">
Receipts and check scans are packaged into zipped archives.
</p>
</div>
{emailStatus ? <div className="alert alert-info py-2">{emailStatus}</div> : null}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEmailOpen(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Send Email
</button>
</div>
</form>
</div>
</div>
</div>
) : null}
<style>{`
.wrap-headers thead th { white-space: normal !important; vertical-align: middle; }
`}</style>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,72 @@
.reimbursement-batch-page .wrap-headers thead th {
white-space: normal;
}
.reimb-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
align-items: start;
}
.reimb-col {
min-width: 0;
}
@media (min-width: 992px) {
.reimb-layout {
grid-template-columns: 1fr 2fr;
}
.reimb-table-scroll,
.reimb-batches-scroll {
max-height: calc(100vh - 240px);
overflow-y: auto;
padding-right: 0.25rem;
}
}
.reimb-draggable-table tr {
cursor: grab;
}
.reimb-draggable-table tr.dragging {
opacity: 0.5;
}
.reimb-draggable-table.drag-over {
outline: 2px dashed #0d6efd;
outline-offset: -4px;
}
.reimb-batches-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.batch-card .batch-header {
background-color: #f8f9fa;
}
.batch-dropzone {
border: 1px dashed #dee2e6;
border-radius: 0.65rem;
padding: 0.75rem;
min-height: 120px;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.batch-dropzone.drag-over {
border-color: #0d6efd;
background-color: #eef5ff;
}
.batch-item {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: grab;
}
.batch-item.dragging {
opacity: 0.6;
}
.batch-locked .batch-dropzone {
opacity: 0.75;
}
.receipt-viewer {
width: 100%;
min-height: 420px;
border: none;
}