Skip to content

Commit f33778c

Browse files
Merge pull request #63 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Keep the screenshots the panel has always asked for
2 parents 6192673 + 52e9764 commit f33778c

10 files changed

Lines changed: 597 additions & 6 deletions

File tree

‎admin/admin.css‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,8 +583,37 @@ label.finance-toggle {
583583
.booking-conflict-error {
584584
color: var(--danger); font-size: 0.85rem; font-weight: 600; margin: -6px 0 14px; min-height: 1.2em;
585585
}
586+
/* Thumbnails: the files chosen in a form before they are sent, and the ones
587+
already attached to a booking. Same strip in both places, so what you see
588+
after saving looks like what you saw before it. */
586589
.proof-preview { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
587-
.proof-preview img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); }
590+
.proof-preview img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); display: block; }
591+
592+
/* A stored one carries a remove button, which sits on its corner rather than
593+
beside it: a row of thumbnails each with a button underneath is twice as
594+
tall and reads as a list of buttons. */
595+
.proof-item { position: relative; display: inline-block; line-height: 0; }
596+
.proof-item a { display: block; border-radius: 8px; }
597+
.proof-item a:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
598+
599+
.proof-remove {
600+
position: absolute; top: -6px; right: -6px;
601+
width: 20px; height: 20px; padding: 0;
602+
display: grid; place-items: center;
603+
border: 1px solid var(--border); border-radius: 50%;
604+
background: var(--surface); color: var(--ink-dim);
605+
font-size: 14px; line-height: 1; cursor: pointer;
606+
}
607+
.proof-remove:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
608+
.proof-remove:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
609+
610+
/* A finger is wider than 20px. On a touch screen the target is padded out
611+
without the button itself growing, which would crowd the thumbnail. */
612+
@media (pointer: coarse) {
613+
.proof-remove::after {
614+
content: ''; position: absolute; inset: -12px;
615+
}
616+
}
588617

