@@ -1180,6 +1180,11 @@ function openBookingModal(booking) {
11801180 document . getElementById ( 'bkPhone' ) . value = booking . customer_phone || '' ;
11811181 document . getElementById ( 'bkAddress' ) . value = booking . customer_address || '' ;
11821182 document . getElementById ( 'bkLicence' ) . value = booking . licence_number || '' ;
1183+ document . getElementById ( 'bkWhatsapp' ) . value = booking . whatsapp || '' ;
1184+ document . getElementById ( 'bkLicenceExpiry' ) . value = booking . licence_expiry || '' ;
1185+ document . getElementById ( 'bkIdNumber' ) . value = booking . id_number || '' ;
1186+ document . getElementById ( 'bkCustomerType' ) . value = booking . customer_type || 'New' ;
1187+ document . getElementById ( 'bkEstimatedKm' ) . value = booking . estimated_km ?? '' ;
11831188 document . getElementById ( 'bkStartDate' ) . value = sd ;
11841189 document . getElementById ( 'bkStartTime' ) . value = st ;
11851190 document . getElementById ( 'bkReturnDate' ) . value = rd ;
@@ -1248,6 +1253,11 @@ bookingForm.addEventListener('submit', async (e) => {
12481253 phone : document . getElementById ( 'bkPhone' ) . value . trim ( ) ,
12491254 address : document . getElementById ( 'bkAddress' ) . value . trim ( ) ,
12501255 licence_number : document . getElementById ( 'bkLicence' ) . value . trim ( ) ,
1256+ whatsapp : document . getElementById ( 'bkWhatsapp' ) . value . trim ( ) ,
1257+ licence_expiry : document . getElementById ( 'bkLicenceExpiry' ) . value ,
1258+ id_number : document . getElementById ( 'bkIdNumber' ) . value . trim ( ) ,
1259+ customer_type : document . getElementById ( 'bkCustomerType' ) . value ,
1260+ estimated_km : document . getElementById ( 'bkEstimatedKm' ) . value ,
12511261 vehicle_id : Number ( bookingVehicleSelect . value ) ,
12521262 start_at : startAt ,
12531263 return_at : returnAt ,
@@ -1420,6 +1430,8 @@ async function renderBookingDetail(id) {
14201430 <div class="detail-field"><span class="k">Extra KM (${ Number ( booking . extra_km || 0 ) . toLocaleString ( 'en-IN' ) } km)</span><span class="v">+ ${ formatINR ( booking . extra_km_charge ) } </span></div>` : '' }
14211431 ${ Number ( charges . other_charges ) ? `
14221432 <div class="detail-field"><span class="k">Other Charges</span><span class="v">+ ${ formatINR ( charges . other_charges ) } </span></div>` : '' }
1433+ ${ ( booking . extras || [ ] ) . map ( ( x ) => `
1434+ <div class="detail-field"><span class="k">${ escapeHTML ( x . label ) } ${ x . note ? ' — ' + escapeHTML ( x . note ) : '' } </span><span class="v">+ ${ formatINR ( x . amount ) } </span></div>` ) . join ( '' ) }
14231435 ${ Number ( charges . discount ) ? `
14241436 <div class="detail-field"><span class="k">Discount</span><span class="v">- ${ formatINR ( charges . discount ) } </span></div>` : '' }
14251437 <div class="detail-field"><span class="k">Rental Amount Due</span><span class="v">${ formatINR ( booking . total ) } </span></div>
@@ -1486,6 +1498,7 @@ async function renderBookingDetail(id) {
14861498 <div class="detail-field"><span class="k">Fuel Level</span><span class="v">${ booking . pickup . fuel_level || '—' } </span></div>
14871499 <div class="detail-field"><span class="k">Condition</span><span class="v">${ booking . pickup . condition_note || '—' } </span></div>
14881500 </div>
1501+ ${ checklistHTML ( booking . pickup . checklist ) }
14891502 ${ open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${ booking . pickup . id } " data-current="${ booking . pickup . odometer_km } ">Correct reading</button>` : '' }
14901503 ` : '<p class="detail-empty">Not recorded yet.</p>' }
14911504 ${ attachmentsHTML ( booking . files , 'pickup' , 'Pickup photos' ) }
@@ -1510,11 +1523,34 @@ async function renderBookingDetail(id) {
15101523 <div class="detail-field"><span class="k">Extra KM</span><span class="v">${ Number ( km . extra_km || 0 ) . toLocaleString ( 'en-IN' ) } </span></div>
15111524 <div class="detail-field"><span class="k">Extra KM Charge</span><span class="v">${ formatINR ( km . extra_km_charge || 0 ) } </span></div>
15121525 </div>
1526+ ${ checklistHTML ( booking . return . checklist ) }
15131527 ${ open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${ booking . return . id } " data-current="${ booking . return . odometer_km } ">Correct reading</button>` : '' }
15141528 ` : `<p class="detail-empty">${ booking . pickup ? 'Not recorded yet.' : 'Record pickup first.' } </p>` }
15151529 ${ attachmentsHTML ( booking . files , 'return' , 'Return photos' ) }
15161530 </div>
15171531
1532+ <div class="detail-section">
1533+ <div class="detail-section-title">
1534+ <span>Customer Documents</span>
1535+ <button class="btn btn-outline btn-sm" id="detailAddDocBtn">+ Add Document</button>
1536+ </div>
1537+ ${ documentsHTML ( booking . documents ) }
1538+ </div>
1539+
1540+ ${ ( booking . damages || [ ] ) . length ? `
1541+ <div class="detail-section">
1542+ <div class="detail-section-title"><span>Damage</span></div>
1543+ ${ booking . damages . map ( ( d ) => `
1544+ <div class="payment-row">
1545+ <span class="payment-meta">${ escapeHTML ( d . description ) }
1546+ <span class="payment-note">noticed at ${ d . noticed_at } ${ d . note ? ' · ' + escapeHTML ( d . note ) : '' } </span>
1547+ </span>
1548+ <span class="amount">${ formatINR ( d . estimated_cost ) } </span>
1549+ ${ open ? `<button class="btn btn-ghost btn-sm void-damage" data-id="${ d . id } ">Remove</button>` : '' }
1550+ </div>` ) . join ( '' ) }
1551+ ${ attachmentsHTML ( booking . files , 'damage' , 'Damage photos' ) }
1552+ </div>` : '' }
1553+
15181554 <div class="detail-section">
15191555 <div class="detail-section-title"><span>Booking Timeline</span></div>
15201556 ${ timelineHTML ( booking . timeline ) }
@@ -1553,6 +1589,57 @@ function wireDetailActions(booking) {
15531589 } catch ( err ) { showError ( err ) ; }
15541590 } ) ;
15551591
1592+ // Adding a document. A hidden input rather than a modal: one file, one
1593+ // kind, and a dialog around that is more clicks than the job needs.
1594+ on ( 'detailAddDocBtn' , ( ) => {
1595+ if ( ! booking . customer_id ) { alert ( 'This booking has no customer record yet.' ) ; return ; }
1596+ const kind = prompt (
1597+ 'Which document?\n\n' + DOCUMENT_KINDS . map ( ( [ k , l ] ) => `${ k } — ${ l } ` ) . join ( '\n' ) ,
1598+ 'licence' ,
1599+ ) ;
1600+ if ( kind === null ) return ;
1601+ if ( ! DOCUMENT_KINDS . some ( ( [ k ] ) => k === kind . trim ( ) ) ) {
1602+ alert ( 'That is not one of the documents this keeps.' ) ;
1603+ return ;
1604+ }
1605+
1606+ const expiry = kind . trim ( ) === 'licence'
1607+ ? ( prompt ( 'Licence expiry date (YYYY-MM-DD). Leave blank if you do not have it.' , '' ) || '' )
1608+ : '' ;
1609+
1610+ const picker = document . createElement ( 'input' ) ;
1611+ picker . type = 'file' ;
1612+ picker . accept = 'image/jpeg,image/png,image/webp,image/avif,application/pdf' ;
1613+ picker . addEventListener ( 'change' , async ( ) => {
1614+ if ( ! picker . files || picker . files . length === 0 ) return ;
1615+ try {
1616+ await api . customerFiles . add ( booking . customer_id , kind . trim ( ) , picker . files [ 0 ] , expiry . trim ( ) ) ;
1617+ await renderBookingDetail ( booking . id ) ;
1618+ } catch ( err ) { showError ( err ) ; }
1619+ } ) ;
1620+ picker . click ( ) ;
1621+ } ) ;
1622+
1623+ document . querySelectorAll ( '.remove-doc' ) . forEach ( ( btn ) => {
1624+ btn . addEventListener ( 'click' , async ( ) => {
1625+ if ( ! confirm ( 'Remove this document? It cannot be brought back.' ) ) return ;
1626+ try {
1627+ await api . customerFiles . remove ( Number ( btn . dataset . id ) ) ;
1628+ await renderBookingDetail ( booking . id ) ;
1629+ } catch ( err ) { showError ( err ) ; }
1630+ } ) ;
1631+ } ) ;
1632+
1633+ document . querySelectorAll ( '.void-damage' ) . forEach ( ( btn ) => {
1634+ btn . addEventListener ( 'click' , async ( ) => {
1635+ if ( ! confirm ( 'Remove this damage record? Any charge raised for it stays — void that separately.' ) ) return ;
1636+ try {
1637+ await api . extras . voidDamage ( Number ( btn . dataset . id ) ) ;
1638+ await refreshAfterBookingChange ( ) ;
1639+ } catch ( err ) { showError ( err ) ; }
1640+ } ) ;
1641+ } ) ;
1642+
15561643 on ( 'detailDeleteBtn' , async ( ) => {
15571644 if ( ! confirm (
15581645 `Delete ${ booking . booking_number } for good?\n\n` +
@@ -1623,6 +1710,88 @@ function nowTimeStr() {
16231710 return new Date ( ) . toTimeString ( ) . slice ( 0 , 5 ) ;
16241711}
16251712
1713+ // ---- Customer documents ----
1714+ //
1715+ // A licence is commonly a PDF, which has no thumbnail to show, so these are a
1716+ // list of rows rather than the strip of pictures the booking attachments use.
1717+ // The row says what it is, whether it has expired, and gives a link that
1718+ // opens it.
1719+ const DOCUMENT_KINDS = [
1720+ [ 'licence' , 'Driving licence' ] ,
1721+ [ 'id' , 'Aadhaar / ID' ] ,
1722+ [ 'passport' , 'Passport' ] ,
1723+ [ 'other' , 'Other document' ] ,
1724+ ] ;
1725+
1726+ function documentsHTML ( documents ) {
1727+ const held = documents || [ ] ;
1728+ if ( held . length === 0 ) {
1729+ return '<p class="detail-empty">No documents on file for this customer yet.</p>' ;
1730+ }
1731+
1732+ const today = todayStr ( ) ;
1733+ return `
1734+ <div class="doc-list">
1735+ ${ held . map ( ( d ) => {
1736+ const expired = d . expires_on && d . expires_on < today ;
1737+ return `
1738+ <div class="doc-row">
1739+ <span class="doc-kind">${ escapeHTML ( d . label ) } </span>
1740+ <span class="doc-meta">${ d . is_pdf ? 'PDF' : 'Image' } ${ d . caption ? ' · ' + escapeHTML ( d . caption ) : '' } </span>
1741+ ${ d . expires_on ? `<span class="doc-meta ${ expired ? 'doc-expired' : '' } ">
1742+ ${ expired ? 'Expired' : 'Expires' } ${ formatDate ( d . expires_on ) } </span>` : '' }
1743+ <span class="doc-actions">
1744+ <a class="btn btn-ghost btn-sm" href="${ d . url } " target="_blank" rel="noopener">Open</a>
1745+ <button class="btn btn-ghost btn-sm remove-doc" data-id="${ d . id } ">Remove</button>
1746+ </span>
1747+ </div>` ;
1748+ } ) . join ( '' ) }
1749+ </div>` ;
1750+ }
1751+
1752+ // ---- The handover checklist ----
1753+ //
1754+ // Read out of the boxes rather than kept in a variable, so what is sent is
1755+ // exactly what is on screen. Unticked is sent as false rather than omitted:
1756+ // "not checked" and "checked and wrong" are different things, and only the
1757+ // first is what a blank means here.
1758+ function readChecklist ( prefix ) {
1759+ const out = { } ;
1760+ for ( const box of document . querySelectorAll ( `#${ prefix } Checklist [data-check]` ) ) {
1761+ out [ box . dataset . check ] = box . checked ;
1762+ }
1763+ return out ;
1764+ }
1765+
1766+ function clearChecklist ( prefix ) {
1767+ for ( const box of document . querySelectorAll ( `#${ prefix } Checklist [data-check]` ) ) {
1768+ box . checked = false ;
1769+ }
1770+ }
1771+
1772+ /** The checklist as it was recorded, for the booking detail. */
1773+ function checklistHTML ( checklist ) {
1774+ if ( ! checklist || typeof checklist !== 'object' ) return '' ;
1775+ const items = Object . entries ( checklist ) ;
1776+ if ( items . length === 0 ) return '' ;
1777+
1778+ const label = ( key ) => {
1779+ const box = document . querySelector ( `[data-check="${ key } "]` ) ;
1780+ return box ? box . parentElement . textContent . trim ( ) : key ;
1781+ } ;
1782+ const checked = items . filter ( ( [ , v ] ) => v ) ;
1783+
1784+ return `
1785+ <p class="modal-section-label">Checked at handover (${ checked . length } of ${ items . length } )</p>
1786+ <div class="check-grid">
1787+ ${ items . map ( ( [ key , value ] ) => `
1788+ <span class="check-item" style="cursor:default">
1789+ <span aria-hidden="true">${ value ? '\u2713' : '\u2014' } </span>
1790+ ${ escapeHTML ( label ( key ) ) }
1791+ </span>` ) . join ( '' ) }
1792+ </div>` ;
1793+ }
1794+
16261795// ---- Booking attachments ----
16271796//
16281797// The five file boxes -- payment screenshot, deposit proof, refund proof,
@@ -1855,6 +2024,7 @@ const pickupModalOverlay = document.getElementById('pickupModalOverlay');
18552024
18562025function openPickupModal ( bookingId , booking ) {
18572026 resetAttachmentBox ( 'pickup' ) ;
2027+ clearChecklist ( 'pickup' ) ;
18582028 document . getElementById ( 'pickupForm' ) . reset ( ) ;
18592029 document . getElementById ( 'pickupBookingId' ) . value = bookingId ;
18602030 document . getElementById ( 'pickupDateField' ) . value = todayStr ( ) ;
@@ -1878,6 +2048,7 @@ document.getElementById('pickupForm').addEventListener('submit', async (e) => {
18782048 fuel_level : document . getElementById ( 'pickupFuelLevel' ) . value ,
18792049 condition_note : document . getElementById ( 'pickupCondition' ) . value . trim ( ) ,
18802050 notes : document . getElementById ( 'pickupNotes' ) . value . trim ( ) ,
2051+ checklist : readChecklist ( 'pickup' ) ,
18812052 } ) ;
18822053 await uploadAttachments ( 'pickup' , Number ( document . getElementById ( 'pickupBookingId' ) . value ) ) ;
18832054 pickupModalOverlay . hidden = true ;
@@ -1904,6 +2075,7 @@ function updateReturnKmPreview() {
19042075
19052076function openReturnModal ( bookingId , booking ) {
19062077 resetAttachmentBox ( 'return' ) ;
2078+ clearChecklist ( 'return' ) ;
19072079 document . getElementById ( 'returnForm' ) . reset ( ) ;
19082080 document . getElementById ( 'returnBookingId' ) . value = bookingId ;
19092081 document . getElementById ( 'returnDateField' ) . value = todayStr ( ) ;
@@ -1931,13 +2103,72 @@ document.getElementById('returnForm').addEventListener('submit', async (e) => {
19312103 fuel_level : document . getElementById ( 'returnFuelLevel' ) . value ,
19322104 condition_note : document . getElementById ( 'returnCondition' ) . value . trim ( ) ,
19332105 notes : document . getElementById ( 'returnNotes' ) . value . trim ( ) ,
2106+ checklist : readChecklist ( 'return' ) ,
19342107 } ) ;
2108+
2109+ // After the reading, not with it: the charges hang off a booking that has
2110+ // been returned, and a refused charge must not cost someone the reading
2111+ // they just took.
2112+ await raiseReturnCharges ( Number ( document . getElementById ( 'returnBookingId' ) . value ) ) ;
19352113 await uploadAttachments ( 'return' , Number ( document . getElementById ( 'returnBookingId' ) . value ) ) ;
19362114 returnModalOverlay . hidden = true ;
19372115 await refreshAfterBookingChange ( ) ;
19382116 } catch ( err ) { showError ( err ) ; } finally { btn . disabled = false ; }
19392117} ) ;
19402118
2119+ /**
2120+ * Turns the boxes on the return form into charges and a damage record.
2121+ *
2122+ * Each on its own line rather than one lump, because a customer does not
2123+ * accept "other charges" and nobody can explain one a month later.
2124+ *
2125+ * Every failure is reported and none of them stops the rest: a rejected
2126+ * cleaning charge should not take the fuel charge down with it, and the return
2127+ * itself is already saved by the time this runs.
2128+ */
2129+ async function raiseReturnCharges ( bookingId ) {
2130+ const lines = [
2131+ [ 'cleaning' , 'returnCleaning' , 'Cleaning on return' ] ,
2132+ [ 'fuel' , 'returnFuel' , 'Fuel on return' ] ,
2133+ [ 'late' , 'returnLate' , 'Late return' ] ,
2134+ [ 'other' , 'returnOther' , document . getElementById ( 'returnOtherNote' ) . value . trim ( ) ] ,
2135+ ] ;
2136+
2137+ const problems = [ ] ;
2138+
2139+ for ( const [ kind , field , note ] of lines ) {
2140+ const amount = Number ( document . getElementById ( field ) . value ) || 0 ;
2141+ if ( amount <= 0 ) continue ;
2142+ if ( kind === 'other' && note === '' ) {
2143+ problems . push ( 'The other charge needs a line saying what it is for, so it was not added.' ) ;
2144+ continue ;
2145+ }
2146+ try {
2147+ await api . extras . add ( { booking_id : bookingId , kind, amount, note } ) ;
2148+ } catch ( err ) { problems . push ( `${ kind } : ${ err . message } ` ) ; }
2149+ }
2150+
2151+ const damage = document . getElementById ( 'returnDamage' ) . value . trim ( ) ;
2152+ if ( damage !== '' ) {
2153+ try {
2154+ await api . extras . addDamage ( {
2155+ booking_id : bookingId ,
2156+ description : damage ,
2157+ estimated_cost : document . getElementById ( 'returnDamageCost' ) . value || '0' ,
2158+ noticed_at : 'return' ,
2159+ // Recording damage and billing for it are separate decisions. Some
2160+ // comes off the deposit, some is absorbed, and assuming the first
2161+ // would make the other two wrong.
2162+ charge_customer : document . getElementById ( 'returnDamageCharge' ) . checked ,
2163+ } ) ;
2164+ } catch ( err ) { problems . push ( `damage: ${ err . message } ` ) ; }
2165+ }
2166+
2167+ if ( problems . length ) {
2168+ alert ( 'The return was saved. These did not go through:\n\n' + problems . join ( '\n' ) ) ;
2169+ }
2170+ }
2171+
19412172// ---- Dashboard: Rental Overview (point 9) ----
19422173function renderRentalOverview ( ) {
19432174 const grid = document . getElementById ( 'rentalStatGrid' ) ;
0 commit comments