🔑 Client Credentials
PINGARAGE
Member Portal
Getting started
1. Scan QR code
2. Enter password above
3. Change password when prompted
4. View services & rewards
+9607563060 · Kaamineege, sh.kanditheemu
⬇ Download card image
WhatsApp
Viber
Telegram
📋 Copy as text message
w[0]).join('').toUpperCase());
html('cd-card-name', (name||'').toUpperCase());
html('cd-card-id', 'PG-' + String(clientId||0).padStart(6,'0') + ' · LOYALTY');
initCardTexture();
if (!clientId) return;
// Fetch client data
const data = await GET('clients.php?id=' + clientId);
if (!data) return;
// Update loyalty card
const tierClass = {Bronze:'lc-bronze',Silver:'lc-silver',Gold:'lc-gold',Platinum:'lc-platinum'}[data.tier]||'lc-bronze';
const discountMap = {Bronze:0, Silver:5, Gold:15, Platinum:20};
const card = el('cd-loyalty-card');
if (card) card.className = 'loyalty-card-inner ' + tierClass;
const tierLabel = (data.tier||'Bronze');
html('cd-tier-badge', tierLabel.toUpperCase());
const lcCard = el('cd-loyalty-card');
if (lcCard) {
lcCard.classList.remove('lc-bronze','lc-silver','lc-gold','lc-platinum');
lcCard.classList.add('lc-' + tierLabel.toLowerCase());
}
html('cd-points', fmt(data.points||0));
html('cd-visits', data.vehicle_count||0);
html('cd-discount', (discountMap[data.tier]||0) + '%');
html('cd-stat-points', fmt(data.points||0) + ' pts');
const bal = parseFloat(data.outstanding_balance||0);
html('cd-stat-balance', bal > 0 ? 'MVR ' + fmt(bal) : 'Clear');
const balEl = el('cd-stat-balance');
if (balEl) balEl.style.color = bal > 0 ? 'var(--red-dark)' : 'var(--green-dark)';
// Progress bar
const tierPts = {Bronze:0, Silver:1000, Gold:2000, Platinum:3000};
const nextPts = {Bronze:1000, Silver:2000, Gold:3000, Platinum:3000};
const pts = data.points || 0;
const curr = tierPts[data.tier] || 0;
const next = nextPts[data.tier] || 3000;
const pct = Math.min(100, Math.round((pts - curr) / (next - curr) * 100));
const fill = el('cd-prog-fill');
if (fill) setTimeout(() => fill.style.width = pct + '%', 100);
html('cd-prog-pct', pct + '%');
html('cd-prog-label', pts >= 3000 ? 'Max tier reached 🏆' : fmt(next - pts) + ' pts to next tier');
// Vehicles
const vehicles = await GET('vehicles.php?client_id=' + clientId);
if (vehicles && vehicles.length) {
html('cd-vehicles', vehicles.map(v => `
${v.make} ${v.model}
${v.license_plate||'—'} · ${v.year||''} ${v.fuel_type||''}
`).join(''));
} else {
html('cd-vehicles', 'No vehicles registered yet.
');
}
// Service history
const history = data.service_history || [];
if (history.length) {
html('cd-history', history.slice(0,5).map((h,i) => `
${h.services||'Service'}
${formatDate(h.created_at)} · MVR ${fmt(h.total_amount)}
${statusBadge(h.status)}
`).join(''));
} else {
html('cd-history', 'No service history yet.
');
}
// Outstanding invoices
if (bal > 0) {
const wrap = el('cd-outstanding-wrap');
if (wrap) wrap.style.display = '';
html('cd-outstanding', `
MVR ${fmt(bal)}
Please contact us to settle your balance
+9607563060
`);
}
// Active promos for this tier
const promos = await GET('promotions.php?active=1&tier=' + (data.tier||'Bronze'));
if (promos && promos.length) {
const wrap2 = el('cd-promos-wrap');
if (wrap2) wrap2.style.display = '';
html('cd-promos', promos.map(p => `
`).join(''));
}
renderClientAppointments();
const vehSel=el('booking-vehicle');
if(vehSel&&vehicles?.length)vehSel.innerHTML='No specific vehicle '+vehicles.map(v=>`${v.make} ${v.model} (${v.license_plate||''}) `).join('');
// Reset to Home tab when dashboard first loads
document.querySelectorAll('.cn-item').forEach(n => n.classList.remove('active'));
const homeBtn = document.querySelector('.cn-item[data-cn-page="home"]');
if (homeBtn) homeBtn.classList.add('active');
// Show home sections, hide sub-page sections
cdAllSections.forEach(id => {
const e2 = el(id);
if (!e2) return;
e2.style.display = ['cd-invoices-wrap','cd-account-wrap'].includes(id) ? 'none' : '';
});
}
// ── LOYALTY CARD TEXTURE PICKER ──────────────────────────────────────
let _lcTexture = 'carbon';
function setCardTexture(tex) {
_lcTexture = tex;
const card = el('cd-loyalty-card');
if (!card) return;
card.classList.remove('lc-tex-carbon','lc-tex-metal','lc-tex-tyre');
card.classList.add('lc-tex-' + tex);
['carbon','metal','tyre'].forEach(t => {
const btn = el('lc-btn-' + t);
if (btn) btn.classList.toggle('active', t === tex);
});
localStorage.setItem('pg_card_tex', tex);
}
function initCardTexture() {
const saved = localStorage.getItem('pg_card_tex') || 'carbon';
setCardTexture(saved);
}
function clientLogout() {
state.token = null; state.user = null;
state.loginMode = 'admin';
state.changePasswordMode = 'admin';
localStorage.removeItem('pg_token');
localStorage.removeItem('pg_user');
localStorage.removeItem('pg_loginmode');
navigate('login');
}
/* ── CLIENT NAV SECTIONS ── */
const cdSections = {
home: ['cd-loyalty-wrap','cd-vehicles-wrap','cd-history-wrap','cd-promos-wrap','cd-appointments-wrap'],
appointments: ['cd-appointments-wrap'],
invoices: ['cd-invoices-wrap'],
vehicles: ['cd-vehicles-wrap'],
account: ['cd-account-wrap'],
};
// All toggleable section IDs (excludes welcome banner which is always shown)
const cdAllSections = [
'cd-loyalty-wrap','cd-vehicles-wrap','cd-history-wrap',
'cd-promos-wrap','cd-appointments-wrap','cd-invoices-wrap','cd-account-wrap',
];
function cdNav(section, btn) {
// Update active button state
document.querySelectorAll('.cn-item').forEach(n => n.classList.remove('active'));
if (btn) btn.classList.add('active');
state.cdSection = section;
// Show/hide sections
if (section === 'home') {
// Home shows everything except invoices and account
cdAllSections.forEach(id => {
const e2 = el(id);
if (!e2) return;
e2.style.display = ['cd-invoices-wrap','cd-account-wrap'].includes(id) ? 'none' : '';
});
} else {
// Only show the sections for this tab
cdAllSections.forEach(id => {
const e2 = el(id);
if (!e2) return;
e2.style.display = (cdSections[section] || []).includes(id) ? '' : 'none';
});
// Lazy-load section data
if (section === 'appointments') renderClientAppointments();
if (section === 'invoices') renderClientInvoices();
if (section === 'vehicles') renderClientVehicles();
if (section === 'account') renderClientAccount();
}
// Welcome banner always visible
const banner = el('cd-welcome-banner');
if (banner) banner.style.display = '';
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function renderClientAppointments() {
html('cd-appointments',skeleton(2));
const appts=await GET('portal_appointments.php');
if(!appts){html('cd-appointments','Could not load.
');return;}
if(!appts.length){html('cd-appointments','No appointments yet.
');return;}
html('cd-appointments',appts.map(a=>`${formatDate(a.appointment_date)} at ${a.time_from}
${a.make?a.make+' '+a.model:'Vehicle not set'} · ${a.service_names||a.notes||'—'}
${statusBadge(a.status)}${['pending','confirmed'].includes(a.status)?`Cancel `:''}
`).join(''));
}
async function loadBookingSlots(date) {
if(!date)return;
html('booking-slots','Loading slots…
');
const data=await GET(`portal_appointments.php?action=slots&date=${date}`);
if(!data||!data.available){html('booking-slots','Not available.
');return;}
state.bookingSlots=data.slots; state.selectedSlot=null;
html('booking-slots',`Pick a time
${data.slots.map(s=>`
${s.time_from}
`).join('')}
`);
}
function selectSlot(from,to,e2){document.querySelectorAll('.slot-btn').forEach(b=>b.classList.remove('selected'));e2.classList.add('selected');state.selectedSlot={from,to};}
async function submitClientBooking() {
const date=el('booking-date')?.value,vehicle=el('booking-vehicle')?.value,notes=el('booking-notes')?.value,svcEl=el('booking-service'),services=svcEl?.value?[svcEl.value]:[];
if(!date){showToast('Please select a date',true);return;}
if(!state.selectedSlot){showToast('Please select a time slot',true);return;}
const btn=el('btn-book-appt');if(btn){btn.textContent='Booking…';btn.disabled=true;}
const r=await POST('portal_appointments.php',{appointment_date:date,time_from:state.selectedSlot.from,time_to:state.selectedSlot.to,vehicle_id:vehicle||null,notes,services});
if(btn){btn.textContent='Book appointment';btn.disabled=false;}
if(r){showToast('Booked!');closeModal('book-appt-modal');renderClientAppointments();if(el('booking-date'))el('booking-date').value='';if(el('booking-notes'))el('booking-notes').value='';html('booking-slots','');state.selectedSlot=null;}
}
async function cancelClientAppt(id){if(!confirm('Cancel?'))return;const r=await PUT(`portal_appointments.php?id=${id}`,{status:'cancelled'});if(r){showToast('Cancelled');renderClientAppointments();}}
async function renderClientVehicles() {
const wrap = el('cd-vehicles-wrap');
if (!wrap) return;
const clientId = state.user?.id;
if (!clientId) return;
// Only reload if currently empty
const inner = el('cd-vehicles');
if (inner && inner.children.length && !inner.querySelector('.skeleton')) return;
const vehicles = await GET('vehicles.php?client_id=' + clientId);
if (!vehicles || !vehicles.length) {
if (inner) inner.innerHTML = '' +
'
' +
'
No vehicles yet
' +
'
+ Add your vehicle ' +
'
';
return;
}
if (inner) inner.innerHTML = vehicles.map(v => `
${v.make} ${v.model}${v.year ? ' ' + v.year : ''}
${v.license_plate || '—'}
`).join('');
}
async function renderClientInvoices() {
const wrap=el('cd-invoices-wrap');if(!wrap)return;
wrap.innerHTML='My Invoices
'+skeleton(3)+'
';
const invs=await GET(`invoices.php?client_id=${state.user?.id}&limit=20`);
if(!invs?.invoices?.length){if(el('cd-invoices-list'))el('cd-invoices-list').innerHTML='No invoices.
';return;}
if(el('cd-invoices-list'))el('cd-invoices-list').innerHTML=invs.invoices.map(i=>`#${i.invoice_number}
${formatDate(i.created_at)}
${statusBadge(i.status)}
MVR ${fmt(i.total_amount)}
View `).join('');
}
async function renderClientAccount(){
const wrap = el('cd-account-wrap'); if(!wrap) return;
const u = state.user || {};
const clientId = u.id;
const loyalty = clientId ? await GET('loyalty.php?client_id=' + clientId) : null;
const pts = loyalty?.points ?? u.points ?? 0;
const tier = loyalty?.tier_name || u.tier || 'Bronze';
const discount = loyalty?.tier_discount ?? 0;
const nextTier = loyalty?.next_tier_name || null;
const ptsToNext= loyalty?.points_to_next ?? 0;
const progress = loyalty?.progress_pct ?? 0;
const tierColors = {bronze:'#c49a6c',silver:'#a0aec0',gold:'#ecc94b',platinum:'#8b5cf6'};
const tierColor = tierColors[tier.toLowerCase()] || '#c49a6c';
wrap.innerHTML = `
My Account
${(u.name||'?')[0].toUpperCase()}
${u.name||'—'}
${u.phone||''}
${u.email||''}
Membership tier
${tier}
Total points
${Number(pts).toLocaleString()}
Tier discount
${discount}% off
${nextTier ? 'Progress to ' + nextTier : 'Tier progress'}
${Number(pts).toLocaleString()} pts
${ptsToNext > 0 ? Number(ptsToNext).toLocaleString() + ' pts to ' + nextTier : 'Max tier reached!'}
How points work
Earn 1 point for every MVR 1 spent
Higher tier = better discounts on services
Points never expire while account is active
Sign out
`;
}
// ── CLIENT: ADD VEHICLE ───────────────────────────────────────────────
function onCvTypeChange(type) {
const makeEl = el('cv-make');
if (makeEl) makeEl.innerHTML = buildMakeOptions('', type);
const modelEl = el('cv-model');
if (modelEl) modelEl.innerHTML = 'Select make first ';
const custEl = el('cv-model-custom-wrap');
if (custEl) custEl.style.display = 'none';
}
async function saveClientVehicle() {
const typeRadio = document.querySelector('input[name="cv-type"]:checked');
if (!typeRadio) { showToast('Please select vehicle type', true); return; }
const makeRaw = el('cv-make')?.value?.trim();
const make = makeRaw ? makeRaw.replace(' (Bike)', '').trim() : '';
const modelSel = el('cv-model')?.value;
const model = modelSel === 'Specify below'
? (el('cv-model-custom')?.value?.trim() || '')
: (modelSel || '').trim();
const year = el('cv-year')?.value;
const plate = el('cv-plate')?.value?.trim()?.toUpperCase();
const color = el('cv-color')?.value?.trim();
if (!make) { showToast('Please select a make', true); return; }
if (!model) { showToast('Please select a model', true); return; }
if (!plate) { showToast('License plate is required', true); return; }
const clientId = state.user?.id;
if (!clientId) { showToast('Not logged in', true); return; }
const btn = el('cv-submit-btn');
if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; }
const r = await POST('vehicles.php', {
client_id: clientId,
make,
model,
year: year || null,
license_plate: plate,
color: color || null,
fuel_type: typeRadio.value === 'bike' ? 'Petrol' : 'Petrol',
notes: typeRadio.value === 'bike' ? '[Motorcycle/Scooter]' : '',
});
if (btn) { btn.textContent = 'Save vehicle'; btn.disabled = false; }
if (r) {
showToast('Vehicle added successfully');
closeModal('client-add-vehicle-modal');
// Reset form
document.querySelectorAll('input[name="cv-type"]').forEach(rb => rb.checked = false);
['cv-make','cv-model','cv-year','cv-plate','cv-color'].forEach(id => {
const e2 = el(id); if (e2) e2.value = '';
});
const custW = el('cv-model-custom-wrap');
if (custW) custW.style.display = 'none';
// Force reload of vehicles list
const inner = el('cd-vehicles');
if (inner) inner.innerHTML = '';
renderClientVehicles();
// Also update booking vehicle selects if open
const bvSel = el('cbk-vehicle-id');
if (bvSel) {
const vehs = await GET('vehicles.php?client_id=' + clientId);
if (vehs?.length) {
bvSel.innerHTML = 'No specific vehicle ' +
vehs.map(v => `${v.make} ${v.model} (${v.license_plate||''}) `).join('');
}
}
}
}
async function saveExpense(){
const desc=el('exp-desc')?.value?.trim(),amount=el('exp-amount')?.value,cat=el('exp-category')?.value||'Other',date=el('exp-date')?.value,notes=el('exp-notes')?.value;
if(!desc){showToast('Description required',true);return;}if(!amount){showToast('Amount required',true);return;}if(!date){showToast('Date required',true);return;}
const r=await POST('finance.php?action=add_expense',{description:desc,amount,category:cat,expense_date:date,notes});
if(r){showToast('Expense saved');closeModal('add-expense-modal');['exp-desc','exp-amount','exp-notes'].forEach(id=>{const e2=el(id);if(e2)e2.value='';});renderFinance();}
}
function render(page) {
const fns = {
dashboard: renderDashboard, 'client-dashboard': renderClientDashboard, clients: renderClients,
profile: renderProfile, vehicles: renderVehicles,
inventory: renderInventory, invoices: renderInvoices,
loyalty: renderLoyalty, finance: renderFinance, services: renderServices,
promos: renderPromos, appointments: renderAppointments,
calendar: renderCalendar,
booking: () => bkLoadSettings().then(() => renderBookingGrid()),
'client-booking': renderClientBookingGrid,
};
if (fns[page]) fns[page]();
}
/* ── HELPERS ── */
function el(id) { return document.getElementById(id); }
function html(id, h) { const e = el(id); if (e) e.innerHTML = h; }
function fmt(n) { return Number(n || 0).toLocaleString('en', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); }
function initials(n) { return (n||'').split(' ').slice(0,2).map(w=>w[0]).join('').toUpperCase(); }
function todayStr() { return new Date().toISOString().split('T')[0]; }
function formatDate(d){ if (!d) return ''; return new Date(d).toLocaleDateString('en', { day:'numeric', month:'short', year:'numeric' }); }
function formatTime(t){ if (!t) return '--'; const [h,m]=t.split(':'); const hh=parseInt(h); return `${hh>12?hh-12:hh||12}:${m}`; }
function ampm(t) { if (!t) return ''; return parseInt(t.split(':')[0])>=12?'PM':'AM'; }
function greeting() { const h=new Date().getHours(); return h<12?'morning':h<17?'afternoon':'evening'; }
function avatarBg(tier) {
return {Gold:'var(--amber-light)',Silver:'var(--ocean-light)',Platinum:'var(--green-light)',Bronze:'var(--teal-light)'}[tier]||'var(--teal-light)';
}
function avatarClr(tier) {
return {Gold:'var(--amber-dark)',Silver:'#0C447C',Platinum:'var(--green-dark)',Bronze:'var(--teal-deep)'}[tier]||'var(--teal-deep)';
}
function statusBadge(s) {
const map = { active:'green',blocked:'red',paid:'green',draft:'amber',overdue:'red',
unpaid:'amber',ok:'green',low:'amber',out:'red',confirmed:'teal',pending:'amber',cancelled:'red' };
return `${s?s.charAt(0).toUpperCase()+s.slice(1):'—'} `;
}
function skeleton(n=3) {
return Array(n).fill(0).map((_,i)=>`
`).join('');
}
function showToast(msg, isError=false) {
const t = el('toast');
t.textContent = msg;
t.style.background = isError ? 'var(--red-dark)' : 'var(--text)';
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2600);
}
function showInputError(id, msg) {
const inp = el(id);
if (!inp) return;
const orig = inp.placeholder;
inp.classList.add('error');
inp.placeholder = msg;
setTimeout(() => { inp.classList.remove('error'); inp.placeholder = orig; }, 2800);
}
/* ── FLIP CARD ── */
function flipToPasswordChange() {
const card = el('login-flip-card');
if (card) card.classList.add('flipped');
}
function flipBack() {
const card = el('login-flip-card');
if (card) card.classList.remove('flipped');
}
/* ── AUTH ── */
async function validateLogin() {
const identifier = el('login-email')?.value.trim();
const password = el('login-pass')?.value;
if (!identifier) { showGlassError('login-email', 'Enter your email'); return; }
if (!password) { showGlassError('login-pass', 'Enter your password'); return; }
const btn = el('btn-login');
btn.textContent = 'Signing in…'; btn.disabled = true;
// Try admin login first
let result = await POST('auth.php?action=login', { email: identifier, password });
// If admin login fails, try client portal login automatically
if (!result || result._authError) {
const portalResult = await POST('portal_auth.php?action=login', { phone: identifier, password });
if (portalResult && !portalResult._authError && portalResult.token) {
// Client portal login succeeded
result = portalResult;
state.loginMode = 'client';
state.changePasswordMode = 'client';
}
// If both fail, result keeps the admin error for display
}
btn.textContent = 'Sign in to Pingarage';
btn.disabled = false;
if (!result) return;
// Map portal login client data into the shared user state
// Inject role so returning-user routing works correctly
if (!result.user && result.client) {
result.user = { ...result.client, role: 'client' };
}
// If admin login succeeded, ensure role is set
if (result.user && !result.user.role) {
result.user.role = state.loginMode === 'client' ? 'client' : 'admin';
}
// Handle wrong credentials
if (result._authError) {
showGlassError('login-pass', result.error || 'Invalid credentials');
// Shake the card
const card = el('login-flip-card');
if (card) {
card.style.animation = 'shake 0.4s ease';
setTimeout(() => card.style.animation = '', 500);
}
el('login-pass').value = '';
el('login-pass').focus();
return;
}
if (!result.token) { showGlassError('login-pass', 'Login failed — try again'); return; }
state.token = result.token;
state.user = result.user || result.client || null;
// Ensure role is always set on the stored user object
if (state.user && !state.user.role) {
state.user.role = state.loginMode === 'client' ? 'client' : 'admin';
}
localStorage.setItem('pg_token', result.token);
localStorage.setItem('pg_user', JSON.stringify(state.user));
localStorage.setItem('pg_loginmode', state.loginMode);
if (result.must_change_password) {
// Store which endpoint to use for password change
state.changePasswordMode = state.loginMode;
const tempField = el('pw-temp');
if (tempField) {
tempField.value = password; // pre-fill temp password
tempField.setAttribute('readonly','true');
tempField.style.opacity = '0.75';
}
// Update flip-back card for client mode
if (state.loginMode === 'client') {
const clientInfo = result.client;
const titleEl = document.querySelector('.flip-back h3, .flip-back [style*="font-weight:700"]');
const subEl = document.querySelector('.flip-back [style*="0.78rem"]');
if (subEl) subEl.textContent = 'Welcome ' + (clientInfo?.name||'') + ' — please set your new password';
}
flipToPasswordChange();
} else {
// Navigate to appropriate home screen based on role
const userRole = (state.user && state.user.role) || state.loginMode || 'admin';
if (userRole === 'client') {
navigate('client-dashboard');
} else {
navigate('dashboard');
}
}
}
function showGlassError(id, msg) {
const inp = el(id);
if (!inp) return;
// Add red border
inp.style.borderColor = '#ff6b6b';
inp.style.background = 'rgba(255,100,100,0.12)';
// Show error message below the input
let errEl = document.getElementById(id + '-err');
if (!errEl) {
errEl = document.createElement('div');
errEl.id = id + '-err';
errEl.style.cssText = 'font-size:0.72rem;color:#ff9999;margin-top:4px;font-weight:500;';
inp.parentNode.appendChild(errEl);
}
errEl.textContent = '⚠ ' + msg;
// Clear after 3s
setTimeout(() => {
inp.style.borderColor = '';
inp.style.background = '';
if (errEl) errEl.textContent = '';
}, 3000);
}
async function validatePasswordChange() {
const current = el('pw-temp')?.value;
const newpw = el('pw-new')?.value;
const confirm = el('pw-conf')?.value;
if (!current) { showGlassError('pw-temp', 'Enter temporary password'); return; }
if (!newpw || newpw.length < 8) { showGlassError('pw-new', 'Min. 8 characters'); return; }
if (newpw !== confirm) { showGlassError('pw-conf', 'Passwords do not match'); return; }
const btn = el('btn-setpw');
btn.textContent = 'Saving…'; btn.disabled = true;
// Use correct endpoint based on who is changing password
const changeEndpoint = state.changePasswordMode === 'client'
? 'portal_auth.php?action=change_password'
: 'auth.php?action=change_password';
// For client mode without token — try logging in first
if (state.changePasswordMode === 'client' && !state.token) {
const loginResult = await POST('portal_auth.php?action=login', {
phone: el('login-email')?.value || '',
password: current,
});
if (loginResult && loginResult.token) {
state.token = loginResult.token;
localStorage.setItem('pg_token', loginResult.token);
} else {
showGlassError('pw-temp', 'Invalid credentials — check your username');
if (btn) { btn.textContent = 'Set password & continue'; btn.disabled = false; }
return;
}
}
const result = await POST(changeEndpoint, {
current_password: current, new_password: newpw, confirm_password: confirm,
});
btn.textContent = 'Set password & continue'; btn.disabled = false;
if (result && !result._authError) {
if (state.changePasswordMode === 'client') {
showToast('Password set! Welcome to Pingarage 🎉');
// Reset form fields
const emailEl = el('login-email');
if (emailEl) { emailEl.value=''; emailEl.removeAttribute('readonly'); emailEl.style.opacity=''; }
const tempEl = el('pw-temp');
if (tempEl) { tempEl.value=''; tempEl.removeAttribute('readonly'); tempEl.style.opacity=''; }
['pw-new','pw-conf'].forEach(id => { const e=el(id); if(e) e.value=''; });
// Navigate to client dashboard
navigate('client-dashboard');
} else {
showToast('Password updated! Welcome to Pingarage 🎉');
navigate('dashboard');
}
}
}
function logout() {
state.token = null; state.user = null;
state.loginMode = 'admin';
localStorage.removeItem('pg_token');
localStorage.removeItem('pg_user');
localStorage.removeItem('pg_loginmode');
navigate('login');
}
/* ══════════════════════════════════════════
RENDER FUNCTIONS
══════════════════════════════════════════ */
/* ── DASHBOARD ── */
async function renderDashboard() {
html('dash-greeting', `
Good ${greeting()}, ${state.user?.name?.split(' ')[0]||'Admin'}
${new Date().toLocaleDateString('en',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}
`);
html('dash-stats', skeleton(4));
const [stats, finSummary] = await Promise.all([
GET('dashboard.php?action=stats'),
GET(`finance.php?action=summary&month=${new Date().getMonth()+1}&year=${new Date().getFullYear()}`),
]);
if (!stats) return;
const trendArrow = (pct) => {
if (pct === null || pct === undefined) return '';
return `${pct>=0?'▲':'▼'} ${Math.abs(pct)}% vs last month
`;
};
html('dash-stats', `
${stats.active_clients}
Active clients
MVR ${fmt(stats.today_revenue)}
Today's revenue
${trendArrow(finSummary?.revenue_change_pct)}
${stats.pending_appts}
Pending appts
MVR ${fmt(stats.outstanding)}
Outstanding
${stats.low_stock>0?`${stats.low_stock} item${stats.low_stock>1?'s':''} need restocking
${stats.low_stock} `:''}
`);
const activity = await GET('dashboard.php?action=recent_activity&limit=5');
if (activity) {
html('dash-activity-list', activity.map(i=>`
${initials(i.client_name)}
${i.client_name||'Unknown'}
#${i.invoice_number} · ${formatDate(i.created_at)}
${statusBadge(i.status)}
`).join(''));
}
}
/* ── CREDENTIAL STATUS HELPER ── */
function renderCredButton(c) {
// portal_active=1, is_temp_password=1 → issued, not yet activated
// portal_active=1, is_temp_password=0 → active (client changed password)
// portal_active=0 or null → no credentials yet
if (!c.portal_active) {
// No credentials yet — show Issue button
return `
Issue creds
`;
}
if (c.is_temp_password == 1) {
// Credentials issued but not yet activated (client hasn't logged in)
return `
Pending activation
Resend
`;
}
// is_temp_password=0 → client activated their account
return `
✓ Active
Reset pass
`;
}
/* ── CLIENTS ── */
async function renderClients() {
html('clients-list', skeleton(4));
const search = el('client-search')?.value||'';
const tier = state.filterChip==='gold'?'Gold':state.filterChip==='silver'?'Silver':state.filterChip==='bronze'?'Bronze':'';
const status = ['active','blocked'].includes(state.filterChip)?state.filterChip:'';
let url = 'clients.php?limit=50';
if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${status}`;
if (tier) url += `&tier=${tier}`;
const result = await GET(url);
if (!result) return;
state.clients = result.clients||[];
html('clients-list', state.clients.length ? state.clients.map(c=>`
${initials(c.name)}
${c.name}
PG-${String(c.id).padStart(4,'0')}
${c.phone||c.email||'—'} · ${c.tier} tier${c.notes?` · ${c.notes}`:''}
${statusBadge(c.status)}
${renderCredButton(c)}
`).join('')
: `
No clients found
Add a client to get started.
`);
}
/* ── CLIENT PROFILE ── */
async function renderProfile() {
const c = state.currentClient;
if (!c) { navigate('clients'); return; }
html('profile-topbar-title', c.name);
html('profile-card', skeleton(3));
const data = await GET(`clients.php?id=${c.id}`);
if (!data) return;
html('profile-card', `
${fmt(data.points)}
Points
${data.vehicle_count||0}
Vehicles
${data.outstanding_balance>0?'MVR '+fmt(data.outstanding_balance):'Clear'}
Balance
`);
// Fill outstanding card
const bal = parseFloat(data.outstanding_balance||0);
const outAmt = el('profile-outstanding-amount');
if (outAmt) {
outAmt.textContent = bal > 0 ? 'MVR ' + fmt(bal) : 'No outstanding balance';
outAmt.style.color = bal > 0 ? 'var(--red-dark)' : 'var(--green-dark)';
}
const outCard = el('profile-outstanding-card');
if (outCard) outCard.style.display = bal > 0 ? '' : 'none';
if (data.service_history?.length) {
html('profile-history', data.service_history.map((h,i)=>`
${h.services||'Service'}
${formatDate(h.created_at)} · MVR ${fmt(h.total_amount)} · ${statusBadge(h.status)}
`).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:''}
History
`).join(''));
} else {
html('profile-vehicles-list', `
No vehicles yet.
+ Add vehicle
`);
}
}
/* ── 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}
History
`).join('')
: `
`) +
`
+ Add vehicle
`);
}
/* ── 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 = '
Select category ' +
cats.filter(c => catFilter === 'all' || c.type === catFilter || catFilter === 'service' || catFilter === 'part')
.map(c => `
${c.name} `).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)}
Edit
Del
`).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('')
: `
`);
}
/* ── 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'?`
Mark paid `:''}
${' '}
`).join('')
: `
`);
}
/* ── 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=>`
`).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||'📦'} MVR ${fmt(e.total)}
${e.pct}%
`).join('')}
Total MVR ${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=>`
`).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||'—'}
Confirm
Decline
`).join('')
: `
`);
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 = '
— select service — ' +
svcs.map(s => `
${s.name} — MVR ${fmt(s.default_price)} `).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)}
Price
×
Adjust price for this invoice only — preset price (MVR ${fmt(line._basePrice ?? line.price)}) stays unchanged
`).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 = '
Select client first ';
return;
}
sel.innerHTML = '
Loading vehicles… ';
const vehicles = await GET('vehicles.php?client_id=' + clientId);
if (vehicles && vehicles.length) {
sel.innerHTML = '
Select vehicle ▾ ' +
vehicles.map(v => `
${v.make} ${v.model} · ${v.license_plate||'—'} `).join('');
} else {
sel.innerHTML = '
No vehicles — add one first ';
}
}
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 = '
Select client ▾ ' +
result.clients.map(c=>`
PG-${String(c.id).padStart(4,'0')} · ${c.name} `).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 = '
Select vehicle ▾ ' +
result.map(v=>`
${v.make} ${v.model} · ${v.license_plate} `).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='
Select type first ';
const cvModel=el('cv-model'); if(cvModel) cvModel.innerHTML='
Select make first ';
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='
Select type first ';
const modelEl=el('nv-model');
if(modelEl) modelEl.innerHTML='
Select make first ';
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)=>
`
${name} `
).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 += ``;
});
// 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 += `
${cell.client_name||'Booking'}${svc}
`;
} 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',`