@@ -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) {
14211426function 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">×</button>
1613+ </span>
1614+ ` ) . join ( '' ) }
1615+ </div>
1616+ ` ;
1617+ }
1618+
14931619// ---- Payment modal ----
14941620const paymentModalOverlay = document . getElementById ( 'paymentModalOverlay' ) ;
14951621
14961622function 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) => {
15251657const depositModalOverlay = document . getElementById ( 'depositModalOverlay' ) ;
15261658
15271659function 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
15681702function 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) => {
16031739const pickupModalOverlay = document . getElementById ( 'pickupModalOverlay' ) ;
16041740
16051741function 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
16521790function 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 ; }
0 commit comments