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