Files
alrahma_web_client/src/pages/reimbursements/ReimbursementsIndexPage.tsx
T
2026-06-07 00:52:04 -04:00

579 lines
25 KiB
TypeScript

import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
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 { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
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>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</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>
)
}