Skip to content

Commit b16ee45

Browse files
Merge pull request #67 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
The records a rental business is asked for when something goes wrong
2 parents 10a2e4c + 134c20a commit b16ee45

14 files changed

Lines changed: 1248 additions & 28 deletions

‎admin/admin.css‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,36 @@ 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+
/* The handover checklist. Two columns on anything wider than a phone, because
587+
thirteen checks in one column is a scroll nobody finishes. */
588+
.check-grid {
589+
display: grid; gap: 4px 16px; margin-top: 8px;
590+
/* 165px, not 210: the modal these sit in is about 420px of content wide, and
591+
at 210 the grid silently fell back to one column -- thirteen checks in a
592+
single column is the scroll this was meant to avoid. */
593+
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
594+
}
595+
.check-item {
596+
display: flex; align-items: center; gap: 8px;
597+
font-size: 0.88rem; color: var(--ink-dim);
598+
/* A 44px row, so a thumb hits the label as readily as the box. */
599+
min-height: 34px; cursor: pointer;
600+
}
601+
.check-item input { width: 17px; height: 17px; flex: none; accent-color: var(--primary); }
602+
.check-item:has(input:checked) { color: var(--ink); font-weight: 600; }
603+
604+
/* A document, which may be a PDF and so has no thumbnail to show. */
605+
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
606+
.doc-row {
607+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
608+
border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px;
609+
background: var(--surface);
610+
}
611+
.doc-kind { font-weight: 700; font-size: 0.84rem; }
612+
.doc-meta { color: var(--ink-faint); font-size: 0.8rem; }
613+
.doc-row .doc-actions { margin-left: auto; display: flex; gap: 6px; }
614+
.doc-expired { color: var(--danger); font-weight: 700; }
615+
586616
/* A car that is not ours. Stated on the card rather than only inside the edit
587617
form, because "whose car is this" decides what a booking on it earns and
588618
nobody should have to open a dialog to find out. */

‎admin/admin.js‎

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

18562025
function 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

19052076
function 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) ----
19422173
function renderRentalOverview() {
19432174
const grid = document.getElementById('rentalStatGrid');

‎admin/api.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,35 @@ const api = {
156156
apiRequest('api/booking-files.php?action=delete', { method: 'POST', body: { id } }),
157157
},
158158

159+
// Charges raised after a booking was priced, and the damage behind them.
160+
extras: {
161+
add: (extra) =>
162+
apiRequest('api/booking-extras.php?action=add-extra', { method: 'POST', body: extra }),
163+
void: (id, reason) =>
164+
apiRequest('api/booking-extras.php?action=void-extra', { method: 'POST', body: { id, reason } }),
165+
addDamage: (damage) =>
166+
apiRequest('api/booking-extras.php?action=add-damage', { method: 'POST', body: damage }),
167+
voidDamage: (id) =>
168+
apiRequest('api/booking-extras.php?action=void-damage', { method: 'POST', body: { id } }),
169+
},
170+
171+
// A customer's identity documents. Multipart, and one file at a time --
172+
// these are picked deliberately, one per kind, not dragged in as a batch.
173+
customerFiles: {
174+
list: (customerId) =>
175+
apiRequest(`api/customer-files.php?action=list&customer_id=${customerId}`),
176+
add: (customerId, kind, file, expiresOn = '') => {
177+
const form = new FormData();
178+
form.append('customer_id', String(customerId));
179+
form.append('kind', kind);
180+
if (expiresOn) form.append('expires_on', expiresOn);
181+
form.append('file', file);
182+
return apiUpload('api/customer-files.php?action=add', form);
183+
},
184+
remove: (id) =>
185+
apiRequest('api/customer-files.php?action=delete', { method: 'POST', body: { id } }),
186+
},
187+
159188
payments: {
160189
add: (payment) =>
161190
apiRequest('api/payments.php?action=add', { method: 'POST', body: payment }),

0 commit comments

Comments
 (0)