1452 lines
55 KiB
PHP
1452 lines
55 KiB
PHP
<?= $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() ?>
|