Quick actions
Recent activity
All →
All
Active
Blocked
Gold
Silver
Bronze
Tap a client to view profile
← Clients
Vehicles
View all →
Service history
Active promo for Gold members
15% off next full service
Valid until 31 May 2026
Outstanding balance
← Profile
Vehicles
Look up vehicle by VIN or plate
Stock list
Filter ▾
Add / edit item
Alert triggers when stock falls below this number.
Invoices
⭐ Gold Member
Pingarage Member
PG-0001 · LOYALTY CARD
0
Points
0
Visits
0%
Discount
Progress to next tier 0%
View member card
Gold
Platinum
Silver
Bronze
Tier Benefits
Tier upgrade path
Recent points activity
Select a client from the clients screen to view their points history.
Finance
Month total
Collected
Outstanding
Open invoices
Paid vs outstanding
Collected
Unpaid
Outstanding aging
Top services this month
Expenses by Category
No expenses this month.
Promos & Banners
Create new promo
Service Price List
Base prices are admin-only. On invoices, prices can be adjusted per job.
All
Services
Parts
Loading…
Available
Pending
Confirmed
Blocked
Loading schedule…
Loading…
Available — tap to book
Taken
My booking
Loading schedule…
Pingarage
Bronze
Member
PG-000000 · LOYALTY
Progress to next tier 0%
My Vehicles
Recent Services
`).join('')); } else { html('profile-history', '
No service history yet.
'); } // Load vehicles for this client const vehicles = await GET(`vehicles.php?client_id=${c.id}`); if (vehicles && vehicles.length) { html('profile-vehicles-list', vehicles.map(v=>`
${v.make} ${v.model}${v.license_plate?' · '+v.license_plate:''}
${v.year||''} ${v.fuel_type||''}${v.vin?' · '+v.vin:''}
`).join('')); } else { html('profile-vehicles-list', `
No vehicles yet.
`); } } /* ── VEHICLES ── */ async function renderVehicles() { const c = state.currentClient; if (c) html('vehicles-client-name', `${c.name}'s Vehicles`); html('vehicles-list', skeleton(2)); const url = c ? `vehicles.php?client_id=${c.id}` : 'vehicles.php'; const result = await GET(url); if (!result) return; html('vehicles-list', (result.length ? result.map(v=>`
${v.make} ${v.model} · ${v.license_plate||'—'}
VIN: ${v.vin||'N/A'} · ${v.year||'—'} · ${v.fuel_type}
`).join('') : `
No vehicles
`) + `
`); } /* ── SERVICES PRICE LIST ── */ async function renderServices() { html('services-list', skeleton(4)); const catFilter = document.querySelector('#service-cat-chips .chip.active')?.dataset.cat || 'all'; // Load categories for dropdown const cats = await GET('services.php?action=categories'); if (cats) { const sel = el('svc-category'); if (sel) { sel.innerHTML = '' + cats.filter(c => catFilter === 'all' || c.type === catFilter || catFilter === 'service' || catFilter === 'part') .map(c => ``).join(''); } } const services = await GET('services.php'); if (!services) return; html('services-list', services.length ? services.map(s => `
${s.name}
${s.category_name ? `
${s.category_name}${s.duration_mins?' · '+s.duration_mins+' min':''}
` : ''} ${s.description ? `
${s.description}
` : ''}
MVR ${fmt(s.default_price)}
`).join('') : `
🔧
No services yet
Add your first service with a base price.
`); } function filterServices(cat) { document.querySelectorAll('#service-cat-chips .chip').forEach(c => c.classList.toggle('active', c.dataset.cat === cat)); renderServices(); } function editService(id, name, price, catId, duration, desc) { el('svc-edit-id').value = id; el('svc-name').value = name; el('svc-price').value = price; el('svc-duration').value = duration; el('svc-desc').value = desc; el('svc-modal-title').textContent = 'Edit service'; // Set category const sel = el('svc-category'); if (sel) sel.value = catId; showModal('add-service-price-modal'); } async function saveServicePrice() { const id = el('svc-edit-id')?.value; const name = el('svc-name')?.value.trim(); const price = parseFloat(el('svc-price')?.value || 0); const catId = el('svc-category')?.value || null; const duration = parseInt(el('svc-duration')?.value || 60); const desc = el('svc-desc')?.value.trim(); if (!name) { showInputError('svc-name', 'Name is required'); return; } if (!price) { showInputError('svc-price', 'Enter a base price'); return; } const body = { name, default_price: price, category_id: catId||null, duration_mins: duration, description: desc||null }; const result = id ? await PUT(`services.php?id=${id}`, body) : await POST('services.php', body); if (result) { closeModal('add-service-price-modal'); // Reset form el('svc-edit-id').value = ''; el('svc-name').value = ''; el('svc-price').value = ''; el('svc-duration').value = ''; el('svc-desc').value = ''; el('svc-modal-title').textContent = 'Add service / part'; showToast(id ? 'Service updated ✓' : 'Service added ✓'); renderServices(); } } async function deleteService(id, name) { if (!confirm('Delete "' + name + '"?')) return; const r = await DELETE(`services.php?id=${id}`); if (r) { showToast('Service deleted'); renderServices(); } } function escJs(str) { if (!str) return ''; return String(str).replace(/'/g, "\\'").replace(/"/g, '\\"'); } /* ── SEARCH VEHICLE ── */ async function searchVehicle() { const q = el('vin-search-input')?.value.trim(); if (!q) return; html('vin-search-result', skeleton(1)); const result = await GET(`vehicles.php?search=${encodeURIComponent(q)}`); if (result && result.length) { html('vin-search-result', result.map(v=>`
${v.make} ${v.model} · ${v.license_plate||'—'}
${v.client_name||'—'} · ${v.vin||'—'}
`).join('')); } else { html('vin-search-result', '
No vehicles found.
'); } } /* ── INVENTORY ── */ async function renderInventory() { html('inv-list', skeleton(4)); const [items, lowStock] = await Promise.all([GET('inventory.php'), GET('inventory.php?action=low_stock')]); if (!items) return; state.inventory = items; html('inv-alert', lowStock?.length ? `
${lowStock.length} item${lowStock.length>1?'s':''} need restocking
Review
` : ''); html('inv-list', items.length ? items.map(i=>`
${(i.category_name||'GEN').substring(0,3).toUpperCase()}
${i.name}
SKU: ${i.sku||'—'} · ${i.quantity} ${i.unit}
${statusBadge(i.stock_status)}
`).join('') : `
No items yet
`); } /* ── INVOICES ── */ async function renderInvoices() { html('inv-rows', skeleton(3)); const result = await GET('invoices.php?limit=20'); if (!result) return; state.invoices = result.invoices||[]; html('inv-rows', state.invoices.length ? state.invoices.map(i=>`
#${i.invoice_number}
${i.client_name||'—'} · ${formatDate(i.created_at)}
${statusBadge(i.status)}
MVR ${fmt(i.total_amount)}
${i.status!=='paid'?``:''} ${''}
`).join('') : `
No invoices yet
`); } /* ── LOYALTY ── */ async function renderLoyalty() { const tiers = await GET('loyalty.php?action=tiers'); const tierName = 'Gold'; updateLoyaltyCard(tierName, 'Pingarage Member', 2450, 18, 15, 82); if (tiers) { html('loyalty-tier-preview', tiers.map(t=>`
${t.name}
${fmt(t.min_points)}+ pts · ${t.discount_pct}% off labour
${t.discount_pct}% off
`).join('')); } renderBenefitsView(tierName); } function tierGrad(tier) { return {Bronze:'linear-gradient(135deg,#7B4F2E,#C49A6C)',Silver:'linear-gradient(135deg,#4A5568,#A0AEC0)',Gold:'linear-gradient(135deg,#744210,#ECC94B)',Platinum:'linear-gradient(135deg,#1a1a2e,#6C63FF)'}[tier]||'var(--teal-light)'; } function tierClass(tier) { return {Bronze:'lc-bronze',Silver:'lc-silver',Gold:'lc-gold',Platinum:'lc-platinum'}[tier]||'lc-gold'; } function updateLoyaltyCard(tier, name, points, visits, discount, pct) { const card = el('loyalty-card-el'); if (card) { card.className = `loyalty-card-inner ${tierClass(tier)}`; } html('lc-tier-badge', `⭐ ${tier} Member`); html('lc-name', name); html('lc-id', `PG-0001 · LOYALTY CARD`); html('lc-points', fmt(points)); html('lc-visits', visits); html('lc-discount', discount + '%'); const fill = el('lc-prog-fill'); if (fill) setTimeout(()=>fill.style.width = pct+'%', 100); html('lc-prog-pct', pct+'%'); } function previewTier(tier) { document.querySelectorAll('#loyalty-tier-chips .chip').forEach(c=>c.classList.toggle('active', c.dataset.tier===tier)); const demos = { Bronze: {pts:200, vis:2, disc:0, pct:20}, Silver: {pts:1200, vis:8, disc:5, pct:50}, Gold: {pts:2450, vis:18, disc:15, pct:82}, Platinum: {pts:3500, vis:30, disc:20, pct:100}, }; const d = demos[tier]||demos.Gold; updateLoyaltyCard(tier, 'Pingarage Member', d.pts, d.vis, d.disc, d.pct); renderBenefitsView(tier); } function renderBenefitsView(tier) { const benefits = state.tierBenefits[tier] || []; html('benefits-view', benefits.map(b=>`
${b}
`).join('')); } function toggleBenefitsEdit() { state.editingBenefits = !state.editingBenefits; const view = el('benefits-view'); const edit = el('benefits-edit'); const btn = el('benefits-edit-btn'); if (state.editingBenefits) { // Get current tier from active chip const activeTier = document.querySelector('#loyalty-tier-chips .chip.active')?.dataset.tier || 'Gold'; const benefits = state.tierBenefits[activeTier] || []; view.style.display = 'none'; edit.style.display = 'block'; btn.textContent = 'Cancel'; html('benefits-edit-rows', benefits.map((b,i)=>`
`).join('')); } else { cancelBenefitsEdit(); } } function cancelBenefitsEdit() { state.editingBenefits = false; el('benefits-view').style.display = 'block'; el('benefits-edit').style.display = 'none'; el('benefits-edit-btn').textContent = 'Edit'; } function addBenefitRow() { const container = el('benefits-edit-rows'); const div = document.createElement('div'); div.className = 'benefit-edit-row'; div.innerHTML = `
`; container.appendChild(div); div.querySelector('input').focus(); } function saveBenefits() { const activeTier = document.querySelector('#loyalty-tier-chips .chip.active')?.dataset.tier || 'Gold'; const inputs = document.querySelectorAll('#benefits-edit-rows .benefit-edit-input'); const benefits = Array.from(inputs).map(i=>i.value.trim()).filter(Boolean); state.tierBenefits[activeTier] = benefits; cancelBenefitsEdit(); renderBenefitsView(activeTier); showToast(`${activeTier} tier benefits saved`); // TODO: persist to DB via API when backend endpoint is ready } /* ── FINANCE ── */ async function renderFinance() { const [stats,agingRaw,services,trend,expBreakdown] = await Promise.all([ GET(`finance.php?action=summary&month=${state.calMonth+1}&year=${state.calYear}`), GET('finance.php?action=aging'), GET(`finance.php?action=top_services&month=${state.calMonth+1}&year=${state.calYear}`), GET('finance.php?action=trend&months=6'), GET(`finance.php?action=expense_breakdown&month=${state.calMonth+1}&year=${state.calYear}`), ]); if (!stats) return; const netProfit = stats.net_profit||(stats.collected-stats.expenses); const profColor = netProfit>=0?'':'background:linear-gradient(135deg,#7f1d1d,#b91c1c);'; if(el('fin-profit-card')) html('fin-profit-card',`
Net Profit
MVR ${fmt(netProfit)}
${stats.revenue_change_pct!=null?`
${stats.revenue_change_pct>=0?'▲':'▼'} ${Math.abs(stats.revenue_change_pct)}% vs last month
`:''}
Collection
${stats.collection_rate}%
`); html('fin-stat-total',`MVR ${fmt(stats.month_total)}`); html('fin-stat-coll', `MVR ${fmt(stats.collected)}`); html('fin-stat-out', `MVR ${fmt(stats.outstanding)}`); html('fin-stat-open', stats.open_invoices); const paidPct=stats.month_total>0?(stats.collected/stats.month_total*100):0; const pf=el('fin-paid-fill'); if(pf){pf.style.width=paidPct+'%';pf.style.background='var(--teal)';} const uf=el('fin-unpaid-fill'); if(uf){uf.style.width=(100-paidPct)+'%';uf.style.background='var(--red)';} if(trend?.length&&el('fin-trend-chart')){ const maxR=Math.max(...trend.map(t=>parseFloat(t.revenue)||0),1); html('fin-trend-chart',`
${trend.map(t=>`
`).join('')}
${trend.map(t=>`
${(t.label||t.period).split(' ')[0]}
`).join('')}
`); } const aging=agingRaw?.buckets||agingRaw; if(aging) html('fin-aging',Object.entries(aging).map(([k,v])=>`
${v.label}
${v.count} invoice${v.count!==1?'s':''}
${statusBadge(k==='0_30'?'ok':k==='31_60'?'pending':'overdue')}
MVR ${fmt(v.amount)}
`).join('')); if(services?.length){const mx=Math.max(...services.map(s=>s.total_revenue),1);html('fin-top-services',services.map(s=>`
${s.name||'Other'}
MVR ${fmt(s.total_revenue)}
`).join(''));} if(expBreakdown?.breakdown?.length&&el('fin-expense-breakdown')){html('fin-expense-breakdown',`
${expBreakdown.breakdown.map(e=>`
${e.icon||'📦'}
${e.category}
MVR ${fmt(e.total)}
${e.pct}%
`).join('')}
TotalMVR ${fmt(expBreakdown.grand_total)}
`);} } /* ── PROMOS ── */ async function renderPromos() { html('promos-list', skeleton(2)); const promos = await GET('promotions.php'); if (!promos) return; state.promos = promos; html('promos-list', promos.length ? promos.map(p=>`
${p.discount_value}${p.discount_type==='percentage'?'%':' MVR'} OFF
${p.title}
Valid: ${formatDate(p.start_date)} – ${formatDate(p.end_date)} · ${p.tier}
${p.is_active?'Live':'Off'}
${p.is_active?'Live':'Inactive'}
`).join('') : `
No promos yet
Create your first promotion below.
`); } function previewPromoImage(input) { const file = input.files[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) { showToast('Image too large — max 2MB', true); return; } const reader = new FileReader(); reader.onload = e => { state.promoImageData = e.target.result; const preview = el('np-banner-preview'); const bg = el('np-preview-bg'); if (preview) preview.style.display = 'block'; if (bg) bg.style = `position:absolute;inset:0;background-image:url('${e.target.result}');background-size:cover;background-position:center;`; // Update preview text live updatePromoPreview(); // Show thumbnail in upload zone const zone = el('np-img-preview'); if (zone) zone.innerHTML = `Image selected ✓`; }; reader.readAsDataURL(file); } function updatePromoPreview() { const title = el('np-title')?.value||'Promo title'; const val = el('np-dval')?.value||'0'; const dtype = el('np-dtype')?.value||'percentage'; const start = el('np-start')?.value; const end = el('np-end')?.value; html('np-preview-discount', `${val}${dtype==='percentage'?'%':' MVR'} OFF`); html('np-preview-title', title); html('np-preview-validity', `${start?formatDate(start):'—'} – ${end?formatDate(end):'—'}`); const preview = el('np-banner-preview'); if (preview && !state.promoImageData) { preview.style.display = (title.length>1||val>0)?'block':'none'; const bg = el('np-preview-bg'); if (bg && !state.promoImageData) bg.style = 'position:absolute;inset:0;background:linear-gradient(120deg,#185FA5,#378ADD);'; } } /* ── APPOINTMENTS ── */ async function renderBookingGrid() { html('appts-pending', skeleton(2)); const [pending, confirmed] = await Promise.all([ GET('appointments.php?status=pending&limit=20'), GET(`appointments.php?status=confirmed&date=${todayStr()}`), ]); html('appts-pending-count', pending?.length?`${pending.length} new`:''); html('appts-pending', pending?.length ? pending.map(a=>`
${formatTime(a.time_from)}
${ampm(a.time_from)}
${formatDate(a.appointment_date)}
${a.client_name||'Walk-in'}
${a.service_names||a.notes||'—'}
`).join('') : `
No pending requests
`); html('appts-confirmed', confirmed?.length ? confirmed.map(a=>`
${formatTime(a.time_from)}
${ampm(a.time_from)}
${a.client_name||'Walk-in'}
${a.service_names||a.notes||'—'}
${statusBadge('confirmed')}
`).join('') : ''); } /* ── CALENDAR ── */ async function renderBookingGrid() { const months=['January','February','March','April','May','June','July','August','September','October','November','December']; const m=state.calMonth, y=state.calYear; html('cal-month-label', `${months[m]} ${y}`); const calData = await GET(`appointments.php?action=calendar&month=${m+1}&year=${y}`); if (calData) { state.bookedDates = Object.keys(calData.booked||{}).map(Number); state.blockedDates = calData.blocked||[]; } const firstDay=new Date(y,m,1).getDay(), daysInMonth=new Date(y,m+1,0).getDate(); const todayDay=new Date().getDate(), isThisMonth=new Date().getMonth()===m&&new Date().getFullYear()===y; let cells=['S','M','T','W','T','F','S'].map(d=>`
${d}
`).join(''); for(let i=0;i`; for(let d=1;d<=daysInMonth;d++){ let cls='cal-day'; if(isThisMonth&&d===todayDay) cls+=' today'; else if(state.blockedDates.includes(d)) cls+=' blocked'; else if(state.bookedDates.includes(d)) cls+=' booked'; cells+=`
${d}
`; } html('cal-grid', cells); const todayAppts=await GET(`appointments.php?date=${todayStr()}&limit=20`); html('cal-schedule', todayAppts?.length ? todayAppts.map(a=>`
${formatTime(a.time_from)}${ampm(a.time_from)}
${a.client_name||'Walk-in'}
${a.service_names||a.notes||'—'}
`).join('') : '
No appointments today.
'); } /* ══════════════════════════════════════════ ACTIONS ══════════════════════════════════════════ */ // Track which client we're issuing to (for phone-add flow) let _issuingClientId = null; async function issueCredentials(clientId) { _issuingClientId = clientId; // Find client from cache or fetch const client = state.clients.find(c => c.id === parseInt(clientId)) || await GET(`clients.php?id=${clientId}`); if (!client) { showToast('Client not found', true); return; } const phone = (client.phone || '').trim(); if (!phone) { // No phone — collect it first const nameEl = el('collect-phone-client-name'); if (nameEl) nameEl.textContent = client.name; const inp = el('collect-phone-input'); if (inp) inp.value = ''; showModal('collect-phone-modal'); setTimeout(() => el('collect-phone-input')?.focus(), 350); return; } // Has phone — issue credentials directly await doIssueCredentials(clientId); } async function reissueCredentials(clientId) { // Resend = show existing card again — generate new temp pass and resend if (!confirm('Generate a new temporary password and resend credentials?')) return; await doIssueCredentials(clientId); } async function resetClientPassword(clientId, clientName) { if (!confirm('Reset password for ' + clientName + '?\nThis generates a new temporary password.\nYou will need to send them the new credentials.')) return; const result = await POST('portal_auth.php?action=admin_reset', { client_id: clientId }); if (result && !result._authError) { // Show the credential card with new temp password const username = result.username || '—'; const password = result.temp_password; const name = result.client_name; const idStr = 'PG-' + String(result.client_id || clientId).padStart(4,'0'); const dateStr = new Date().toLocaleDateString('en',{day:'numeric',month:'short',year:'numeric'}); const portalUrl = window.location.origin; _credData = { username, password, name, idStr, dateStr, portalUrl }; const setHtml = (id,v) => { const e=el(id); if(e) e.innerHTML=v; }; setHtml('card-client-name', toTitleCase(name)); setHtml('card-client-id', idStr + ' · Password Reset'); setHtml('card-username', username); setHtml('card-password', password); setHtml('card-issued-date', 'Reset: ' + dateStr); const qrLink = portalUrl + '#change?u=' + encodeURIComponent(username) + '&p=' + encodeURIComponent(password); generateQR(qrLink, 'cred-qr-canvas', 72); _credData.qrLink = qrLink; showModal('cred-modal'); renderClients(); } } async function savePhoneAndIssue() { const phone = el('add-phone-input')?.value.trim(); if (!phone) { showInputError('add-phone-input', 'Enter phone number'); return; } const btn = el('add-phone-input').closest('.modal-sheet').querySelector('.btn'); const origText = btn.textContent; btn.textContent = 'Saving…'; btn.disabled = true; // Save phone to client profile const updated = await PUT(`clients.php?id=${_issuingClientId}`, { phone }); btn.textContent = origText; btn.disabled = false; if (!updated) return; // Update local cache const cached = state.clients.find(c => c.id === parseInt(_issuingClientId)); if (cached) cached.phone = phone; closeModal('add-phone-modal'); showToast('Phone saved ✓ — issuing credentials…'); // Small delay for UX, then issue setTimeout(() => doIssueCredentials(_issuingClientId), 600); } async function doIssueCredentials(clientId) { const result = await POST('clients.php?action=issue_credentials', { client_id: clientId }); if (!result || result._authError) return; // Warn admin if this is a resend (old card is now invalid) if (result.is_resend) { showToast('⚠ New password generated — previous card is now invalid', true); } const username = result.username || result.phone || '—'; const password = result.temp_password; const name = result.client_name; const idStr = 'PG-' + String(result.client_id || clientId).padStart(4,'0'); const dateStr = new Date().toLocaleDateString('en',{day:'numeric',month:'short',year:'numeric'}); const portalUrl = window.location.origin; // Store for download/copy _credData = { username, password, name, idStr, dateStr, portalUrl }; // Fill the card const setHtml = (id, val) => { const e = el(id); if(e) e.innerHTML = val; }; setHtml('card-client-name', name); setHtml('card-client-id', idStr + ' · Bronze Member'); setHtml('card-username', username); setHtml('card-password', password); setHtml('card-issued-date', 'Issued: ' + dateStr); // QR encodes username + temp password → scanning goes DIRECTLY to force-change card const qrLink = portalUrl + '#change?u=' + encodeURIComponent(username) + '&p=' + encodeURIComponent(password); generateQR(qrLink, 'cred-qr-canvas', 72); _credData.qrLink = qrLink; showModal('cred-modal'); renderClients(); } let _credData = {}; function generateQR(text, canvasId, size) { const canvas = el(canvasId); if (!canvas) return; const ctx = canvas.getContext('2d'); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { ctx.clearRect(0,0,size,size); ctx.drawImage(img,0,0,size,size); }; img.onerror = () => { ctx.fillStyle = '#fff'; ctx.fillRect(0,0,size,size); ctx.fillStyle = '#0F6E56'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.fillText('SCAN', size/2, size/2 - 4); ctx.fillText('QR', size/2, size/2 + 8); }; img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(text) + '&bgcolor=ffffff&color=0a2e22&margin=2'; } async function downloadCredCard() { const btn = el('btn-download-card'); if (btn) { btn.textContent = '⏳ Preparing…'; btn.disabled = true; } try { const cardEl = el('cred-card-preview'); if (!cardEl) return; if (typeof html2canvas !== 'undefined') { const canvas = await html2canvas(cardEl, { scale:3, useCORS:true, backgroundColor:null, logging:false }); const link = document.createElement('a'); link.download = 'Pingarage-' + (_credData.name||'client').replace(/\s+/g,'-') + '-credentials.png'; link.href = canvas.toDataURL('image/png'); link.click(); showToast('Card downloaded ✓ — share on WhatsApp / Telegram / Viber'); } else { exportCredCardSVG(); } } catch(e) { exportCredCardSVG(); } finally { if (btn) { btn.textContent = '⬇ Download card'; btn.disabled = false; } } } function escSVG(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function exportCredCardSVG() { const d = _credData; const w = 400, h = 240; const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=72x72&data=' + encodeURIComponent((d.portalUrl||'') + '?u=' + encodeURIComponent(d.username||'')) + '&bgcolor=ffffff&color=0a2e22&margin=2'; const svg = ` PINGARAGE Member Portal Access ${escSVG(d.name)} ${escSVG(d.idStr)} · Bronze Member USERNAME (PHONE) ${escSVG(d.username)} TEMP PASSWORD ${escSVG(d.password)} How to get started 1. Scan QR or visit our portal 2. Login with your phone number 3. Enter the password above 4. Change password on first login Kaamineege, sh.kanditheemu · +9607563060 ${escSVG(d.dateStr)} `; const blob = new Blob([svg], { type:'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.download = 'Pingarage-' + (d.name||'client').replace(/\s+/g,'-') + '-credentials.svg'; a.href = url; a.click(); setTimeout(() => URL.revokeObjectURL(url), 3000); showToast('Card downloaded ✓ — share on WhatsApp / Telegram / Viber'); } function buildCredMessage() { const d = _credData; const nl = "\n"; return [ "\u{1F511} *PINGARAGE — Portal Access*", "", "\u{1F464} " + (d.name||"") + " | " + (d.idStr||""), "", "\u{1F4F1} *Username:* " + (d.username||""), "\u{1F510} *Password:* " + (d.password||""), "", "\u{1F4CB} *Steps to login:*", "1. Visit " + (d.portalUrl||window.location.origin), "2. Enter your phone as username", "3. Enter the password above", "4. You will be asked to change your password", "", "Questions? Call: +9607563060", "_Kaamineege, sh.kanditheemu_" ].join(nl); } function copyCredText() { navigator.clipboard.writeText(buildCredMessage()) .then(() => showToast('Copied ✓ — paste into WhatsApp / Telegram / Viber')) .catch(() => showToast('Copy failed — try manually', true)); } function shareOnWhatsApp() { const d = _credData; const msg = buildCredMessage(); const phone = (d.username||'').replace(/[^0-9]/g,''); const url = phone.length >= 7 ? 'https://wa.me/' + phone + '?text=' + encodeURIComponent(msg) : 'https://wa.me/?text=' + encodeURIComponent(msg); window.open(url, '_blank'); } function shareOnViber() { const d = _credData; const msg = buildCredMessage(); const phone = (d.username||'').replace(/[^0-9]/g,''); // Viber deep link — opens direct chat if number is in contacts const url = phone.length >= 7 ? 'viber://chat?number=' + encodeURIComponent('+' + phone.replace(/^0+/,'')) + '&text=' + encodeURIComponent(msg) : 'viber://forward?text=' + encodeURIComponent(msg); window.location.href = url; setTimeout(() => showToast('Opening Viber… if not installed, use Copy text'), 1000); } function shareOnTelegram() { const d = _credData; const msg = buildCredMessage(); // Telegram share — user picks contact in Telegram const url = 'https://t.me/share/url?url=' + encodeURIComponent(d.portalUrl||window.location.origin) + '&text=' + encodeURIComponent(msg); window.open(url, '_blank'); } async function confirmAppt(id) { await bkConfirm(id); } async function declineAppt(id) { await bkDecline(id); } async function declineAppt(id) { const r = await PUT(`appointments.php?id=${id}`, { status: 'cancelled' }); if (r) { showToast('Appointment declined'); renderBookingGrid(); } } async function togglePromo(id, active) { const r = await PUT(`promotions.php?id=${id}`, { is_active: active?1:0 }); if (r) { showToast(active?'Promo is now live 🎉':'Promo deactivated'); renderPromos(); } } async function deletePromo(id) { if (!confirm('Delete this promotion?')) return; const r = await DELETE(`promotions.php?id=${id}`); if (r) { showToast('Promotion deleted'); renderPromos(); } } async function markPaid(id) { const r = await POST('invoices.php?action=mark_paid', { invoice_id: id }); if (r) { showToast('Invoice marked as paid ✓'); renderInvoices(); } } // ── Invoice line items state ───────────────────────── let _invoiceLines = []; let _servicesCache = []; async function loadServicesForInvoice() { if (_servicesCache.length) return _servicesCache; const svcs = await GET('services.php'); _servicesCache = svcs || []; return _servicesCache; } async function openAddLineModal() { // Load services into dropdown const svcs = await loadServicesForInvoice(); const sel = el('line-service-pick'); if (sel) { sel.innerHTML = '' + svcs.map(s => ``).join(''); } // Clear fields const descEl = el('line-desc'); const priceEl = el('line-price'); const qtyEl = el('line-qty'); if (descEl) descEl.value = ''; const hintEl = el('line-price-hint'); if (hintEl) { hintEl.style.display = 'none'; hintEl.textContent = ''; } if (priceEl) priceEl.value = ''; if (qtyEl) qtyEl.value = '1'; showModal('add-service-modal'); } function prefillLineItem(serviceId) { if (!serviceId) return; const svcs = _servicesCache; const svc = svcs.find(s => String(s.id) === String(serviceId)); if (!svc) return; const descEl = el('line-desc'); const priceEl = el('line-price'); if (descEl) descEl.value = svc.name; if (priceEl) priceEl.value = svc.default_price; // Show preset hint below price field const hint = el('line-price-hint'); if (hint) { hint.textContent = 'Preset: MVR ' + fmt(svc.default_price) + ' — you can change this for this invoice only'; hint.style.display = ''; } } function addLineItem() { const desc = el('line-desc')?.value.trim(); const price = parseFloat(el('line-price')?.value || 0); const qty = parseInt(el('line-qty')?.value || 1); const svcId = el('line-service-pick')?.value || null; if (!desc) { showInputError('line-desc', 'Description required'); return; } if (!price) { showInputError('line-price', 'Enter a price'); return; } _invoiceLines.push({ desc, price, qty, service_id: svcId||null, total: price * qty, _basePrice: price, _edited: false }); closeModal('add-service-modal'); renderInvoiceLines(); showToast(`${desc} added`); } function removeLineItem(idx) { _invoiceLines.splice(idx, 1); renderInvoiceLines(); } // ── Per-invoice price editing (preset in DB stays unchanged) ───────── function toggleLineEdit(i) { const editDiv = el('line-edit-' + i); if (!editDiv) return; const isOpen = editDiv.style.display !== 'none'; _invoiceLines.forEach((_, j) => { const d = el('line-edit-' + j); if (d && j !== i) d.style.display = 'none'; }); editDiv.style.display = isOpen ? 'none' : ''; if (!isOpen) { const p = el('line-price-edit-' + i); if (p) { p.focus(); p.select(); } } } function applyLineEdit(i) { const newPrice = parseFloat(el('line-price-edit-' + i)?.value || 0); const newQty = parseInt(el('line-qty-edit-' + i)?.value || 1); if (isNaN(newPrice) || newPrice < 0) { showToast('Enter a valid price', true); return; } if (isNaN(newQty) || newQty < 1) { showToast('Enter a valid quantity', true); return; } const line = _invoiceLines[i]; if (line._basePrice === undefined) line._basePrice = line.price; line.price = newPrice; line.qty = newQty; line.total = newPrice * newQty; line._edited = newPrice !== line._basePrice; renderInvoiceLines(); showToast('Price updated for this invoice only — preset unchanged'); } function resetLinePrice(i) { const line = _invoiceLines[i]; if (line._basePrice !== undefined) { line.price = line._basePrice; line.total = line._basePrice * line.qty; line._edited = false; } renderInvoiceLines(); showToast('Reset to preset price'); } function renderInvoiceLines() { const container = el('invoice-line-items'); if (!container) return; if (!_invoiceLines.length) { container.innerHTML = '
No items yet. Add a service above.
'; const totalEl = el('inv-total'); if (totalEl) totalEl.textContent = 'MVR 0.00'; return; } container.innerHTML = _invoiceLines.map((line, i) => `
${line.desc}
Qty ${line.qty} × MVR ${fmt(line.price)} ${line._edited ? 'custom price' : ''}
MVR ${fmt(line.total)}
`).join(''); const total = _invoiceLines.reduce((s, l) => s + l.total, 0); const totalEl = el('inv-total'); if (totalEl) totalEl.textContent = 'MVR ' + fmt(total); } // ── Invoice helpers ────────────────────────────────── function showSection(sectionId) { ['inv-list-section','inv-create-section'].forEach(id => { const el2 = el(id); if (el2) el2.style.display = id === sectionId ? '' : 'none'; }); // Load clients dropdown when create section opens if (sectionId === 'inv-create-section') { loadClientDropdown('inv-client'); } } async function onInvClientChange(clientId) { const sel = el('inv-vehicle'); if (!sel) return; if (!clientId) { sel.innerHTML = ''; return; } sel.innerHTML = ''; const vehicles = await GET('vehicles.php?client_id=' + clientId); if (vehicles && vehicles.length) { sel.innerHTML = '' + vehicles.map(v => ``).join(''); } else { sel.innerHTML = ''; } } async function saveInvoice(status) { const client_id = el('inv-client')?.value; const vehicle_id = el('inv-vehicle')?.value; const notes = el('inv-notes')?.value.trim(); if (!client_id) { showToast('Select a client', true); return; } if (!vehicle_id) { showToast('Select a vehicle', true); return; } if (!_invoiceLines.length) { showToast('Add at least one service', true); return; } const jobs = _invoiceLines.map(l => ({ service_id: l.service_id, description: l.desc, quantity: l.qty, labour_cost: l.price, parts_cost: 0, amount: l.total, })); const result = await POST('invoices.php', { client_id, vehicle_id, status, notes, jobs }); if (result) { _invoiceLines = []; renderInvoiceLines(); showSection('inv-list-section'); showToast(status === 'draft' ? 'Invoice saved as draft' : 'Invoice issued ✓'); renderInvoices(); } } async function saveNewClient() { const nameRaw = el('nc-name')?.value.trim(); const phoneRaw = el('nc-phone')?.value.trim(); const emailRaw = el('nc-email')?.value.trim(); const nickRaw = el('nc-nickname')?.value.trim(); const nid = el('nc-nid')?.value.trim().toUpperCase(); const tier = el('nc-tier')?.value; if (!nameRaw) { showInputError('nc-name', 'Name is required'); return; } // Auto-format const name = toTitleCase(nameRaw); const phone = phoneRaw ? formatPhone(phoneRaw) : null; const email = emailRaw ? emailRaw.toLowerCase() : null; const nickname = nickRaw ? toTitleCase(nickRaw) : ''; const notes = [ nickname ? 'Nickname: ' + nickname : '', nid ? 'NID: ' + nid : '', ].filter(Boolean).join(' · ') || null; const result = await POST('clients.php', { name, phone, email, tier, notes }); if (result) { closeModal('add-client-modal'); showToast(`${name} added · ID: PG-${String(result.id).padStart(4,'0')}`); renderClients(); } } /* ── BULK CLIENTS — CSV paste ── */ let _bulkParsed = []; function parseBulkCSV() { const raw = el('bulk-csv-input')?.value || ''; if (!raw.trim()) { clearBulkCSV(); return; } const lines = raw.split('\n').filter(l => l.trim().length > 0); const valid = []; const errors = []; // Detect separator: tab (Excel paste) or comma (CSV) const sep = lines[0] && lines[0].includes('\t') ? '\t' : ','; // Column name mapping — matches your Excel headers case-insensitively const COL_MAP = { name: ['name'], nickname: ['nick name','nickname','nick','preferred name'], phone: ['phone','mobile','contact','tel'], email: ['email','e-mail'], nid: ['nid','national id','id','national id number'], }; // Parse header row to determine column order const headerRow = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/^"|"$/g,'')); const colIndex = {}; Object.entries(COL_MAP).forEach(([field, aliases]) => { const idx = headerRow.findIndex(h => aliases.some(a => h.includes(a))); if (idx !== -1) colIndex[field] = idx; }); const isHeader = Object.keys(colIndex).length >= 2; // at least 2 cols matched = it's a header const dataLines = isHeader ? lines.slice(1) : lines; dataLines.forEach((line, idx) => { if (!line.trim()) return; const cols = line.split(sep).map(c => c.trim().replace(/^"|"$/g, '')); let name='', nickname='', phone='', email='', nid=''; if (isHeader) { // Use detected column positions name = cols[colIndex.name ?? 0] || ''; nickname = cols[colIndex.nickname ?? -1] || ''; phone = cols[colIndex.phone ?? 2] || ''; email = cols[colIndex.email ?? 3] || ''; nid = cols[colIndex.nid ?? 4] || ''; } else { // Fallback: assume order Name, Nickname, Phone, Email, NID [name='', nickname='', phone='', email='', nid=''] = cols; } name = name.trim(); nickname = nickname.trim(); phone = phone.trim(); email = email.trim(); nid = nid.trim(); if (!name) return; // blank row — skip silently valid.push({ name, nickname, phone, email, nid, tier: 'Bronze' }); }); _bulkParsed = valid; const wrap = el('bulk-preview-wrap'); const saveBtn = el('bulk-save-btn'); const errDiv = el('bulk-errors'); if (valid.length > 0) { wrap.style.display = 'block'; html('bulk-preview-count', `${valid.length} client${valid.length!==1?'s':''} ready to import`); // Apply title case to names before preview valid.forEach(c => { c.name = toTitleCase(c.name); c.nickname = c.nickname ? toTitleCase(c.nickname) : ''; }); html('bulk-preview-rows', valid.map((c, i) => ` ${i+1} ${c.name} ${c.nickname?`
${c.nickname}`:''} ${c.phone} ${c.nid?`
${c.nid}`:''} ${c.nid?`${c.nid}`:''} `).join('')); saveBtn.disabled = false; saveBtn.style.opacity = '1'; saveBtn.style.cursor = 'pointer'; saveBtn.textContent = `Import ${valid.length} client${valid.length!==1?'s':''}`; } else { wrap.style.display = 'none'; saveBtn.disabled = true; saveBtn.style.opacity = '0.5'; saveBtn.style.cursor = 'not-allowed'; saveBtn.textContent = 'Import all'; } if (errors.length > 0) { errDiv.style.display = 'block'; errDiv.innerHTML = `
⚠ ${errors.length} blank row${errors.length!==1?'s':''} skipped
Rows without a name were ignored.
`; } else { errDiv.style.display = 'none'; } } function clearBulkCSV() { const inp = el('bulk-csv-input'); if (inp) inp.value = ''; _bulkParsed = []; const wrap = el('bulk-preview-wrap'); if (wrap) wrap.style.display = 'none'; const err = el('bulk-errors'); if (err) err.style.display = 'none'; const btn = el('bulk-save-btn'); if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; } } function addBulkRow() { /* legacy — no longer used */ } async function saveBulkClients() { if (!_bulkParsed.length) { showToast('Paste some clients first', true); return; } const saveBtn = el('bulk-save-btn'); saveBtn.textContent = 'Importing…'; saveBtn.disabled = true; let saved = 0, failed = 0; for (const c of _bulkParsed) { const notes = [ c.nickname ? 'Nickname: ' + c.nickname : '', c.nid ? 'NID: ' + c.nid : '', ].filter(Boolean).join(' · '); const r = await POST('clients.php', { name: toTitleCase(c.name), phone: c.phone ? formatPhone(c.phone) : null, email: c.email ? c.email.toLowerCase() : null, tier: c.tier, notes: notes || null, }); if (r) saved++; else failed++; } saveBtn.textContent = 'Import clients'; closeModal('bulk-client-modal'); clearBulkCSV(); showToast(`✓ ${saved} client${saved!==1?'s':''} imported${failed?' · '+failed+' failed':''}`); renderClients(); } async function saveNewVehicle() { const c = state.currentClient; const makeRaw = el('nv-make')?.value.trim(); // Strip " (Bike)" suffix added for display disambiguation const make = makeRaw.replace(' (Bike)', '').trim(); // model select: if 'Specify below', use custom input const modelSel = el('nv-model')?.value; const model = (modelSel === 'Specify below') ? (el('nv-model-custom')?.value?.trim() || '') : (modelSel || '').trim(); const plate = el('nv-plate')?.value.trim(); const year = el('nv-year')?.value; const vin = el('nv-vin')?.value.trim(); const fuel = el('nv-fuel')?.value; const typeRadio = document.querySelector('input[name="nv-type"]:checked'); if (!typeRadio) { showToast('Please select vehicle type (Bike or Car)', true); return; } if (!make) { showInputError('nv-make', 'Make required'); return; } if (!model) { showInputError('nv-model', 'Model required'); return; } if (!plate) { showInputError('nv-plate', 'Plate required'); return; } const vehicleType = typeRadio?.value; const finalFuel = vehicleType === 'bike' ? (fuel || 'Petrol') : fuel; // Store vehicle type as a note prefix for easy identification const typeNote = vehicleType === 'bike' ? '[Motorcycle/Scooter] ' : ''; const r = await POST('vehicles.php', { client_id:c?.id, make, model, license_plate:plate, year, vin, fuel_type:finalFuel, notes: typeNote + (el('nv-notes')?.value?.trim() || '') }); if (r) { closeModal('vehicle-modal'); showToast('Vehicle added ✓'); renderVehicles(); } } async function saveNewPromo() { const title = el('np-title')?.value.trim(); const start = el('np-start')?.value; const end = el('np-end')?.value; const tier = el('np-tier')?.value; const dtype = el('np-dtype')?.value; const dval = el('np-dval')?.value; if (!title) { showInputError('np-title', 'Title required'); return; } if (!start || !end) { showToast('Set start and end dates', true); return; } const r = await POST('promotions.php', { title, start_date:start, end_date:end, tier, discount_type:dtype, discount_value:parseFloat(dval)||0, banner_url: state.promoImageData || null, }); if (r) { state.promoImageData = null; el('np-title').value = ''; el('np-dval').value = ''; el('np-start').value = ''; el('np-end').value = ''; const preview = el('np-banner-preview'); if(preview) preview.style.display='none'; showToast('Promo created 🎉'); renderPromos(); } } async function saveBlockSlot() { const date = el('block-date')?.value; const from = el('block-from')?.value; const to = el('block-to')?.value; const reason = el('block-reason')?.value; if (!date) { showToast('Select a date', true); return; } const r = await POST('appointments.php?action=block_slot', { date, time_from:from, time_to:to, reason }); if (r) { closeModal('block-modal'); showToast('Date blocked'); renderBookingGrid(); } } function calDayClick(d) { if (state.blockedDates.includes(d)) { showToast('This day is blocked'); return; } if (state.bookedDates.includes(d)) { showToast('Appointments on this day'); return; } showToast(`Day ${d} is free`); } function calPrev() { state.calMonth--; if(state.calMonth<0){state.calMonth=11;state.calYear--;} renderBookingGrid(); } function calNext() { state.calMonth++; if(state.calMonth>11){state.calMonth=0;state.calYear++;} renderBookingGrid(); } async function filterClients(chip) { state.filterChip = chip; document.querySelectorAll('#client-chips .chip').forEach(c=>c.classList.toggle('active', c.dataset.filter===chip)); renderClients(); } function viewClient(id) { const c = state.clients.find(x=>x.id===parseInt(id)); if (c) navigate('profile', c); else GET(`clients.php?id=${id}`).then(data=>{ if(data) navigate('profile', data); }); } async function loadClientDropdown(selectId) { const result = await GET('clients.php?status=active&limit=100'); const sel = el(selectId); if (!sel || !result?.clients) return; sel.innerHTML = '' + result.clients.map(c=>``).join(''); } async function loadVehicleDropdown(selectId, clientId) { if (!clientId) return; const result = await GET(`vehicles.php?client_id=${clientId}`); const sel = el(selectId); if (!sel||!result) return; sel.innerHTML = '' + result.map(v=>``).join(''); } function showModal(id) { const m=el(id); if(m) m.classList.add('open'); if(id==='bulk-client-modal') clearBulkCSV(); if(id==='add-client-modal') { setTimeout(()=>{ formatPhoneInput(el('nc-phone')); }, 100); } if(id==='book-appt-modal') { const d=el('booking-date'); if(d) d.min=todayStr(); } if(id==='client-add-vehicle-modal') { document.querySelectorAll('input[name="cv-type"]').forEach(r=>r.checked=false); const cvMake=el('cv-make'); if(cvMake) cvMake.innerHTML=''; const cvModel=el('cv-model'); if(cvModel) cvModel.innerHTML=''; const cvCust=el('cv-model-custom-wrap'); if(cvCust) cvCust.style.display='none'; ['cv-year','cv-plate','cv-color'].forEach(id2=>{const e2=el(id2);if(e2)e2.value='';}); } if(id==='add-expense-modal') { const d=el('exp-date'); if(d&&!d.value) d.value=todayStr(); } // Populate vehicle make/model dropdowns if(id==='vehicle-modal') { // Reset type radio document.querySelectorAll('input[name="nv-type"]').forEach(r=>r.checked=false); // Reset make/model const makeEl=el('nv-make'); if(makeEl) makeEl.innerHTML=''; const modelEl=el('nv-model'); if(modelEl) modelEl.innerHTML=''; const custW=el('nv-model-custom-wrap'); if(custW) custW.style.display='none'; // Clear previous values ['nv-year','nv-plate','nv-vin'].forEach(id2=>{const e2=el(id2);if(e2)e2.value='';}); } } function closeModal(id) { const m=el(id); if(m) m.classList.remove('open'); } function goTo(page, extra) { navigate(page, extra); } /* ── INIT ── */ function init() { const hash = window.location.hash || ''; // ── QR SCAN: #change?u=phone&p=temppass ────────── // Scanning QR goes directly to force-change card, no login step const changeMatch = hash.match(/#change\?(.+)/); if (changeMatch) { const params = new URLSearchParams(changeMatch[1]); const qrUser = params.get('u') || ''; const qrPass = params.get('p') || ''; state.loginMode = 'client'; state.changePasswordMode = 'client'; // First login with temp credentials silently to get a token navigate('login'); setTimeout(async () => { // Show force-change card directly flipToPasswordChange(); // Pre-fill the force-change fields const tempEl = el('pw-temp'); const newEl = el('pw-new'); if (tempEl) { tempEl.value = qrPass; tempEl.setAttribute('readonly','true'); tempEl.style.opacity='0.75'; } if (newEl) newEl.focus(); // Disable submit while silent login is in progress const setBtn = el('btn-setpw'); if (setBtn) { setBtn.disabled = true; setBtn.style.opacity = '0.5'; setBtn.textContent = 'Verifying…'; } // Silent login to get token for password change const loginResult = await POST('portal_auth.php?action=login', { phone: qrUser, password: qrPass }); if (loginResult && loginResult.token) { state.token = loginResult.token; state.user = loginResult.client || loginResult.user; state.loginMode = 'client'; state.changePasswordMode = 'client'; localStorage.setItem('pg_token', loginResult.token); localStorage.setItem('pg_user', JSON.stringify(state.user)); // Enable the set-password button const setBtn = el('btn-setpw'); if (setBtn) { setBtn.disabled = false; setBtn.style.opacity = '1'; setBtn.textContent = 'Set password & continue'; } showToast('Enter your new password 🔐'); } else { // Login failed — QR might be expired (hash was reset) flipBack(); const errMsg = (loginResult && loginResult.error) || 'QR code expired — ask admin to re-issue credentials'; showGlassError('login-email', errMsg); showToast(errMsg, true); } }, 300); // Clean up URL hash history.replaceState(null, '', window.location.pathname); return; } // ── NORMAL LOGIN: #login?u=phone (manual visit) ── const loginMatch = hash.match(/#login\?(.+)/); if (loginMatch) { const params = new URLSearchParams(loginMatch[1]); const qrUser = params.get('u') || ''; state.loginMode = 'client'; navigate('login'); setTimeout(() => { const emailEl = el('login-email'); const labelEl = document.querySelector('label[for="login-email"]'); const btnLogin = el('btn-login'); if (emailEl) { emailEl.value = qrUser; emailEl.setAttribute('readonly','true'); emailEl.style.opacity='0.75'; } if (labelEl) labelEl.textContent = 'Phone number'; if (btnLogin) btnLogin.textContent = 'Access my account'; el('login-pass')?.focus(); }, 300); history.replaceState(null, '', window.location.pathname); return; } // ── ADMIN / RETURNING USER ──────────────────────── if (state.token && state.user) { // Route based on stored role — client tokens have role:'client' const savedRole = (state.user.role) || localStorage.getItem('pg_loginmode') || 'admin'; if (savedRole === 'client') { state.loginMode = 'client'; navigate('client-dashboard'); } else { state.loginMode = 'admin'; navigate('dashboard'); } } else { state.loginMode = 'admin'; navigate('login'); } document.querySelectorAll('.nav-item[data-page]').forEach(n=>{ n.addEventListener('click', e=>{ e.preventDefault(); navigate(n.dataset.page); }); }); el('btn-login')?.addEventListener('click', validateLogin); el('login-email')?.addEventListener('keydown', e=>{ if(e.key==='Enter') el('login-pass')?.focus(); }); el('login-pass')?.addEventListener('keydown', e=>{ if(e.key==='Enter') validateLogin(); }); el('btn-setpw')?.addEventListener('click', validatePasswordChange); document.querySelectorAll('#client-chips .chip').forEach(c=>{ c.addEventListener('click', ()=>filterClients(c.dataset.filter)); }); let searchTimer; el('client-search')?.addEventListener('input', ()=>{ clearTimeout(searchTimer); searchTimer=setTimeout(renderClients, 400); }); document.querySelectorAll('.modal-overlay').forEach(m=>{ m.addEventListener('click', e=>{ if(e.target===m) m.classList.remove('open'); }); }); el('cal-prev')?.addEventListener('click', calPrev); el('cal-next')?.addEventListener('click', calNext); document.addEventListener('click', e=>{ const row=e.target.closest('[data-client-id]'); if(row&&!e.target.closest('button')) viewClient(row.dataset.clientId); }); el('inv-client')?.addEventListener('change', e=>loadVehicleDropdown('inv-vehicle', e.target.value)); // Enter key on add-phone modal el('add-phone-input')?.addEventListener('keydown', e=>{ if(e.key==='Enter') savePhoneAndIssue(); }); // Live promo preview wiring ['np-title','np-dval','np-dtype','np-start','np-end'].forEach(id=>{ el(id)?.addEventListener('input', updatePromoPreview); }); } // ════════════════════════════════════════════════════ // BOOKING GRID ENGINE // ════════════════════════════════════════════════════ const bkState = { anchorDate: new Date(), cbkAnchorDate: new Date(), gridData: null, cbkGridData: null, activeBay: 0, settings: null, pendingSlot: null, pendingAdminSlot: null, }; function bkDateStr(d) { return d.toISOString().split('T')[0]; } function bkAddDays(d,n) { const nd=new Date(d); nd.setDate(nd.getDate()+n); return nd; } function bkFmtWeekLabel(ws) { const s=new Date(ws), e=bkAddDays(s,6), o={day:'numeric',month:'short'}; return s.toLocaleDateString('en',o)+' \u2013 '+e.toLocaleDateString('en',{...o,year:'numeric'}); } function bkFmtTime(t) { if(!t) return ''; const [h,m]=t.split(':').map(Number); return `${h%12||12}:${m.toString().padStart(2,'0')} ${h>=12?'PM':'AM'}`; } // ── ADMIN GRID ────────────────────────────────────── async function renderBookingGrid() { const dateStr = bkDateStr(bkState.anchorDate); html('bk-grid-container','
Loading\u2026
'); if (!bkState.settings) await bkLoadSettings(); const data = await GET(`booking.php?action=week&date=${dateStr}`); if (!data) { html('bk-grid-container','
Failed to load schedule.
'); return; } bkState.gridData = data; bkState.settings = data.settings; html('bk-week-label', bkFmtWeekLabel(data.week_start)); bkUpdateNavButtons(); if (data.bays > 1) { el('bk-bay-tabs').style.display = ''; html('bk-bay-tabs', ['All',...data.bay_names].map((name,i)=> `` ).join('')); } else { el('bk-bay-tabs').style.display='none'; } buildAdminGrid(data); } function buildAdminGrid(data) { const {days,slot_labels,bays,bay_names}=data; const showBays = bkState.activeBay===0 ? bays : 1; const bayOffset = bkState.activeBay===0 ? 0 : bkState.activeBay-1; let g = `
`; // Header g += `
Time
`; days.forEach(day => { g += `
${day.day_name}
${day.day_num}
${day.month_name}
${!day.is_open?'
Closed
':''}
`; }); // Rows slot_labels.forEach(slot => { g += `
${bkFmtTime(slot.from)}
`; days.forEach(day => { for (let bi=bayOffset; bi
`; } else if (cell) { const svc = cell.services ? `
${cell.services}
` : ''; g += `
`; } else if (day.is_past) { g += `
`; } else { g += `
`; } } }); }); g += ``; html('bk-grid-container', g); } function bkWeek(dir) { const next = bkAddDays(bkState.anchorDate, dir * 7); const today = new Date(); today.setHours(0,0,0,0); // Never go before today if (dir < 0 && next < today) return; // Never go beyond advance limit if (dir > 0 && bkState.settings) { const advDays = parseInt(bkState.settings.advance_days) || 14; const limit = bkAddDays(today, advDays); if (next > limit) return; } bkState.anchorDate = next; renderBookingGrid(); } function bkToday() { bkState.anchorDate = new Date(); renderBookingGrid(); } function bkUpdateNavButtons() { const today = new Date(); today.setHours(0,0,0,0); const prevBtn = el('bk-prev-week'); const nextBtn = el('bk-next-week'); const anchor = new Date(bkState.anchorDate); anchor.setHours(0,0,0,0); const advDays = bkState.settings ? parseInt(bkState.settings.advance_days)||14 : 14; const limit = bkAddDays(today, advDays); if (prevBtn) prevBtn.disabled = anchor <= today; if (nextBtn) nextBtn.disabled = bkAddDays(anchor, 7) > limit; } function bkSetBay(i) { bkState.activeBay=i; if(bkState.gridData) buildAdminGrid(bkState.gridData); document.querySelectorAll('.bay-tab').forEach((t,j)=>t.classList.toggle('active',j===i)); } function bkAdminClickSlot(date,timeFrom,bay) { bkState.pendingAdminSlot={date,timeFrom,bay}; const d=new Date(date+'T00:00:00'); const dl=d.toLocaleDateString('en',{weekday:'short',day:'numeric',month:'short'}); html('bk-detail-content',`

Slot Action

${dl} · ${bkFmtTime(timeFrom)} · Bay ${bay}
`); showModal('bk-detail-modal'); } function bkOpenAdminBook(date,timeFrom,bay) { closeModal('bk-detail-modal'); bkState.pendingAdminSlot={date,timeFrom,bay}; const dateEl=el('abk-date'); if(dateEl) dateEl.value=date; const timeEl=el('abk-time-from'); if (timeEl && bkState.settings) { const {open_time,close_time,slot_duration}=bkState.settings; const slots=[]; let cur=new Date('2000-01-01T'+open_time); const end=new Date('2000-01-01T'+close_time); while(cur``).join(''); } const bayWrap=el('abk-bay-wrap'), bayEl=el('abk-bay'); if (bkState.settings && parseInt(bkState.settings.bays)>1) { bayWrap.style.display=''; const bn=bkState.settings.bay_names.split(',').map(s=>s.trim()); bayEl.innerHTML=bn.map((n,i)=>``).join(''); } else { if(bayWrap) bayWrap.style.display='none'; } showModal('admin-book-modal'); } let _abkTimer=null; async function abkSearchClient(q) { clearTimeout(_abkTimer); if(!q||q.length<2){el('abk-client-results').style.display='none';return;} _abkTimer=setTimeout(async()=>{ const r=await GET(`clients.php?search=${encodeURIComponent(q)}&limit=6`); if(!r?.clients?.length){el('abk-client-results').style.display='none';return;} el('abk-client-results').style.display=''; html('abk-client-results',`
${r.clients.map(c=>`
${c.name}
${c.phone||'No phone'}
`).join('')}
`); },300); } async function abkSelectClient(id,name,phone) { el('abk-client-id').value=id; el('abk-client-results').style.display='none'; el('abk-client-search').value=name; const s=el('abk-client-selected'); s.style.display=''; html('abk-client-selected',`
${name} ${phone}
`); const vehs=await GET(`vehicles.php?client_id=${id}`); const ve=el('abk-vehicle-id'); if(ve) ve.innerHTML=''+(vehs||[]).map(v=>``).join(''); } async function abkSubmit() { const cid=el('abk-client-id')?.value, date=el('abk-date')?.value, tf=el('abk-time-from')?.value; if(!cid){showToast('Please select a client',true);return;} if(!date){showToast('Please select a date',true);return;} if(!tf){showToast('Please select a time',true);return;} const bay=el('abk-bay')?.value||1, veh=el('abk-vehicle-id')?.value, svc=el('abk-service')?.value, notes=el('abk-notes')?.value; const btn=el('abk-submit-btn'); if(btn){btn.textContent='Booking\u2026';btn.disabled=true;} const r=await POST('booking.php?action=book',{client_id:parseInt(cid),vehicle_id:veh?parseInt(veh):null,date,time_from:tf,bay_number:parseInt(bay),services:svc?[svc]:[],notes:notes||null}); if(btn){btn.textContent='Confirm booking';btn.disabled=false;} if(r){showToast('Booking confirmed');closeModal('admin-book-modal');['abk-client-search','abk-notes','abk-service'].forEach(id=>{const e=el(id);if(e)e.value='';});el('abk-client-id').value='';el('abk-client-selected').style.display='none';renderBookingGrid();} } async function bkShowDetail(id) { html('bk-detail-content',skeleton(2)); showModal('bk-detail-modal'); const appt=await GET(`appointments.php?id=${id}`); if(!appt){html('bk-detail-content','
Failed to load.
');return;} const d=new Date(appt.appointment_date+'T00:00:00'); html('bk-detail-content',`

Booking #${appt.id}

${statusBadge(appt.status)}
${d.toLocaleDateString('en',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}
${bkFmtTime(appt.time_from)}
${appt.client_name||'—'}
${appt.client_phone||'—'}
${appt.make?appt.make+' '+appt.model:'—'}
${appt.license_plate||'—'}
${appt.notes?`
${appt.notes}
`:''}
${appt.status==='pending'?``:''} ${appt.status==='confirmed'?``:''}
`); } async function bkConfirm(id) { const r=await PUT(`booking.php?action=confirm&id=${id}`,{}); if(r){showToast('Confirmed');closeModal('bk-detail-modal');renderBookingGrid();} } async function bkDecline(id) { if(!confirm('Decline?'))return; const r=await PUT(`booking.php?action=decline&id=${id}`,{}); if(r){showToast('Declined');closeModal('bk-detail-modal');renderBookingGrid();} } async function bkCancelAdmin(id) { if(!confirm('Cancel?'))return; const r=await bkDELETE(`booking.php?action=cancel&id=${id}`); if(r){showToast('Cancelled');closeModal('bk-detail-modal');renderBookingGrid();} } function bkOpenBlock(date,timeFrom,bay) { closeModal('bk-detail-modal'); el('blk-date').value=date; el('blk-time').value=bkFmtTime(timeFrom); el('blk-reason').value=''; el('blk-type').value='slot'; bkState.pendingAdminSlot={date,timeFrom,bay}; showModal('bk-block-modal'); } function blkTypeChange() { el('blk-time-wrap').style.display=el('blk-type').value==='day'?'none':''; } async function blkSubmit() { const slot=bkState.pendingAdminSlot; if(!slot)return; const type=el('blk-type').value, reason=el('blk-reason').value; const bd={date:slot.date,type,reason}; if(type==='slot'){bd.time_from=slot.timeFrom;bd.bay_number=slot.bay;} const r=await PUT('booking.php?action=block',bd); if(r){showToast('Slot blocked');closeModal('bk-block-modal');renderBookingGrid();} } async function bkLoadSettings() { const s=await GET('booking.php?action=settings'); if(!s)return null; bkState.settings=s; el('bs-open-time').value=s.open_time||'08:00'; el('bs-close-time').value=s.close_time||'17:00'; el('bs-slot-duration').value=s.slot_duration||'60'; el('bs-bays').value=s.bays||'1'; el('bs-bay-names').value=s.bay_names||'Bay 1'; const _adv=parseInt(s.advance_days||14);const _advOpts=[7,14,21,30];const _advNear=_advOpts.reduce((a,b)=>Math.abs(b-_adv){ cb.checked=od.includes(parseInt(cb.value)); }); return s; } async function bkSaveSettings() { const od=[...document.querySelectorAll('#bs-open-days input:checked')].map(c=>c.value).join(','); const r=await POST('booking.php?action=settings',{open_time:el('bs-open-time').value,close_time:el('bs-close-time').value,slot_duration:el('bs-slot-duration').value,bays:el('bs-bays').value,bay_names:el('bs-bay-names').value,advance_days:el('bs-advance-days').value,max_per_client:el('bs-max-per-client').value,open_days:od}); if(r){showToast('Settings saved');closeModal('booking-settings-modal');renderBookingGrid();} } // ── CLIENT BOOKING GRID ───────────────────────────── async function renderClientBookingGrid() { const dateStr=bkDateStr(bkState.cbkAnchorDate); html('cbk-grid-container','
Loading\u2026
'); const data=await GET(`booking.php?action=week&date=${dateStr}`); if(!data){html('cbk-grid-container','
Failed to load.
');return;} bkState.cbkGridData=data; html('cbk-week-label',bkFmtWeekLabel(data.week_start)); cbkUpdateNavButtons(); buildClientGrid(data); const cid=state.user?.id; if(cid){const vehs=await GET(`vehicles.php?client_id=${cid}`);const vs=el('cbk-vehicle-id');if(vs&&vehs?.length){vs.innerHTML=''+vehs.map(v=>``).join('');}} } function buildClientGrid(data) { const {days,slot_labels,bays}=data; let g=`
`; g+=`
Time
`; days.forEach(day=>{ g+=`
${day.day_name}
${day.day_num}
${day.month_name}
${!day.is_open?'
Closed
':''}
`; }); slot_labels.forEach(slot=>{ g+=`
${bkFmtTime(slot.from)}
`; days.forEach(day=>{ const cells=day.slots[slot.from]||[]; const myCell=cells.find(c=>c&&c.is_mine); const hasAvail=cells.some(c=>c===null); const availBay=(cells.findIndex(c=>c===null))+1; if(!day.is_open||day.is_past||day.is_too_far){ g+=`
`; } else if(myCell){ g+=`
`; } else if(!hasAvail){ g+=`
`; } else { g+=`
`; } }); }); g+=`
`; html('cbk-grid-container',g); } function cbkWeek(dir) { const next = bkAddDays(bkState.cbkAnchorDate, dir * 7); const today = new Date(); today.setHours(0,0,0,0); if (dir < 0 && next < today) return; if (dir > 0 && bkState.settings) { const advDays = parseInt(bkState.settings.advance_days) || 14; const limit = bkAddDays(today, advDays); if (next > limit) return; } bkState.cbkAnchorDate = next; renderClientBookingGrid(); } function cbkToday() { bkState.cbkAnchorDate = new Date(); renderClientBookingGrid(); } function cbkUpdateNavButtons() { const today = new Date(); today.setHours(0,0,0,0); const prevBtn = el('cbk-prev-week'); const nextBtn = el('cbk-next-week'); const anchor = new Date(bkState.cbkAnchorDate); anchor.setHours(0,0,0,0); const advDays = bkState.settings ? parseInt(bkState.settings.advance_days)||14 : 14; const limit = bkAddDays(today, advDays); if (prevBtn) prevBtn.disabled = anchor <= today; if (nextBtn) nextBtn.disabled = bkAddDays(anchor, 7) > limit; } function cbkClickSlot(date,timeFrom,bay) { bkState.pendingSlot={date,timeFrom,bay}; const d=new Date(date+'T00:00:00'); html('cbk-book-summary',d.toLocaleDateString('en',{weekday:'long',day:'numeric',month:'long'})+' · '+bkFmtTime(timeFrom)); el('cbk-service').value=''; el('cbk-notes').value=''; showModal('cbk-book-modal'); } async function cbkConfirm() { const slot=bkState.pendingSlot; if(!slot)return; const veh=el('cbk-vehicle-id')?.value, svc=el('cbk-service')?.value, notes=el('cbk-notes')?.value; const btn=el('cbk-confirm-btn'); if(btn){btn.textContent='Booking\u2026';btn.disabled=true;} const r=await POST('booking.php?action=book',{date:slot.date,time_from:slot.timeFrom,bay_number:slot.bay,vehicle_id:veh?parseInt(veh):null,services:svc?[svc]:[],notes:notes||null}); if(btn){btn.textContent='Book this slot';btn.disabled=false;} if(r){showToast('Booked! We will confirm shortly.');closeModal('cbk-book-modal');renderClientBookingGrid();} } async function cbkMyBookingDetail(id) { html('bk-detail-content',skeleton(2)); showModal('bk-detail-modal'); const appt=await GET(`appointments.php?id=${id}`); if(!appt)return; const d=new Date(appt.appointment_date+'T00:00:00'); html('bk-detail-content',`

My Booking

${statusBadge(appt.status)}
${d.toLocaleDateString('en',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}
${bkFmtTime(appt.time_from)}
${appt.make?appt.make+' '+appt.model:'—'}
${appt.license_plate||'—'}
${appt.notes?`
${appt.notes}
`:''}
${appt.status!=='cancelled'&&appt.status!=='completed'?``:''} `); } async function cbkCancelBooking(id) { if(!confirm('Cancel this booking?'))return; const r=await bkDELETE(`booking.php?action=cancel&id=${id}`); if(r){showToast('Booking cancelled');closeModal('bk-detail-modal');renderClientBookingGrid();} } async function bkDELETE(url) { try { const res=await fetch(BASE+url,{method:'DELETE',headers:{'Content-Type':'application/json',...(state.token?{Authorization:'Bearer '+state.token}:{})}}); const j=await res.json(); if(!j.success){showToast(j.error||'Error',true);return null;} return j.data; } catch(e){showToast('Network error',true);return null;} } // ═══════════════════════════════════════════ // MALDIVES VEHICLE DATABASE // ═══════════════════════════════════════════ const VEHICLES_DB = { 'Honda (Bike)': { type:'bike', models:['Wave 110','Wave 125','Wave 125i','Wave Alpha','Wave RS','Wave S','Dash 125','Revo','Blade 110','PCX 125','PCX 150','PCX 160','Vario 125','Vario 150','Vario 160','Scoopy','Scoopy i','Click 125i','Click 150i','ADV 150','ADV 160','BeAT','BeAT Street','Activa 125','Activa 6G','CB150R','CB160R','CB300R','CBR150R','CBR250R','CBR300R','CBR500R','CRF150L','CRF250L','CRF300L','XR150L','EX5','EX5 Dream','Super Cub C125'] }, 'Yamaha (Bike)': { type:'bike', models:['NMax 125','NMax 155','NMax 160','Aerox 155','Aerox S','Lexi 125','Filano','Filano Hybrid','FreeGo','FreeGo S','Grand Filano','Xmax 250','Xmax 300','BWSx 125','Fino','Fino 125','Mio','Mio M3','Mio S','Mio Z','Ego S','Ego Avantiz','Y15ZR','Y16ZR','Jupiter MX','Jupiter MX King','MT-15','MT-25','MT-07','MT-09','YZF-R15','YZF-R25','YZF-R3','Exciter 150','Exciter 155','WR155R'] }, 'Suzuki (Bike)': { type:'bike', models:['Address 110','Address V125','Burgman Street 125','Avenis 125','Access 125','GSX-R150','GSX-R250','GSX-S150','Raider R150','Raider J115','Satria F150','Smash 115','Shogun 125'] }, 'Kawasaki (Bike)': { type:'bike', models:['Z125 Pro','Z250','Z400','Z650','Z900','Ninja 125','Ninja 250','Ninja 300','Ninja 400','Ninja 650','W175','W800','KLX 140','KLX 150','KLX 230','KLX 300','Versys 300','Versys 650','J125','J300'] }, 'TVS (Bike)': { type:'bike', models:['Sport 100','Star City','Star City+','Radeon 110','Apache RTR 160','Apache RTR 180','Apache RTR 200 4V','NTORQ 125','XL 100','Ronin 225'] }, 'Bajaj (Bike)': { type:'bike', models:['Pulsar 125','Pulsar 150','Pulsar 160NS','Pulsar 180','Pulsar 200NS','Pulsar 220F','Pulsar RS200','CT100','CT110','Platina 100','Platina 110H','Avenger 150','Avenger 160','Avenger 220','Boxer 100','Boxer 150','Discover 100','Discover 110','Discover 125','Pulsar N160','Pulsar N250','Dominar 250','Dominar 400'] }, 'Hero (Bike)': { type:'bike', models:['Splendor Plus','Splendor iSmart','HF Deluxe','HF 100','Passion Pro','Passion XPro','Glamour','Glamour Xtec','Super Splendor','Xtreme 160R','Xtreme 200R','Xpulse 200','Xpulse 200T','Maestro Edge 110','Maestro Edge 125','Destini 125','Pleasure+'] }, 'Kymco (Bike)': { type:'bike', models:['Like 125','Like 150i','Agility 125','Agility 150','Downtown 125i','Downtown 350i','Racing King 180Fi','Xciting 250','Xciting 400','AK 550'] }, 'Benelli (Bike)': { type:'bike', models:['BN 125','BN 150','BN 302','TNT 135','TNT 150i','TNT 302R','Leoncino 250','Leoncino 500','502C','752S'] }, 'CFMoto (Bike)': { type:'bike', models:['150NK','250NK','300NK','250SR','300SR','400GT','650GT','650NK'] }, 'Loncin (Bike)': { type:'bike', models:['LX110','LX125','GP150','GP250','Voge 150R','Voge 300RR','Voge 500R'] }, Toyota: { type:'car', models:['Corolla','Corolla Axio','Corolla Fielder','Camry','Premio','Allion','Mark X','Crown','Vios','Yaris','Starlet','Land Cruiser','Land Cruiser Prado','Land Cruiser 70','Land Cruiser 200','Land Cruiser 300','Fortuner','RAV4','Rush','Raize','Hilux Surf','Kluger','Harrier','C-HR','Hilux','Hilux Single Cab','Hilux Double Cab','HiAce','HiAce Van','HiAce Commuter','Alphard','Vellfire','Noah','Voxy','Sienta','Wish','Estima','Prius','Prius Alpha','Aqua','Coaster','Dyna'] }, Nissan: { type:'car', models:['Sunny','Bluebird','Sylphy','Teana','Skyline','X-Trail','Patrol','Patrol Y61','Patrol Y62','Pathfinder','Murano','Dualis','Juke','Terra','Kicks','Navara','Frontier','Hardbody','Elgrand','Serena','NV200','Caravan','Note','March'] }, Honda: { type:'car', models:['Civic','Civic Ferio','Accord','City','Fit','Jazz','Grace','CR-V','HR-V','Vezel','Pilot','BR-V','Odyssey','Stepwgn','Stream','Freed','Mobilio','Insight','Fit Hybrid','Vezel Hybrid'] }, Mitsubishi: { type:'car', models:['Pajero','Pajero Sport','Pajero Mini','Outlander','Eclipse Cross','ASX','Triton','L200','Strada','Lancer','Galant','Colt','Mirage','Delica','L300','Express'] }, Suzuki: { type:'car', models:['Jimny','Vitara','Grand Vitara','Escudo','S-Cross','Ignis','Swift','Alto','Cultus','Ciaz','Baleno','Carry','Super Carry','APV','Every'] }, Mazda: { type:'car', models:['Demio','Mazda2','Axela','Mazda3','Atenza','Mazda6','CX-3','CX-5','CX-8','CX-30','Biante','Premacy','MPV','BT-50'] }, Subaru: { type:'car', models:['Forester','Outback','XV','Crosstrek','Impreza','Legacy','WRX','Exiga'] }, Isuzu: { type:'car', models:['D-Max','D-Max Single Cab','D-Max Double Cab','MU-X','Trooper','NPR','NQR','ELF'] }, Hyundai: { type:'car', models:['Accent','Elantra','Sonata','Tucson','Santa Fe','Creta','Venue','i10','i20','i30','H-1','Starex','County'] }, Kia: { type:'car', models:['Picanto','Rio','Cerato','Forte','Sportage','Sorento','Seltos','Carnival','Sedona','Stonic','Sonet'] }, 'Land Rover': { type:'car', models:['Defender','Defender 90','Defender 110','Discovery','Discovery Sport','Discovery 3','Discovery 4','Range Rover','Range Rover Sport','Range Rover Evoque','Freelander'] }, 'Mercedes-Benz':{ type:'car', models:['C-Class','E-Class','S-Class','A-Class','B-Class','GLC','GLE','GLS','GLA','GLB','Sprinter','Vito'] }, BMW: { type:'car', models:['3 Series','5 Series','7 Series','1 Series','X1','X3','X5','X6','X7'] }, Ford: { type:'car', models:['Ranger','Ranger Raptor','Ranger Single Cab','Everest','Explorer','F-150','Transit','Transit Custom'] }, Lexus: { type:'car', models:['IS','ES','LS','GS','RX','LX','NX','GX','LX 570','LX 600'] }, Volkswagen: { type:'car', models:['Golf','Polo','Passat','Tiguan','Touareg','Transporter T5','Transporter T6','Crafter'] }, Audi: { type:'car', models:['A3','A4','A6','Q3','Q5','Q7'] }, Jeep: { type:'car', models:['Wrangler','Cherokee','Grand Cherokee','Compass','Renegade'] }, Daihatsu: { type:'car', models:['Terios','Mira','Move','Boon','Hijet','Hijet Truck','Gran Max'] }, Perodua: { type:'car', models:['Myvi','Axia','Bezza','Aruz','Alza'] }, Proton: { type:'car', models:['Saga','Persona','Exora','X50','X70'] }, Tata: { type:'car', models:['Xenon','Telcoline','Sumo','Indica'] }, Mahindra: { type:'car', models:['Scorpio','Bolero','XUV 500','Thar','Pik Up'] }, Other: { type:'other', models:['Specify below'] }, }; const _BIKE_PRIORITY = ['Honda (Bike)','Yamaha (Bike)','TVS (Bike)','Suzuki (Bike)','Kawasaki (Bike)','Bajaj (Bike)','Hero (Bike)','Kymco (Bike)']; const _CAR_PRIORITY = ['Toyota','Nissan','Honda','Mitsubishi','Suzuki','Mazda','Isuzu']; function getVehicleBrands(typeFilter) { // typeFilter: 'bike' | 'car' | undefined (all) return Object.keys(VEHICLES_DB) .filter(b => !typeFilter || VEHICLES_DB[b].type === typeFilter || VEHICLES_DB[b].type === 'other') .sort((a,b) => { const aIsBike = VEHICLES_DB[a]?.type === 'bike'; const bIsBike = VEHICLES_DB[b]?.type === 'bike'; // Bikes first if (aIsBike && !bIsBike) return -1; if (!aIsBike && bIsBike) return 1; // Within bikes: priority order if (aIsBike && bIsBike) { const ai=_BIKE_PRIORITY.indexOf(a), bi=_BIKE_PRIORITY.indexOf(b); if(ai!==-1&&bi!==-1) return ai-bi; if(ai!==-1) return -1; if(bi!==-1) return 1; return a.localeCompare(b); } // Within cars: priority order const ai=_CAR_PRIORITY.indexOf(a), bi=_CAR_PRIORITY.indexOf(b); if(ai!==-1&&bi!==-1) return ai-bi; if(ai!==-1) return -1; if(bi!==-1) return 1; if(a==='Other') return 1; if(b==='Other') return -1; return a.localeCompare(b); }); } function buildMakeOptions(sel='', typeFilter) { const brands = getVehicleBrands(typeFilter); // Group bikes and cars with optgroups const bikes = brands.filter(b => VEHICLES_DB[b]?.type === 'bike'); const cars = brands.filter(b => VEHICLES_DB[b]?.type === 'car'); const other = brands.filter(b => VEHICLES_DB[b]?.type === 'other'); let opts = ''; if (bikes.length) { opts += ''; opts += bikes.map(b=>``).join(''); opts += ''; } if (cars.length) { opts += ''; opts += cars.map(b=>``).join(''); opts += ''; } other.forEach(b => { opts += ``; }); return opts; } function buildModelOptions(brand, sel='') { if(!brand) return ''; return ''+(VEHICLES_DB[brand]?.models||[]).map(m=>``).join(''); } function onVehicleTypeChange(type) { const makeEl = el('nv-make'); if (makeEl) makeEl.innerHTML = buildMakeOptions('', type); const modelEl = el('nv-model'); if (modelEl) modelEl.innerHTML = ''; const custEl = el('nv-model-custom-wrap'); if (custEl) custEl.style.display = 'none'; } function onMakeChange(makeId, modelId, customWrapId) { const brand=el(makeId)?.value, modelEl=el(modelId), custEl=el(customWrapId); if(modelEl) modelEl.innerHTML=buildModelOptions(brand); if(custEl) custEl.style.display='none'; } function onModelChange(selectEl, customWrapId) { const custEl=el(customWrapId); if(custEl) custEl.style.display=selectEl.value==='Specify below'?'':'none'; } return { init, navigate, goTo, renderClients, issueCredentials, reissueCredentials, resetClientPassword, savePhoneAndIssue, doIssueCredentials, downloadCredCard, exportCredCardSVG, copyCredText, buildCredMessage, shareOnWhatsApp, shareOnViber, shareOnTelegram, confirmAppt, declineAppt, togglePromo, deletePromo, markPaid, filterClients, viewClient, showModal, closeModal, calDayClick, calPrev, calNext, showToast, saveNewClient, saveBulkClients, parseBulkCSV, clearBulkCSV, saveNewVehicle, saveNewPromo, saveBlockSlot, saveInvoice, searchVehicle, showSection, onInvClientChange, previewPromoImage, updatePromoPreview, previewTier, toggleBenefitsEdit, cancelBenefitsEdit, filterServices, editService, saveServicePrice, deleteService, openAddLineModal, prefillLineItem, addLineItem, removeLineItem, renderInvoiceLines, toggleLineEdit, applyLineEdit, resetLinePrice, addBenefitRow, saveBenefits, flipBack, logout, clientLogout, onMakeChange, onModelChange, onVehicleTypeChange, buildMakeOptions, buildModelOptions, // Portal cdNav, renderClientAppointments, renderClientVehicles, saveClientVehicle, onCvTypeChange, setCardTexture, initCardTexture, loadBookingSlots, selectSlot, submitClientBooking, cancelClientAppt, renderClientInvoices, renderClientAccount, saveExpense, // Booking grid renderBookingGrid, bkWeek, bkToday, bkSetBay, bkShowDetail, bkAdminClickSlot, bkUpdateNavButtons, bkOpenAdminBook, bkOpenBlock, bkConfirm, bkDecline, bkCancelAdmin, blkTypeChange, blkSubmit, bkSaveSettings, abkSearchClient, abkSelectClient, abkSubmit, renderClientBookingGrid, cbkWeek, cbkToday, cbkUpdateNavButtons, cbkClickSlot, cbkConfirm, cbkMyBookingDetail, cbkCancelBooking, bkLoadSettings, }; })(); document.addEventListener('DOMContentLoaded', App.init);