510 lines
22 KiB
TypeScript
510 lines
22 KiB
TypeScript
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>
|
|
)
|
|
}
|