579 lines
25 KiB
TypeScript
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>
|
|
)
|
|
}
|