Files
alrahma_sunday_school/app/Views/reimbursements/under_processing.php
T
2026-06-04 23:09:58 -04:00

1452 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid mt-4 reimbursement-batch-page">
<div class="row reimb-layout">
<div class="reimb-col reimb-col-list">
<div class="card shadow-sm h-100">
<div class="card-header d-flex flex-wrap gap-2 align-items-center">
<div>
<h5 class="mb-0">Reimbursements Under Processing</h5>
<small class="text-muted">Drag items into batches to keep work moving.</small>
</div>
<div class="ms-auto d-flex flex-wrap gap-2">
<button type="button" id="addBatchBtn" class="btn btn-primary btn-sm">
<span class="me-1"></span>Add New Batch
</button>
<a href="<?= base_url('reimbursements') ?>" class="btn btn-sm btn-outline-secondary">Reimbursement Main</a>
<a href="<?= base_url('reimbursements/export?type=under_processing') ?>" class="btn btn-sm btn-outline-success">Export CSV</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Drag rows into a batch to assign them to admins. Drag cards back into the table to remove them from a batch.
</p>
<div class="reimb-table-scroll">
<div class="table-responsive">
<table class="table table-bordered align-middle w-100 wrap-headers mb-0 no-mgmt-sticky">
<thead class="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 id="pending-list" class="align-middle reimb-draggable-table">
<?php if (empty($pendingItems)): ?>
<tr class="pending-empty">
<td colspan="6" class="text-center text-muted py-4">
All reimbursements are currently assigned to a batch.
</td>
</tr>
<?php else: ?>
<?php foreach ($pendingItems as $item): ?>
<tr
data-item-id="<?= (int) $item['expense_id'] ?>"
data-reimb-id="<?= $item['reimbursement_id'] ? (int) $item['reimbursement_id'] : '' ?>"
draggable="true"
>
<td class="fw-semibold">$<?= number_format((float) $item['expense_amount'], 2) ?></td>
<td><?= esc($item['vendor'] ?: 'N/A') ?></td>
<td><?= esc($item['description'] ?: '-') ?></td>
<td><?= esc($item['purchased_by'] ?: 'Unknown') ?></td>
<td>
<?php if (!empty($item['receipt_url'])): ?>
<a href="<?= esc($item['receipt_url']) ?>" target="_blank" rel="noopener">View Receipt</a>
<?php else: ?>
<span class="text-muted">N/A</span>
<?php endif; ?>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-outline-success mark-donation-btn"
data-item-id="<?= (int) $item['expense_id'] ?>"
draggable="false"
>
Mark as Donation
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="text-muted small mt-2">Tip: Hold and drag a row to assign it to a batch. Drag it back into the table to unassign.</div>
</div>
</div>
</div>
</div>
<div class="reimb-col reimb-col-batches">
<div class="card shadow-sm h-100">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<div>
<h5 class="mb-0">Active Batches</h5>
<small class="text-muted">Pick admins for the batch, then drag reimbursements under each admin.</small>
</div>
</div>
<div class="card-body">
<div class="reimb-batches-scroll">
<div id="batch-empty-state" class="alert alert-info py-2 px-3 <?= !empty($existingBatches) ? 'd-none' : '' ?>">
No batches yet. Click "Add New Batch" to start organizing reimbursements.
</div>
<div id="batch-board" class="reimb-batches-grid"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="receiptModal" tabindex="-1" aria-labelledby="receiptModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="receiptModalLabel">Expense Receipt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="receipt-viewer-wrapper">
<iframe id="receiptViewer" class="receipt-viewer" src="" title="Receipt preview" allowfullscreen></iframe>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('styles') ?>
<style>
.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;
}
.reimb-col-list,
.reimb-col-batches {
width: 100%;
}
.reimb-table-scroll,
.reimb-batches-scroll {
max-height: none;
}
@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;
}
}
@media (min-width: 992px) {
.reimb-layout {
grid-template-columns: 1fr 2fr;
}
}
.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 .card-header {
background-color: #f8f9fa;
}
.batch-card .batch-dropzones {
display: flex;
flex-direction: column;
gap: 1rem;
}
.batch-card .batch-dropzone {
border: 1px dashed #dee2e6;
border-radius: 0.65rem;
padding: 0.75rem;
min-height: 140px;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.batch-card .batch-dropzone .dropzone-title {
gap: 1rem;
}
.batch-card .batch-dropzone .dropzone-title .admin-subtotal {
font-size: 0.75rem;
color: #0d6efd;
font-weight: 600;
}
.batch-card .batch-dropzone.drag-over {
border-color: #0d6efd;
background-color: #eef5ff;
}
.batch-card .dropzone-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.batch-card .dropzone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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;
cursor: grabbing;
}
.batch-item .item-meta {
font-size: 0.8rem;
color: #6c757d;
}
.batch-card .card-footer {
background-color: #f8f9fa;
}
.batch-card.batch-locked {
border-color: #d1d1d1;
}
.batch-card.batch-locked .batch-dropzone {
opacity: 0.75;
}
.batch-card.batch-locked .batch-admin-picker {
display: none;
}
.batch-card .lock-batch-btn {
border-width: 1px;
}
.batch-card.batch-locked .lock-batch-btn {
pointer-events: none;
}
.batch-card .admin-check-wrapper {
border-top: 1px solid #dee2e6;
margin-top: 0.5rem;
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
.batch-card .admin-check-link {
text-decoration: none;
color: #0d6efd;
}
.batch-card.batch-locked .admin-check-wrapper {
pointer-events: none;
opacity: 0.6;
}
.batch-card .admin-check-wrapper input,
.batch-card .admin-check-wrapper .admin-check-link {
pointer-events: auto;
}
.batch-card .toggle-card-btn {
color: #0d6efd;
border-color: #d5def0;
background-color: #f6f9ff;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.batch-card .toggle-card-btn .toggle-icon {
transition: transform 0.15s ease;
display: inline-block;
}
.batch-card.batch-collapsed .toggle-card-btn .toggle-icon {
transform: rotate(-90deg);
}
.batch-card.batch-collapsed .card-body,
.batch-card.batch-collapsed .card-footer {
display: none;
}
.reimbursement-batch-page .receipt-link {
text-decoration: underline;
}
.receipt-viewer-wrapper {
border-radius: 0.5rem;
overflow: hidden;
background-color: #fff;
}
.receipt-viewer {
width: 100%;
min-height: 420px;
border: none;
}
</style>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(() => {
const cfg = {
items: <?= json_encode(array_values($itemsPayload), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>,
batches: <?= json_encode($existingBatches, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>,
admins: <?= json_encode(array_values($adminUsers), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>,
createBatchUrl: <?= json_encode(base_url('reimbursements/batch/create')) ?>,
lockBatchUrl: <?= json_encode(base_url('reimbursements/batch/lock')) ?>,
checkUploadUrl: <?= json_encode(base_url('reimbursements/batch/admin-file/upload')) ?>,
updateUrl: <?= json_encode(base_url('reimbursements/batch/update')) ?>,
batchExportUrl: <?= json_encode(base_url('reimbursements/batch/export')) ?>,
markDonationUrl: <?= json_encode(base_url('reimbursements/mark-donation')) ?>,
csrfToken: <?= json_encode(csrf_token()) ?>,
csrfHash: <?= json_encode(csrf_hash()) ?>,
};
const tableBody = document.getElementById('pending-list');
const batchBoard = document.getElementById('batch-board');
if (!tableBody || !batchBoard) {
return;
}
const addBatchBtn = document.getElementById('addBatchBtn');
const pageContainer = document.querySelector('.reimbursement-batch-page');
const emptyState = document.getElementById('batch-empty-state');
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const receiptModalEl = document.getElementById('receiptModal');
const receiptViewer = receiptModalEl?.querySelector('#receiptViewer');
const bootstrap = window.bootstrap;
const receiptModalInstance = receiptModalEl && bootstrap && typeof bootstrap.Modal === 'function'
? new bootstrap.Modal(receiptModalEl)
: null;
if (receiptModalEl && receiptModalInstance) {
receiptModalEl.addEventListener('hidden.bs.modal', () => {
if (receiptViewer) {
receiptViewer.src = '';
}
});
}
const itemMap = new Map();
(cfg.items || []).forEach((raw) => {
if (!raw || !raw.id) {
return;
}
const rawBatchId = Number(raw.batch_id ?? raw.batchId ?? raw.batchNumber ?? raw.batch_number) || 0;
itemMap.set(Number(raw.id), {
id: Number(raw.id),
expense_amount: Number(raw.expense_amount) || 0,
vendor: raw.vendor || 'N/A',
description: raw.description || '',
purchased_by: raw.purchased_by || 'Unknown',
receipt_url: raw.receipt_url || '',
batch_number: rawBatchId,
batchId: rawBatchId,
reimbursement_id: raw.reimbursement_id ? Number(raw.reimbursement_id) : null,
});
});
const admins = (cfg.admins || []).map((admin) => ({
id: Number(admin.id),
name: `${(admin.firstname || '').trim()} ${(admin.lastname || '').trim()}`.trim() || 'Admin',
}));
let tempBatchCounter = -1;
let csrfValue = cfg.csrfHash || '';
const csrfCookieName = <?= json_encode(config('Security')->csrfCookieName ?? (config('Security')->cookieName ?? 'csrf_cookie_name')) ?>;
let csrfRequestQueue = Promise.resolve();
let dragPayload = null;
function getCookie(name) {
const parts = document.cookie.split(';');
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i].trim();
if (part.startsWith(`${name}=`)) {
return decodeURIComponent(part.slice(name.length + 1));
}
}
return '';
}
function syncCsrfFromCookie() {
if (!csrfCookieName) {
return csrfValue;
}
const cookieValue = getCookie(csrfCookieName);
if (cookieValue) {
csrfValue = cookieValue;
}
return csrfValue;
}
function updateCsrfToken(response, data = null) {
const headerValue = response?.headers?.get?.('X-CSRF-HASH') || '';
if (headerValue) {
csrfValue = headerValue;
return;
}
const bodyValue = data && typeof data === 'object' ? (data.csrf_hash || data.csrfHash || '') : '';
if (bodyValue) {
csrfValue = bodyValue;
return;
}
syncCsrfFromCookie();
}
function withSerializedCsrfRequest(task) {
const run = csrfRequestQueue
.catch(() => undefined)
.then(() => task());
csrfRequestQueue = run.then(() => undefined, () => undefined);
return run;
}
async function postJson(url, form, fallbackError) {
return withSerializedCsrfRequest(async () => {
syncCsrfFromCookie();
if (cfg.csrfToken) {
form.set(cfg.csrfToken, csrfValue);
}
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
if (csrfValue) {
headers['X-CSRF-TOKEN'] = csrfValue;
}
const response = await fetch(url, {
method: 'POST',
headers,
credentials: 'same-origin',
body: form,
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : {};
} catch (_) {
data = {
success: false,
error: text || fallbackError || 'Unexpected response',
};
}
updateCsrfToken(response, data);
if (!response.ok || !data.success) {
throw new Error(data.error || fallbackError || 'Request failed.');
}
return data;
});
}
function openReceiptModal(url) {
if (!url) {
return;
}
if (receiptModalInstance && receiptViewer) {
receiptViewer.src = url;
receiptModalInstance.show();
return;
}
window.open(url, '_blank', 'noopener');
}
function handleReceiptLinkClick(event) {
const trigger = event.target.closest('[data-receipt-url]');
if (!trigger) {
return;
}
const url = trigger.dataset.receiptUrl;
if (!url) {
return;
}
event.preventDefault();
openReceiptModal(url);
}
function handleDonationClick(event) {
const trigger = event.target.closest('.mark-donation-btn');
if (!trigger) {
return;
}
event.preventDefault();
event.stopPropagation();
const itemId = Number(trigger.dataset.itemId || 0);
if (!itemId) {
return;
}
markDonation(itemId);
}
function toggleBatchEmptyState() {
if (!emptyState) {
return;
}
const hasBatch = !!batchBoard.querySelector('.batch-card');
emptyState.classList.toggle('d-none', hasBatch);
}
function refreshPendingEmptyState() {
const hasRow = !!tableBody.querySelector('tr[data-item-id]');
const emptyRow = tableBody.querySelector('.pending-empty');
if (!hasRow && !emptyRow) {
const tr = document.createElement('tr');
tr.className = 'pending-empty';
const td = document.createElement('td');
td.colSpan = 6;
td.className = 'text-center text-muted py-4';
td.textContent = 'All reimbursements are currently assigned to a batch.';
tr.appendChild(td);
tableBody.appendChild(tr);
} else if (hasRow && emptyRow) {
emptyRow.remove();
}
}
function removeItemFromUI(itemId) {
const row = tableBody.querySelector(`tr[data-item-id="${itemId}"]`);
if (row) {
row.remove();
}
batchBoard.querySelectorAll(`.batch-item[data-item-id="${itemId}"]`).forEach((card) => {
const batchId = Number(card.closest('.batch-card')?.dataset.batchId || 0);
card.remove();
if (batchId) {
updateBatchTotals(batchId);
}
});
itemMap.delete(itemId);
refreshPendingEmptyState();
}
async function markDonation(itemId) {
if (!cfg.markDonationUrl) {
alert('Donation updates are not available right now.');
return;
}
const entry = itemMap.get(itemId);
if (!entry) {
return;
}
const confirmMsg = 'Mark this expense as a donation? It will be removed from reimbursement batches.';
if (!confirm(confirmMsg)) {
return;
}
const form = new FormData();
form.append('expense_id', itemId);
try {
await postJson(cfg.markDonationUrl, form, 'Unable to mark donation right now.');
removeItemFromUI(itemId);
} catch (err) {
console.error(err);
alert(err.message || 'Unable to mark donation right now.');
}
}
function attachRowHandlers(row) {
if (!row || row.dataset.bound === '1') {
return;
}
row.dataset.bound = '1';
row.addEventListener('dragstart', (event) => {
const itemId = Number(row.dataset.itemId);
if (!itemMap.has(itemId)) {
return;
}
const dt = event.dataTransfer;
if (dt) {
dt.effectAllowed = 'move';
dt.setData('text/plain', String(itemId));
}
row.classList.add('dragging');
dragPayload = { source: 'table', itemId };
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
dragPayload = null;
});
}
function buildTableRow(item) {
const tr = document.createElement('tr');
tr.dataset.itemId = item.id;
if (item.reimbursement_id) {
tr.dataset.reimbId = item.reimbursement_id;
} else {
delete tr.dataset.reimbId;
}
tr.draggable = true;
tr.innerHTML = `
<td class="fw-semibold">${currency.format(item.expense_amount)}</td>
<td>${item.vendor || 'N/A'}</td>
<td>${item.description || '-'}</td>
<td>${item.purchased_by || 'Unknown'}</td>
<td>${item.receipt_url ? `<a href="${item.receipt_url}" class="receipt-link" data-receipt-url="${item.receipt_url}" target="_blank" rel="noopener">View Receipt</a>` : '<span class="text-muted">N/A</span>'}</td>
<td>
<button type="button" class="btn btn-sm btn-outline-success mark-donation-btn" data-item-id="${item.id}" draggable="false">
Mark as Donation
</button>
</td>
`;
attachRowHandlers(tr);
return tr;
}
function createBatchItemElement(item, adminId) {
const div = document.createElement('div');
div.className = 'batch-item';
div.draggable = true;
div.dataset.itemId = item.id;
div.dataset.adminId = adminId ?? 0;
if (item.reimbursement_id) {
div.dataset.reimbId = item.reimbursement_id;
} else {
delete div.dataset.reimbId;
}
const receiptLinkHtml = item.receipt_url
? `<a href="${item.receipt_url}" class="receipt-link small text-decoration-none" data-receipt-url="${item.receipt_url}" target="_blank" rel="noopener">View Receipt</a>`
: '<span class="text-muted small">No receipt</span>';
div.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${item.description || 'No description'}</div>
<div class="item-meta">${item.vendor || 'N/A'} • ${item.purchased_by || 'Unknown'}</div>
</div>
<div class="text-end">
<strong>${currency.format(item.expense_amount)}</strong>
<div class="mt-1">${receiptLinkHtml}</div>
</div>
</div>
`;
div.addEventListener('dragstart', (event) => {
const batchId = Number(div.closest('.batch-card')?.dataset.batchId || 0);
const adminSlot = Number(div.dataset.adminId || 0);
const dt = event.dataTransfer;
if (dt) {
dt.effectAllowed = 'move';
dt.setData('text/plain', String(item.id));
}
div.classList.add('dragging');
dragPayload = { source: 'batch', itemId: item.id, fromBatchId: batchId, fromAdminId: adminSlot };
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
dragPayload = null;
});
return div;
}
function updateBatchTotals(batchId) {
const card = batchBoard.querySelector(`.batch-card[data-batch-id="${batchId}"]`);
if (!card) {
return;
}
let total = 0;
let count = 0;
card.querySelectorAll('.batch-dropzone').forEach((zone) => {
let zoneTotal = 0;
zone.querySelectorAll('.batch-item').forEach((el) => {
const item = itemMap.get(Number(el.dataset.itemId));
if (!item) {
return;
}
count += 1;
const amount = Number(item.expense_amount || 0);
total += amount;
zoneTotal += amount;
});
const summary = zone.querySelector('.admin-subtotal');
if (summary) {
summary.textContent = `Subtotal: ${currency.format(zoneTotal)}`;
}
});
const itemsTarget = card.querySelector('.total-items');
const amountTarget = card.querySelector('.total-amount');
const headerItems = card.querySelector('.header-total-items');
const headerAmount = card.querySelector('.header-total-amount');
if (itemsTarget) {
itemsTarget.textContent = count;
}
if (amountTarget) {
amountTarget.textContent = currency.format(total);
}
if (headerItems) {
headerItems.textContent = count;
}
if (headerAmount) {
headerAmount.textContent = currency.format(total);
}
const exportBtn = card.querySelector('.batch-export-btn');
if (exportBtn) {
exportBtn.disabled = count === 0 || !cfg.batchExportUrl;
}
card.dataset.itemCount = String(count);
updateLockButtonState(card);
}
function setCardCollapsed(card, collapsed) {
if (!card) {
return;
}
const shouldCollapse = collapsed === undefined
? !card.classList.contains('batch-collapsed')
: !!collapsed;
card.classList.toggle('batch-collapsed', shouldCollapse);
const toggleBtn = card.querySelector('.toggle-card-btn');
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', shouldCollapse ? 'false' : 'true');
const label = toggleBtn.querySelector('.toggle-label');
if (label) {
label.textContent = shouldCollapse ? 'Expand' : 'Collapse';
}
}
}
async function ensureBatchPersisted(card) {
if (!card) {
throw new Error('Batch not found.');
}
let batchId = Number(card.dataset.batchId || 0);
if (batchId > 0) {
return batchId;
}
if (!cfg.createBatchUrl) {
throw new Error('Batch creation is not available right now.');
}
const lockBtn = card.querySelector('.lock-batch-btn');
const exportBtn = card.querySelector('.batch-export-btn');
if (lockBtn) {
lockBtn.disabled = true;
}
if (exportBtn) {
exportBtn.disabled = true;
}
const data = await createRemoteBatch();
batchId = Number(data.batch_id) || 0;
if (batchId <= 0) {
throw new Error('Invalid batch id returned by server.');
}
card.dataset.batchId = batchId;
card.dataset.batchNumber = batchId;
card.dataset.unsaved = '0';
const sequence = Number(data.sequence ?? data.yearly_number ?? 0);
card.dataset.yearlyBatchNumber = sequence;
const titleEl = card.querySelector('.batch-title');
const header = card.querySelector('.header-summary');
if (titleEl) {
const labelText = card.dataset.customLabel?.trim?.() || '';
const label = labelText !== '' ? labelText : `Batch #${sequence || batchId}`;
titleEl.textContent = label;
}
if (header) {
const draftTag = header.querySelector('.text-warning');
if (draftTag) {
draftTag.remove();
}
}
if (exportBtn) {
exportBtn.disabled = false;
}
card.querySelectorAll('.admin-check-wrapper').forEach((wrapper) => {
const zone = wrapper.previousElementSibling?.classList?.contains('batch-dropzone')
? wrapper.previousElementSibling
: null;
const hasFile = zone?.dataset?.hasCheckFile === '1';
const status = wrapper.querySelector('.admin-check-status');
const input = wrapper.querySelector('.admin-check-input');
if (status && !hasFile) {
status.textContent = 'No file uploaded';
}
if (input && card.dataset.locked !== '1') {
input.disabled = false;
}
});
const allInputs = card.querySelectorAll('.admin-check-input');
allInputs.forEach((input) => {
if (card.dataset.locked !== '1') {
input.disabled = false;
}
});
updateLockButtonState(card);
return batchId;
}
function persistBatchChange(itemId, batchId, adminId, revertAction) {
const entry = itemMap.get(itemId);
if (!entry) { return; }
const form = new FormData();
form.append('expense_id', itemId);
if (entry.reimbursement_id) {
form.append('reimbursement_id', entry.reimbursement_id);
}
form.append('batch_id', batchId);
form.append('batch_number', batchId);
if (adminId || adminId === 0) {
form.append('admin_id', adminId);
} else {
form.append('admin_id', '');
}
postJson(cfg.updateUrl, form, 'Unable to update batch assignment.').then((data) => {
entry.batch_number = batchId;
entry.batchId = batchId;
entry.admin_id = (adminId || adminId === 0) ? adminId : null;
if (data.reimbursement_id) {
entry.reimbursement_id = Number(data.reimbursement_id);
}
const cardNode = batchBoard.querySelector(`.batch-item[data-item-id="${itemId}"]`);
if (cardNode) {
if (entry.reimbursement_id) {
cardNode.dataset.reimbId = entry.reimbursement_id;
} else {
delete cardNode.dataset.reimbId;
}
cardNode.dataset.adminId = entry.admin_id ?? 0;
}
const rowNode = tableBody.querySelector(`tr[data-item-id="${itemId}"]`);
if (rowNode) {
if (entry.reimbursement_id) {
rowNode.dataset.reimbId = entry.reimbursement_id;
} else {
delete rowNode.dataset.reimbId;
}
}
}).catch((err) => {
console.error(err);
if (typeof revertAction === 'function') {
revertAction();
}
alert(err.message || 'Unable to update batch assignment. Please try again.');
});
}
async function createRemoteBatch() {
if (!cfg.createBatchUrl) {
throw new Error('Batch creation endpoint is not configured.');
}
const form = new FormData();
return postJson(cfg.createBatchUrl, form, 'Unable to create a new batch right now.');
}
async function uploadAdminCheckFile(batchId, adminId, input, statusEl, linkEl) {
if (!cfg.checkUploadUrl) {
alert('Check uploads are not available right now.');
return;
}
const file = input?.files?.[0];
if (!file) {
statusEl.textContent = 'No file selected';
return;
}
const form = new FormData();
form.append('batch_id', batchId);
form.append('admin_id', adminId ?? 0);
form.append('check_file', file);
input.disabled = true;
const previous = statusEl.textContent;
statusEl.textContent = 'Uploading...';
try {
const data = await postJson(cfg.checkUploadUrl, form, 'Unable to upload check file right now.');
const label = data.original_filename || 'View check';
statusEl.textContent = `Uploaded: ${data.original_filename || 'file'}`;
if (linkEl && data.url) {
linkEl.href = data.url;
linkEl.textContent = label;
linkEl.classList.remove('d-none');
}
const parentZone = input.closest('.batch-dropzone');
if (parentZone) {
parentZone.dataset.hasCheckFile = '1';
updateLockButtonState(parentZone.closest('.batch-card'));
}
} catch (err) {
console.error(err);
alert(err.message || 'Unable to upload the check file. Please try again.');
statusEl.textContent = previous || 'Upload failed';
} finally {
input.disabled = false;
input.value = '';
}
}
function renderCheckUploader(zone, batchId, adminId, existing = null) {
if (!zone) {
return;
}
let wrapper = zone.querySelector('.admin-check-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'admin-check-wrapper';
wrapper.innerHTML = `
<div class="d-flex flex-wrap align-items-center gap-2">
<label class="form-label form-label-sm mb-0">Check File</label>
<input type="file" class="form-control form-control-sm admin-check-input" accept=".pdf,image/*">
<span class="text-muted small admin-check-status"></span>
</div>
<a class="admin-check-link small d-none" target="_blank" rel="noopener"></a>
`;
zone.appendChild(wrapper);
}
const input = wrapper.querySelector('.admin-check-input');
const status = wrapper.querySelector('.admin-check-status');
const link = wrapper.querySelector('.admin-check-link');
if (!input || !status || !link) {
return;
}
const card = zone.closest('.batch-card');
const cardBatchId = Number(card?.dataset.batchId || 0);
input.disabled = card?.dataset.locked === '1' || cardBatchId <= 0;
if (existing?.url) {
zone.dataset.hasCheckFile = '1';
status.textContent = existing.original_filename ? `Uploaded: ${existing.original_filename}` : 'Uploaded';
link.href = existing.url;
link.textContent = existing.original_filename || 'View check';
link.classList.remove('d-none');
} else {
zone.dataset.hasCheckFile = '0';
status.textContent = cardBatchId <= 0 ? 'Save batch before uploading' : 'No file uploaded';
link.classList.add('d-none');
}
input.onchange = () => {
const latestBatchId = Number(card?.dataset.batchId || batchId || 0);
uploadAdminCheckFile(latestBatchId, adminId ?? 0, input, status, link);
};
updateLockButtonState(card);
}
function markBatchCardLocked(card) {
if (!card || card.dataset.locked === '1') {
return;
}
card.dataset.locked = '1';
card.classList.add('batch-locked');
card.querySelectorAll('.batch-item').forEach((item) => {
item.draggable = false;
});
card.querySelectorAll('.batch-dropzone').forEach((zone) => {
zone.classList.add('locked');
});
const picker = card.querySelector('.batch-admin-picker');
if (picker) {
picker.classList.add('d-none');
}
card.querySelectorAll('.admin-check-input').forEach((input) => {
input.disabled = true;
});
const button = card.querySelector('.lock-batch-btn');
if (button) {
button.textContent = 'Locked';
button.disabled = true;
}
card.dataset.readyToLock = '0';
}
function updateLockButtonState(card) {
if (!card) {
return;
}
const button = card.querySelector('.lock-batch-btn');
if (!button) {
return;
}
const cardBatchId = Number(card.dataset.batchId || 0);
if (cardBatchId <= 0) {
button.disabled = true;
card.dataset.readyToLock = '0';
return;
}
const zones = Array.from(card.querySelectorAll('.batch-dropzone'));
const zonesWithItems = zones.filter((zone) => zone.querySelector('.batch-item'));
const hasItems = zonesWithItems.length > 0;
const ready = hasItems && zonesWithItems.every((zone) => zone.dataset.hasCheckFile === '1');
button.disabled = card.dataset.locked === '1' || !ready;
card.dataset.readyToLock = ready && card.dataset.locked !== '1' ? '1' : '0';
}
async function handleBatchLock(card, batchId, button) {
if (!card || card.dataset.locked === '1') {
return;
}
if (card.dataset.readyToLock !== '1') {
alert('Upload check files for every admin before submitting the batch.');
return;
}
if (!cfg.lockBatchUrl) {
alert('Batch locking is not available right now.');
return;
}
const form = new FormData();
form.append('batch_id', batchId);
if (button) {
button.disabled = true;
button.textContent = 'Locking...';
}
try {
await postJson(cfg.lockBatchUrl, form, 'Unable to lock batch at the moment.');
markBatchCardLocked(card);
} catch (err) {
console.error(err);
alert(err.message || 'Unable to lock the batch. Please try again.');
if (button) {
button.disabled = false;
button.textContent = 'Submit Batch';
}
}
}
function moveRowToBatch(itemId, batchId, adminId, zoneList) {
const row = tableBody.querySelector(`tr[data-item-id="${itemId}"]`);
const item = itemMap.get(itemId);
if (!item || !zoneList) {
return null;
}
if (row) {
row.remove();
}
const card = createBatchItemElement(item, adminId);
zoneList.appendChild(card);
refreshPendingEmptyState();
updateBatchTotals(batchId);
return () => {
card.remove();
const restored = buildTableRow(item);
tableBody.appendChild(restored);
refreshPendingEmptyState();
updateBatchTotals(batchId);
};
}
function moveBatchItemToZone(itemId, batchId, adminId, zoneList) {
const card = batchBoard.querySelector(`.batch-item[data-item-id="${itemId}"]`);
if (!card || !zoneList) {
return null;
}
const previousZone = card.parentElement;
const previousBatch = Number(card.closest('.batch-card')?.dataset.batchId || 0);
const previousAdmin = Number(card.dataset.adminId || 0);
zoneList.appendChild(card);
card.dataset.adminId = adminId ?? '';
updateBatchTotals(batchId);
if (previousBatch && previousBatch !== batchId) {
updateBatchTotals(previousBatch);
}
return () => {
previousZone.appendChild(card);
card.dataset.adminId = previousAdmin;
updateBatchTotals(batchId);
if (previousBatch && previousBatch !== batchId) {
updateBatchTotals(previousBatch);
}
};
}
function moveItemBackToTable(itemId) {
const card = batchBoard.querySelector(`.batch-item[data-item-id="${itemId}"]`);
if (!card) {
return null;
}
const batchId = Number(card.closest('.batch-card')?.dataset.batchId || 0);
const previousZone = card.parentElement;
const item = itemMap.get(itemId);
card.remove();
const newRow = buildTableRow(item);
tableBody.appendChild(newRow);
refreshPendingEmptyState();
updateBatchTotals(batchId);
return () => {
newRow.remove();
previousZone.appendChild(card);
updateBatchTotals(batchId);
};
}
function attachDropHandlers(zone) {
zone.addEventListener('dragover', (event) => {
const parentCard = zone.closest('.batch-card');
if (parentCard?.dataset.locked === '1') {
return;
}
if (!dragPayload) {
return;
}
event.preventDefault();
const dt = event.dataTransfer;
if (dt) {
dt.dropEffect = 'move';
}
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', async (event) => {
event.preventDefault();
zone.classList.remove('drag-over');
const parentCard = zone.closest('.batch-card');
if (parentCard?.dataset.locked === '1') {
return;
}
if (!dragPayload || !dragPayload.itemId) {
return;
}
const list = zone.querySelector('.dropzone-list');
let batchId = Number(zone.closest('.batch-card')?.dataset.batchId || 0);
const adminIdRaw = zone.dataset.adminId;
const adminId = (adminIdRaw === undefined || adminIdRaw === '') ? null : Number(adminIdRaw);
let revertAction = null;
if (batchId <= 0) {
try {
batchId = await ensureBatchPersisted(parentCard);
} catch (err) {
alert(err.message || 'Unable to create batch.');
return;
}
}
if (dragPayload.source === 'table') {
revertAction = moveRowToBatch(dragPayload.itemId, batchId, adminId, list);
persistBatchChange(dragPayload.itemId, batchId, adminId, revertAction);
} else if (dragPayload.source === 'batch') {
revertAction = moveBatchItemToZone(dragPayload.itemId, batchId, adminId, list);
if (dragPayload.fromBatchId !== batchId || dragPayload.fromAdminId !== adminId) {
persistBatchChange(dragPayload.itemId, batchId, adminId, revertAction);
}
}
dragPayload = null;
});
}
function addAdminZone(card, adminId, label, isDefault = false, checkFile = null) {
const existing = card.querySelector(`.batch-dropzone[data-admin-id="${adminId}"]`);
if (existing) {
return existing;
}
const zone = document.createElement('div');
zone.className = 'batch-dropzone';
zone.dataset.adminId = adminId;
zone.dataset.hasCheckFile = checkFile ? '1' : '0';
zone.innerHTML = `
<div class="dropzone-title d-flex justify-content-between align-items-center">
<span>${label}</span>
<span class="admin-subtotal">Subtotal: ${currency.format(0)}</span>
</div>
<div class="dropzone-list"></div>
<div class="admin-check-wrapper">
<div class="d-flex flex-wrap align-items-center gap-2">
<label class="form-label form-label-sm mb-0">Check File</label>
<input type="file" class="form-control form-control-sm admin-check-input" accept=".pdf,image/*">
<span class="text-muted small admin-check-status"></span>
</div>
<a class="admin-check-link small d-none" target="_blank" rel="noopener"></a>
</div>
`;
card.querySelector('.batch-dropzones')?.appendChild(zone);
const placeholder = card.querySelector('.no-admin-placeholder');
if (placeholder) {
placeholder.remove();
}
attachDropHandlers(zone);
renderCheckUploader(zone, Number(card.dataset.batchId || 0), adminId ?? 0, checkFile);
updateBatchTotals(Number(card.dataset.batchId || 0));
if (!isDefault && adminId) {
zone.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return zone;
}
function createBatchCard(batchId, initialItems = [], options = {}) {
const card = document.createElement('div');
card.className = 'card batch-card';
card.dataset.batchId = batchId;
card.dataset.batchNumber = batchId;
const sequence = Number(options.sequence ?? 0);
const visibleSequence = sequence > 0 ? sequence : batchId;
card.dataset.yearlyBatchNumber = sequence;
const labelText = (options.label || '').trim();
const unsaved = !!options.unsaved;
const startCollapsed = options.startCollapsed === true;
const sequenceLabel = unsaved
? 'Draft Batch'
: `Batch #${visibleSequence}`;
const isPlainBatchLabel = /^Batch #\d+$/i.test(labelText);
const title = unsaved
? (labelText || sequenceLabel)
: (!labelText || isPlainBatchLabel
? sequenceLabel
: `${labelText} (${sequenceLabel})`);
if (unsaved) {
card.dataset.unsaved = '1';
}
card.dataset.customLabel = labelText;
const status = (options.status || 'open').trim().toLowerCase();
const isLocked = status && status !== 'open';
card.innerHTML = `
<div class="card-header d-flex flex-wrap gap-2 align-items-center">
<div class="d-flex flex-column flex-grow-1 gap-1">
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-primary toggle-card-btn" aria-expanded="true">
<span class="toggle-icon">v</span>
<span class="toggle-label">Collapse</span>
</button>
<strong class="mb-0 batch-title">${title}</strong>
</div>
<div class="text-muted small d-flex flex-wrap gap-3 header-summary">
<span>Items: <strong class="header-total-items">0</strong></span>
<span>Amount: <strong class="header-total-amount">${currency.format(0)}</strong></span>
${unsaved ? '<span class="text-warning">Draft (not saved)</span>' : ''}
</div>
</div>
<div class="ms-auto d-flex gap-2 align-items-center">
<button type="button" class="btn btn-sm btn-outline-danger lock-batch-btn">Submit Batch</button>
<button type="button" class="btn btn-sm btn-outline-success batch-export-btn">Download CSV</button>
<div class="d-flex gap-2 align-items-center batch-admin-picker">
<div class="input-group input-group-sm">
<select class="form-select form-select-sm admin-select">
<option value="">Select an admin</option>
</select>
<button type="button" class="btn btn-outline-primary add-admin-btn">Add</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="batch-dropzones"></div>
</div>
<div class="card-footer d-flex justify-content-end gap-4 small">
<span>Total Items: <strong class="total-items">0</strong></span>
<span>Total Amount: <strong class="total-amount">${currency.format(0)}</strong></span>
</div>
`;
const select = card.querySelector('.admin-select');
const addBtn = card.querySelector('.add-admin-btn');
const lockBtn = card.querySelector('.lock-batch-btn');
const toggleBtn = card.querySelector('.toggle-card-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const nextCollapsed = !card.classList.contains('batch-collapsed');
setCardCollapsed(card, nextCollapsed);
});
}
if (lockBtn) {
lockBtn.addEventListener('click', () => handleBatchLock(card, batchId, lockBtn));
if (!cfg.lockBatchUrl) {
lockBtn.disabled = true;
}
if (batchId <= 0) {
lockBtn.disabled = true;
}
}
const exportBtn = card.querySelector('.batch-export-btn');
if (exportBtn) {
if (cfg.batchExportUrl) {
exportBtn.addEventListener('click', (event) => {
event.preventDefault();
const targetId = Number(card.dataset.batchId || 0);
if (targetId <= 0) {
return;
}
window.open(`${cfg.batchExportUrl}?batch_id=${targetId}`, '_blank');
});
} else {
exportBtn.disabled = true;
}
if (batchId <= 0) {
exportBtn.disabled = true;
}
}
if (select && addBtn) {
if (admins.length === 0) {
select.innerHTML = '<option value="">No admin roles found</option>';
select.disabled = true;
addBtn.disabled = true;
} else {
admins.forEach((admin) => {
const option = document.createElement('option');
option.value = admin.id;
option.textContent = admin.name || `Admin #${admin.id}`;
select.appendChild(option);
});
addBtn.addEventListener('click', () => {
const adminId = Number(select.value);
if (!adminId) {
return;
}
const admin = admins.find((a) => a.id === adminId);
addAdminZone(card, adminId, admin?.name || `Admin #${adminId}`);
select.value = '';
});
}
}
const dropzoneWrapper = card.querySelector('.batch-dropzones');
const placeholder = document.createElement('div');
placeholder.className = 'no-admin-placeholder text-muted small';
placeholder.textContent = 'Add at least one admin to start assigning reimbursements to this batch.';
dropzoneWrapper.appendChild(placeholder);
const slots = Array.isArray(options.slots) ? options.slots : [];
const renderSlotItems = (zone, itemIds, adminId) => {
const list = zone.querySelector('.dropzone-list');
(Array.isArray(itemIds) ? itemIds : []).forEach((itemId) => {
const item = itemMap.get(Number(itemId));
if (!item) {
return;
}
const cardEl = createBatchItemElement(item, adminId);
list.appendChild(cardEl);
item.batch_number = batchId;
item.batchId = batchId;
});
};
if (slots.length > 0) {
slots.forEach((slot, index) => {
const adminId = Number(slot.admin_id ?? slot.adminId ?? 0);
const label = (slot.admin_name || slot.adminName || '').trim() || (adminId > 0 ? `Admin #${adminId}` : 'Unassigned');
const zone = addAdminZone(card, adminId, label, index === 0, slot.check_file || null);
renderSlotItems(zone, slot.items, adminId);
});
placeholder.remove();
} else {
(initialItems || []).forEach((itemId) => {
const item = itemMap.get(Number(itemId));
if (!item) {
return;
}
const defaultAdmin = admins[0] ?? { id: 0, name: 'Unassigned' };
const zone = addAdminZone(card, defaultAdmin.id, defaultAdmin.name, true);
const list = zone.querySelector('.dropzone-list');
const cardEl = createBatchItemElement(item, defaultAdmin.id);
list.appendChild(cardEl);
item.batch_number = batchId;
item.batchId = batchId;
placeholder.remove();
});
}
updateBatchTotals(batchId);
if (isLocked) {
markBatchCardLocked(card);
} else {
card.dataset.locked = '0';
updateLockButtonState(card);
}
if (startCollapsed) {
setCardCollapsed(card, true);
}
return card;
}
tableBody.querySelectorAll('tr[data-item-id]').forEach((row) => attachRowHandlers(row));
tableBody.addEventListener('dragover', (event) => {
if (!dragPayload || dragPayload.source !== 'batch') {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
tableBody.classList.add('drag-over');
});
tableBody.addEventListener('dragleave', (event) => {
if (event.target === tableBody) {
tableBody.classList.remove('drag-over');
}
});
tableBody.addEventListener('drop', (event) => {
if (!dragPayload || dragPayload.source !== 'batch') {
return;
}
event.preventDefault();
tableBody.classList.remove('drag-over');
const revertAction = moveItemBackToTable(dragPayload.itemId);
persistBatchChange(dragPayload.itemId, 0, null, revertAction);
dragPayload = null;
});
if (pageContainer) {
pageContainer.addEventListener('click', handleReceiptLinkClick);
pageContainer.addEventListener('click', handleDonationClick);
}
(cfg.batches || []).forEach((batch) => {
if (!batch) {
return;
}
const batchId = Number(batch.batchNumber ?? batch.batchId ?? 0);
if (batchId <= 0) {
return;
}
const sequence = Number(batch.sequence ?? batch.yearly_batch_number ?? 0);
const card = createBatchCard(batchId, [], {
label: batch.label,
slots: batch.slots,
status: batch.status,
sequence,
startCollapsed: true,
});
batchBoard.appendChild(card);
updateBatchTotals(batchId);
toggleBatchEmptyState();
});
if (addBatchBtn) {
addBatchBtn.addEventListener('click', async () => {
addBatchBtn.disabled = true;
const card = createBatchCard(0, [], {
label: 'New Batch (draft)',
sequence: 0,
unsaved: true,
startCollapsed: false,
});
batchBoard.prepend(card);
toggleBatchEmptyState();
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
await ensureBatchPersisted(card);
} catch (err) {
console.error(err);
alert(err?.message || 'Unable to create a new batch right now.');
card.remove();
toggleBatchEmptyState();
} finally {
addBatchBtn.disabled = false;
}
});
}
})();
</script>
<?= $this->endSection() ?>