589618
/* ---------------------------------------------------------------------------
590619
The booked-days calendar under a date box.

‎admin/admin.js‎

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,7 @@ async function renderBookingDetail(id) {
13421342
<div class="detail-field"><span class="k">Balance</span><span class="v">${formatINR(booking.balance)}</span></div>
13431343
<div class="detail-field"><span class="k">Status</span><span class="v">${booking.payment_status}</span></div>
13441344
</div>
1345+
${attachmentsHTML(booking.files, 'payment', 'Payment screenshots')}
13451346
</div>
13461347
13471348
<div class="detail-section">
@@ -1368,6 +1369,8 @@ async function renderBookingDetail(id) {
13681369
<span class="amount">${formatINR(r.refund_amount)}</span>
13691370
</div>`).join('')}
13701371
` : ''}
1372+
${attachmentsHTML(booking.files, 'deposit', 'Deposit proof')}
1373+
${attachmentsHTML(booking.files, 'refund', 'Refund proof')}
13711374
</div>
13721375
13731376
<div class="detail-section">
@@ -1384,6 +1387,7 @@ async function renderBookingDetail(id) {
13841387
</div>
13851388
${open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${booking.pickup.id}" data-current="${booking.pickup.odometer_km}">Correct reading</button>` : ''}
13861389
` : '<p class="detail-empty">Not recorded yet.</p>'}
1390+
${attachmentsHTML(booking.files, 'pickup', 'Pickup photos')}
13871391
</div>
13881392
13891393
<div class="detail-section">
@@ -1407,6 +1411,7 @@ async function renderBookingDetail(id) {
14071411
</div>
14081412
${open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${booking.return.id}" data-current="${booking.return.odometer_km}">Correct reading</button>` : ''}
14091413
` : `<p class="detail-empty">${booking.pickup ? 'Not recorded yet.' : 'Record pickup first.'}</p>`}
1414+
${attachmentsHTML(booking.files, 'return', 'Return photos')}
14101415
</div>
14111416
14121417
<div class="detail-section">
@@ -1421,6 +1426,19 @@ async function renderBookingDetail(id) {
14211426
function wireDetailActions(booking) {
14221427
const on = (id, handler) => document.getElementById(id)?.addEventListener('click', handler);
14231428

1429+
// Removing an attachment. Delegated from the panel rather than bound per
1430+
// thumbnail, because the list is re-rendered after every change and
1431+
// per-element listeners would be re-bound each time or left behind.
1432+
document.querySelectorAll('.proof-remove').forEach((btn) => {
1433+
btn.addEventListener('click', async () => {
1434+
if (!confirm('Remove this file? It cannot be brought back.')) return;
1435+
try {
1436+
await api.bookingFiles.remove(Number(btn.dataset.fileId));
1437+
await refreshAfterBookingChange();
1438+
} catch (err) { showError(err); }
1439+
});
1440+
});
1441+
14241442
on('detailEditBtn', () => { closeBookingDetail(); openBookingModal(booking); });
14251443

14261444
on('detailCancelBtn', async () => {
@@ -1490,10 +1508,119 @@ function nowTimeStr() {
14901508
return new Date().toTimeString().slice(0, 5);
14911509
}
14921510

1511+
// ---- Booking attachments ----
1512+
//
1513+
// The five file boxes -- payment screenshot, deposit proof, refund proof,
1514+
// pickup photos, return photos -- have been in these forms since the booking
1515+
// screens were built and were wired to nothing at all. Choosing a file and
1516+
// pressing Save recorded the payment and threw the file away, with no error,
1517+
// which is indistinguishable from the upload having worked.
1518+
//
1519+
// Three things were missing and all three are here: a preview so you can see
1520+
// what you picked, the upload itself, and somewhere on the booking for them to
1521+
// appear afterwards.
1522+
const ATTACHMENT_BOXES = [
1523+
{ input: 'paymentProof', preview: 'paymentProofPreview', kind: 'payment' },
1524+
{ input: 'depositProof', preview: 'depositProofPreview', kind: 'deposit' },
1525+
{ input: 'refundProof', preview: 'refundProofPreview', kind: 'refund' },
1526+
{ input: 'pickupPhotos', preview: 'pickupPhotosPreview', kind: 'pickup' },
1527+
{ input: 'returnPhotos', preview: 'returnPhotosPreview', kind: 'return' },
1528+
];
1529+
1530+
// Object URLs held so they can be revoked. A page that creates one per chosen
1531+
// file and never releases them keeps every image alive in memory for as long
1532+
// as the tab is open, which on a phone is a panel that gets slower all day.
1533+
const attachmentPreviewUrls = new Map();
1534+
1535+
function clearAttachmentPreview(box) {
1536+
const held = attachmentPreviewUrls.get(box.preview) || [];
1537+
for (const url of held) URL.revokeObjectURL(url);
1538+
attachmentPreviewUrls.set(box.preview, []);
1539+
const node = document.getElementById(box.preview);
1540+
if (node) node.innerHTML = '';
1541+
}
1542+
1543+
function resetAttachmentBox(kindOrInput) {
1544+
const box = ATTACHMENT_BOXES.find((b) => b.kind === kindOrInput || b.input === kindOrInput);
1545+
if (!box) return;
1546+
clearAttachmentPreview(box);
1547+
const input = document.getElementById(box.input);
1548+
if (input) input.value = '';
1549+
}
1550+
1551+
for (const box of ATTACHMENT_BOXES) {
1552+
const input = document.getElementById(box.input);
1553+
const preview = document.getElementById(box.preview);
1554+
if (!input || !preview) continue;
1555+
1556+
input.addEventListener('change', () => {
1557+
clearAttachmentPreview(box);
1558+
const urls = [];
1559+
for (const file of input.files) {
1560+
const url = URL.createObjectURL(file);
1561+
urls.push(url);
1562+
const img = document.createElement('img');
1563+
img.src = url;
1564+
img.alt = file.name;
1565+
img.title = file.name;
1566+
preview.appendChild(img);
1567+
}
1568+
attachmentPreviewUrls.set(box.preview, urls);
1569+
});
1570+
}
1571+
1572+
/**
1573+
* Sends whatever is in one of those boxes, once the record it belongs to
1574+
* exists.
1575+
*
1576+
* Deliberately after the save rather than with it: a booking_files row points
1577+
* at a booking, and the payment or reading has to be there first. It also
1578+
* means a refused image never costs someone the payment they just entered --
1579+
* the record is already in, and this reports the file problem on its own.
1580+
*/
1581+
async function uploadAttachments(kind, bookingId, refId = null) {
1582+
const box = ATTACHMENT_BOXES.find((b) => b.kind === kind);
1583+
const input = box ? document.getElementById(box.input) : null;
1584+
if (!input || !input.files || input.files.length === 0) return;
1585+
1586+
try {
1587+
const result = await api.bookingFiles.add(bookingId, kind, input.files, refId);
1588+
if (result.warning) alert(result.warning);
1589+
} catch (err) {
1590+
// Said out loud, and said as being about the file only. The thing the
1591+
// person came to do has already been saved.
1592+
alert(`The ${box.kind} record was saved, but the file was not: ${err.message}`);
1593+
} finally {
1594+
resetAttachmentBox(kind);
1595+
}
1596+
}
1597+
1598+
/** The thumbnails for one kind, inside the booking detail screen. */
1599+
function attachmentsHTML(files, kind, label) {
1600+
const list = (files && files[kind]) || [];
1601+
if (list.length === 0) return '';
1602+
1603+
return `
1604+
<p class="modal-section-label">${label}</p>
1605+
<div class="proof-preview">
1606+
${list.map((f) => `
1607+
<span class="proof-item">
1608+
<a href="${f.url}" target="_blank" rel="noopener">
1609+
<img src="${f.url}" alt="${escapeHTML(f.caption || label)}" title="${escapeHTML(f.caption || label)}">
1610+
</a>
1611+
<button type="button" class="proof-remove" data-file-id="${f.id}"
1612+
aria-label="Remove this file" title="Remove">&times;</button>
1613+
</span>
1614+
`).join('')}
1615+
</div>
1616+
`;
1617+
}
1618+
14931619
// ---- Payment modal ----
14941620
const paymentModalOverlay = document.getElementById('paymentModalOverlay');
14951621

