Skip to content

Commit 52e9764

Browse files
committed
Keep the screenshots the panel has always asked for
Five boxes in the booking screens take a file: Payment Screenshot / Receipt, Deposit Proof, Refund Proof, Pickup Photos, Return Photos. They have been there since those screens were built and were wired to nothing at all. No code read the input, there was no table to put a file in, and nothing anywhere displayed one. Choosing a screenshot and pressing Save recorded the payment and dropped the file on the floor, silently -- which from the outside is indistinguishable from it having worked. Three things were missing and all three are here now: a preview so you can see what you picked, the upload itself, and somewhere on the booking for them to appear afterwards. booking_files, one row per file. kind says which box it came from, and ref_id ties a payment screenshot to the payment it proves rather than to the booking in general -- four payments would otherwise leave four screenshots in a heap nobody can match up; api/booking-files.php takes several files in one request, because the pickup and return boxes accept several and five round trips over a phone connection is how some of them go missing. A file refused for being the wrong sort is reported without losing the others; the detail screen shows each kind under the section it belongs to, click to open full size, with a remove button on the corner of each. The upload runs after the record it belongs to is saved, not with it. A booking_files row points at a booking, and it also means a refused image never costs someone the payment they just typed in: that is already saved, and the file problem is reported on its own. These are not like the other images on this site, and the reader is where that shows. brand.php and photo.php are deliberately public -- a vehicle photograph is an advertisement. A payment screenshot carries a customer's name, their bank and an amount; a pickup photograph shows a registration plate. So booking-file.php requires a signed-in user, sends Cache-Control: private, no-store so nothing in the middle keeps a copy, and the names are random rather than descriptive -- a file name should not read "advance-payment-10000". A random name is not access control; the session is. Verified rather than assumed. Signed out, the reader answers 302 and zero bytes. Signed in, the image and no more. Signed in with a name that is not ours -- a traversal, a .php, a .jpg.php, a short hex, an uppercase one -- 404 every time. In the panel: five inputs wired, two files chosen give two previews with the right names, reopening the modal clears them, the detail screen groups by kind and renders nothing for a kind with no files, and a caption containing <img src=x onerror=...> comes out as text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
1 parent 3f39bf2 commit 52e9764

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)