14961622
function openPaymentModal(bookingId) {
1623+
resetAttachmentBox('payment');
14971624
document.getElementById('paymentForm').reset();
14981625
document.getElementById('paymentBookingId').value = bookingId;
14991626
document.getElementById('paymentDate').value = todayStr();
@@ -1507,15 +1634,20 @@ document.getElementById('paymentForm').addEventListener('submit', async (e) => {
15071634
const btn = e.target.querySelector('button[type="submit"]');
15081635
btn.disabled = true;
15091636
try {
1510-
await api.payments.add({
1511-
booking_id: Number(document.getElementById('paymentBookingId').value),
1637+
const bookingId = Number(document.getElementById('paymentBookingId').value);
1638+
const saved = await api.payments.add({
1639+
booking_id: bookingId,
15121640
kind: document.getElementById('paymentType').value,
15131641
amount: document.getElementById('paymentAmount').value,
15141642
paid_on: document.getElementById('paymentDate').value,
15151643
method: document.getElementById('paymentMethod').value,
15161644
reference: document.getElementById('paymentReference').value.trim(),
15171645
notes: document.getElementById('paymentNotes').value.trim(),
15181646
});
1647+
// Tied to the payment it proves rather than to the booking in general, so
1648+
// a booking with four payments does not end up with four screenshots in a
1649+
// heap nobody can match up.
1650+
await uploadAttachments('payment', bookingId, saved.payment_id ?? null);
15191651
paymentModalOverlay.hidden = true;
15201652
await refreshAfterBookingChange();
15211653
} catch (err) { showError(err); } finally { btn.disabled = false; }
@@ -1525,6 +1657,7 @@ document.getElementById('paymentForm').addEventListener('submit', async (e) => {
15251657
const depositModalOverlay = document.getElementById('depositModalOverlay');
15261658

15271659
function openDepositModal(bookingId, booking) {
1660+
resetAttachmentBox('deposit');
15281661
document.getElementById('depositForm').reset();
15291662
document.getElementById('depositBookingId').value = bookingId;
15301663
document.getElementById('depositDate').value = todayStr();
@@ -1550,6 +1683,7 @@ document.getElementById('depositForm').addEventListener('submit', async (e) => {
15501683
reference: document.getElementById('depositReference').value.trim(),
15511684
notes: document.getElementById('depositNotes').value.trim(),
15521685
});
1686+
await uploadAttachments('deposit', Number(document.getElementById('depositBookingId').value));
15531687
depositModalOverlay.hidden = true;
15541688
await refreshAfterBookingChange();
15551689
} catch (err) { showError(err); } finally { btn.disabled = false; }
@@ -1566,6 +1700,7 @@ function updateRefundPreview() {
15661700
}
15671701

15681702
function openRefundModal(bookingId, booking) {
1703+
resetAttachmentBox('refund');
15691704
document.getElementById('refundForm').reset();
15701705
document.getElementById('refundBookingId').value = bookingId;
15711706
document.getElementById('refundDate').value = todayStr();
@@ -1593,6 +1728,7 @@ document.getElementById('refundForm').addEventListener('submit', async (e) => {
15931728
reference: document.getElementById('refundReference').value.trim(),
15941729
notes: document.getElementById('refundNotes').value.trim(),
15951730
});
1731+
await uploadAttachments('refund', Number(document.getElementById('refundBookingId').value));
15961732
refundModalOverlay.hidden = true;
15971733
alert(`Refunded ${formatINR(result.refund_amount)}.`);
15981734
await refreshAfterBookingChange();
@@ -1603,6 +1739,7 @@ document.getElementById('refundForm').addEventListener('submit', async (e) => {
16031739
const pickupModalOverlay = document.getElementById('pickupModalOverlay');
16041740

16051741
function openPickupModal(bookingId, booking) {
1742+
resetAttachmentBox('pickup');
16061743
document.getElementById('pickupForm').reset();
16071744
document.getElementById('pickupBookingId').value = bookingId;
16081745
document.getElementById('pickupDateField').value = todayStr();
@@ -1627,6 +1764,7 @@ document.getElementById('pickupForm').addEventListener('submit', async (e) => {
16271764
condition_note: document.getElementById('pickupCondition').value.trim(),
16281765
notes: document.getElementById('pickupNotes').value.trim(),
16291766
});
1767+
await uploadAttachments('pickup', Number(document.getElementById('pickupBookingId').value));
16301768
pickupModalOverlay.hidden = true;
16311769
await refreshAfterBookingChange();
16321770
} catch (err) { showError(err); } finally { btn.disabled = false; }
@@ -1650,6 +1788,7 @@ function updateReturnKmPreview() {
16501788
}
16511789

16521790
function openReturnModal(bookingId, booking) {
1791+
resetAttachmentBox('return');
16531792
document.getElementById('returnForm').reset();
16541793
document.getElementById('returnBookingId').value = bookingId;
16551794
document.getElementById('returnDateField').value = todayStr();
@@ -1678,6 +1817,7 @@ document.getElementById('returnForm').addEventListener('submit', async (e) => {
16781817
condition_note: document.getElementById('returnCondition').value.trim(),
16791818
notes: document.getElementById('returnNotes').value.trim(),
16801819
});
1820+
await uploadAttachments('return', Number(document.getElementById('returnBookingId').value));
16811821
returnModalOverlay.hidden = true;
16821822
await refreshAfterBookingChange();
16831823
} catch (err) { showError(err); } finally { btn.disabled = false; }

‎admin/api.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,23 @@ const api = {
135135
apiRequest('api/bookings.php?action=complete', { method: 'POST', body: { id } }),
136136
},
137137

138+
// Screenshots and photographs hanging off a booking. Multipart, so it goes
139+
// through apiUpload rather than apiRequest -- and several files in one
140+
// request, because the pickup and return boxes take several and five round
141+
// trips over a phone connection is how some of them go missing.
142+
bookingFiles: {
143+
add: (bookingId, kind, files, refId = null) => {
144+
const form = new FormData();
145+
form.append('booking_id', String(bookingId));
146+
form.append('kind', kind);
147+
if (refId !== null) form.append('ref_id', String(refId));
148+
for (const file of files) form.append('files[]', file);
149+
return apiUpload('api/booking-files.php?action=add', form);
150+
},
151+
remove: (id) =>
152+
apiRequest('api/booking-files.php?action=delete', { method: 'POST', body: { id } }),
153+
},
154+
138155
payments: {
139156
add: (payment) =>
140157
apiRequest('api/payments.php?action=add', { method: 'POST', body: payment }),

0 commit comments

Comments
 (0)