Shop CRM
Manuals
?
Loading...
technician
Loading...
// ── Stage photo upload (works for all 3 stages) ── async function uploadStagePhotos(input, jobId, photoType) { const files = Array.from(input.files); if (!files.length) return; const { data: job } = await sb.from('jobs').select('vehicle_id').eq('id', jobId).single(); const typeLabels = { intake: 'Before-work photo', work: 'Work photo', completion: 'Release photo' }; let uploaded = 0; for (const file of files) { const path = jobId + '/' + photoType + '/' + Date.now() + '_' + uploaded + '.' + file.name.split('.').pop(); const { data, error } = await sb.storage.from('job-photos').upload(path, file, { upsert: true, contentType: file.type }); if (!error) { const { data: urlData } = sb.storage.from('job-photos').getPublicUrl(path); const url = urlData?.publicUrl; if (url) { await sb.from('vehicle_photos').insert({ job_id: jobId, vehicle_id: job?.vehicle_id, photo_url: url, photo_type: photoType, taken_by: currentUser.id }); await logActivity(jobId, 'photo_added', typeLabels[photoType] + ' uploaded', '', { photo_url: url, stage: photoType }); uploaded++; } } } toast('✅ ' + uploaded + ' photo' + (uploaded!==1?'s':'') + ' uploaded'); viewJob(jobId); // Refresh job view } // ── Release photo modal before completing job ── async function openReleasePhotos(jobId) { const { data: existing } = await sb.from('vehicle_photos').select('id,photo_url').eq('job_id', jobId).eq('photo_type','completion').limit(1); openModal('📷 Before-Release Photos', `
Upload photos of the completed vehicle before releasing to the customer. These protect both you and the customer.
${existing?.length ? `
✅ ${existing.length} release photo(s) already on file
` : ''}
`, ` `); } async function handleReleaseUpload(input, jobId) { const status = document.getElementById('release-upload-status'); if (status) status.textContent = 'Uploading...'; await uploadStagePhotos(input, jobId, 'completion'); if (status) status.textContent = '✅ Photos saved! Click "Done" to complete the job.'; } // ══════════════════════════════════════════════════════════════════ // JOB ACTIVITY LOG // ══════════════════════════════════════════════════════════════════ // Core helper — call this from anywhere to log an event async function logActivity(jobId, eventType, title, detail='', meta={}) { if (!jobId) return; try { await sb.from('job_activity').insert({ job_id: jobId, event_type: eventType, title, detail: detail || null, meta: Object.keys(meta).length ? meta : null, performed_by: currentUser?.id || null }); } catch(e) { console.warn('logActivity failed:', e.message); } } // View the full job log timeline async function viewJobLog(jobId) { const [{ data: job }, { data: activity }, { data: photos }, { data: insp }] = await Promise.all([ sb.from('jobs').select('*,vehicles(*),customers(full_name,phone,email),profiles!jobs_assigned_to_fkey(full_name),line_items(*)').eq('id', jobId).single(), sb.from('job_activity').select('*,profiles(full_name,avatar_color)').eq('job_id', jobId).order('created_at', {ascending: true}), sb.from('vehicle_photos').select('*,profiles(full_name)').eq('job_id', jobId).order('created_at', {ascending: true}), sb.from('vehicle_inspections').select('*').eq('job_id', jobId).maybeSingle() ]); if (!job) return; const v = job.vehicles || {}; const c = job.customers || {}; const items = job.line_items || []; const labor = items.filter(i => i.type === 'labor'); const parts = items.filter(i => i.type === 'part'); // Build timeline combining activity log + photos (sorted by time) const timelineItems = [ ...(activity||[]).map(a => ({ ...a, _kind: 'event' })), ...(photos||[]).map(p => ({ ...p, _kind: 'photo', event_type: 'photo_added', title: p.photo_type === 'scan' ? 'Scan photo uploaded' : p.photo_type === 'completion' ? 'Completion photo uploaded' : 'Vehicle photo uploaded', detail: p.caption || '', performed_by: p.taken_by, profiles: p.profiles })) ].sort((a,b) => new Date(a.created_at) - new Date(b.created_at)); const eventIcons = { job_created: { icon: '📋', color: 'var(--blue)' }, status_change: { icon: '🔄', color: 'var(--amber)' }, intake_completed: { icon: '✅', color: 'var(--green2)' }, inspection_completed: { icon: '🔍', color: 'var(--purple2)' }, photo_added: { icon: '📷', color: 'var(--purple2)' }, part_added: { icon: '🔩', color: 'var(--text2)' }, labor_added: { icon: '🔧', color: 'var(--text2)' }, manual_pulled: { icon: '📖', color: 'var(--blue)' }, note_added: { icon: '📝', color: 'var(--text2)' }, invoice_created: { icon: '🧾', color: 'var(--green2)' }, invoice_sent: { icon: '📤', color: 'var(--green2)' }, payment_received: { icon: '💰', color: 'var(--green2)' }, job_completed: { icon: '🏁', color: 'var(--green2)' }, customer_pickup: { icon: '🚗', color: 'var(--green2)' }, default: { icon: '📌', color: 'var(--text3)' } }; const renderEvent = (item, idx, total) => { const { icon, color } = eventIcons[item.event_type] || eventIcons.default; const time = new Date(item.created_at); const timeStr = time.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) + ' ' + time.toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'}); const isLast = idx === total - 1; const photo = item._kind === 'photo' && item.photo_url; return '
' + '
' + '
' + icon + '
' + (!isLast ? '
' : '') + '
' + '
' + '
' + '
' + item.title + '
' + '
' + timeStr + '
' + '
' + (item.detail ? '
' + item.detail + '
' : '') + (item.profiles?.full_name ? '
By ' + item.profiles.full_name + '
' : '') + (photo ? '
' : '') + (item.meta?.part_number ? '
PN: ' + item.meta.part_number + '
' : '') + '
'; }; const intakePhotos = (photos||[]).filter(p => p.photo_type === 'intake'); const workPhotos = (photos||[]).filter(p => p.photo_type === 'work'); const compPhotos = (photos||[]).filter(p => p.photo_type === 'completion'); const scanPhoto = (photos||[]).find(p => p.photo_type === 'scan'); // Duration const startTime = job.started_at ? new Date(job.started_at) : null; const endTime = job.completed_at ? new Date(job.completed_at) : null; const durationStr = startTime && endTime ? (() => { const m = Math.round((endTime-startTime)/60000); return m >= 60 ? Math.floor(m/60)+'h '+(m%60)+'m' : m+'m'; })() : startTime ? 'In progress' : '—'; openModal('Job Log — ' + (job.job_number||jobId), `
${v.year||''} ${v.make||''} ${v.model||''}
${c.full_name||'—'} · ${fmtPhone(c.phone)}
${job.service_type||'—'} · ${job.location||'—'}
${statusBadge(job.status)}
Tech: ${job.profiles?.full_name||'Unassigned'}
Duration: ${durationStr}
Labor Items
${labor.length}
Parts Used
${parts.length}
Photos
${(photos||[]).length}
Total
${fmt$(job.total)}
${insp ? `
🔍 Inspection Summary
Brakes F/R: ${insp.brakes_front||'—'}/${insp.brakes_rear||'—'}
Oil: ${insp.oil_level||'—'} · ${insp.oil_condition||'—'}
Coolant: ${insp.coolant_level||'—'}
Tires FL/FR: ${insp.tire_fl_tread!=null?insp.tire_fl_tread+'%':'—'}/${insp.tire_fr_tread!=null?insp.tire_fr_tread+'%':'—'}
Tires RL/RR: ${insp.tire_rl_tread!=null?insp.tire_rl_tread+'%':'—'}/${insp.tire_rr_tread!=null?insp.tire_rr_tread+'%':'—'}
CEL: ${insp.check_engine_light ? '⚠️ ON' + (insp.cel_codes?' — '+insp.cel_codes:'') : '✅ Off'}
` : ''}
Activity Timeline
${timelineItems.length ? timelineItems.map((item, i) => renderEvent(item, i, timelineItems.length)).join('') : '
No activity logged yet
' }
📝 Add Note to Log
📷 Upload Work Photo
`, ` `); } async function addJobNote(jobId) { const input = document.getElementById('log-note-input'); const note = input?.value?.trim(); if (!note) return; await logActivity(jobId, 'note_added', 'Note added', note); toast('Note logged'); input.value = ''; viewJobLog(jobId); } async function uploadWorkPhotos(input, jobId) { const files = Array.from(input.files); if (!files.length) return; const status = document.getElementById('work-photo-status'); if (status) status.textContent = 'Uploading...'; const { data: job } = await sb.from('jobs').select('vehicle_id').eq('id', jobId).single(); let uploaded = 0; for (const file of files) { const path = jobId + '/work/' + Date.now() + '_' + uploaded + '.' + file.name.split('.').pop(); const { data, error } = await sb.storage.from('job-photos').upload(path, file, { upsert: true, contentType: file.type }); if (!error) { const { data: urlData } = sb.storage.from('job-photos').getPublicUrl(path); const url = urlData?.publicUrl; if (url) { await sb.from('vehicle_photos').insert({ job_id: jobId, vehicle_id: job?.vehicle_id, photo_url: url, photo_type: 'work', taken_by: currentUser.id }); await logActivity(jobId, 'photo_added', 'Work photo uploaded', '', { photo_url: url }); uploaded++; } } } if (status) status.textContent = uploaded + ' photo' + (uploaded!==1?'s':'') + ' uploaded ✅'; setTimeout(() => viewJobLog(jobId), 800); } async function printJobLog(jobId) { const [{ data: job }, { data: activity }, { data: photos }, { data: insp }] = await Promise.all([ sb.from('jobs').select('*,vehicles(*),customers(*),profiles!jobs_assigned_to_fkey(full_name),line_items(*)').eq('id', jobId).single(), sb.from('job_activity').select('*,profiles(full_name)').eq('job_id', jobId).order('created_at', {ascending: true}), sb.from('vehicle_photos').select('*').eq('job_id', jobId).order('created_at', {ascending: true}), sb.from('vehicle_inspections').select('*').eq('job_id', jobId).maybeSingle() ]); if (!job) return; const v = job.vehicles||{}, c = job.customers||{}; const items = job.line_items||[]; const allEvents = [ ...(activity||[]).map(a=>({...a,_kind:'event'})), ...(photos||[]).map(p=>({...p,_kind:'photo',title:p.photo_type+' photo',event_type:'photo_added',profiles:null})) ].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at)); const eventIcons2 = { job_created:'📋',status_change:'🔄',intake_completed:'✅',photo_added:'📷',part_added:'🔩',labor_added:'🔧',manual_pulled:'📖',note_added:'📝',invoice_created:'🧾',invoice_sent:'📤',payment_received:'💰',job_completed:'🏁',customer_pickup:'🚗' }; const html = ` Job Log ${job.job_number||''} — Supercanic
SUPERCANIC
Mobile Auto Repair · (951) 644-1599
Job Log — ${job.job_number||''}
Printed ${new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'})}
Vehicle
${v.year||''} ${v.make||''} ${v.model||''}
${v.vin?'
VIN: '+v.vin+'
':''}
Customer
${c.full_name||'—'}
${c.phone||''}
Technician
${job.profiles?.full_name||'—'}
Service
${job.service_type||'—'}
Status
${job.status?.toUpperCase()||'—'}
Total
$${Number(job.total||0).toFixed(2)}
${insp ? `
Inspection Results
Front Brakes: ${insp.brakes_front||'—'}
Rear Brakes: ${insp.brakes_rear||'—'}
Oil Level: ${insp.oil_level||'—'}
Oil Condition: ${insp.oil_condition||'—'}
Brake Fluid: ${insp.brake_fluid||'—'}
Coolant: ${insp.coolant_level||'—'}
Tire FL: ${insp.tire_fl_tread!=null?insp.tire_fl_tread+'%':'—'}
Tire FR: ${insp.tire_fr_tread!=null?insp.tire_fr_tread+'%':'—'}
Tire RL: ${insp.tire_rl_tread!=null?insp.tire_rl_tread+'%':'—'}
Tire RR: ${insp.tire_rr_tread!=null?insp.tire_rr_tread+'%':'—'}
Check Engine: ${insp.check_engine_light?'ON — '+(insp.cel_codes||''):'Off'}
${insp.brakes_notes?'
Brake notes: '+insp.brakes_notes+'
':''}
` : ''}
Activity Timeline
${allEvents.map(e=>`
${eventIcons2[e.event_type]||'📌'}
${e.title||''}
${e.detail?'
'+e.detail+'
':''}
${new Date(e.created_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}${e.profiles?.full_name?' · '+e.profiles.full_name:''}
`).join('')}
${items.length ? `
Work Performed
${items.map(i=>``).join('')}
TypeDescriptionQtyPriceTotal
${i.type}${i.description||'—'}${i.quantity}$${Number(i.unit_price||0).toFixed(2)}$${Number((i.quantity||1)*(i.unit_price||0)).toFixed(2)}
Total$${Number(job.total||0).toFixed(2)}
` : ''} ${photos&&photos.length ? `
Photos (${photos.length})
${photos.map(p=>`
${p.photo_type}
`).join('')}
` : ''} `; const win = window.open('','_blank','width=900,height=1100'); win.document.write(html); win.document.close(); setTimeout(()=>{ try{win.print();}catch(e){} }, 600); } // ── Theme ── const LIGHT_CSS = ':root {\n --bg:#f0f2f5 !important;\n --bg2:#ffffff !important;\n --bg3:#e4e7eb !important;\n --bg4:#d1d5db !important;\n --border:rgba(0,0,0,0.10) !important;\n --border2:rgba(0,0,0,0.18) !important;\n --text:#111318 !important;\n --text2:#374151 !important;\n --text3:#9ca3af !important;\n --green:#238a12 !important;\n --green2:#1a6e0d !important;\n --green-bg:rgba(35,138,18,0.10) !important;\n --purple:#7c35c2 !important;\n --purple2:#5e18a8 !important;\n --purple-bg:rgba(124,53,194,0.10) !important;\n --amber:#b45309 !important;\n --amber-bg:rgba(180,83,9,0.10) !important;\n --red:#c0392b !important;\n --red-bg:rgba(192,57,43,0.10) !important;\n }\n body { background: #f0f2f5 !important; color: #111318 !important; }\n .header { background: #ffffff !important; border-bottom: 1px solid rgba(0,0,0,0.12) !important; }\n .sidebar { background: #ffffff !important; border-right: 1px solid rgba(0,0,0,0.08) !important; }\n .body, .content, #content { background: #f0f2f5 !important; }\n .nav-item { color: #374151 !important; }\n .nav-item:hover { background: rgba(124,53,194,0.08) !important; }\n .nav-item.active { background: rgba(124,53,194,0.14) !important; color: #5e18a8 !important; }\n .nav-section { color: #9ca3af !important; }\n .card { background: #ffffff !important; border-color: rgba(0,0,0,0.10) !important; }\n .stat-card { background: #ffffff !important; border-color: rgba(0,0,0,0.10) !important; }\n .stat-card .stat-label { color: #374151 !important; }\n .modal { background: #ffffff !important; border-color: rgba(0,0,0,0.14) !important; }\n .modal-overlay { background: rgba(0,0,0,0.4) !important; }\n .form-input { background: #ffffff !important; border-color: rgba(0,0,0,0.20) !important; color: #111318 !important; }\n .form-label { color: #374151 !important; }\n .form-section { color: #9ca3af !important; border-bottom-color: rgba(0,0,0,0.08) !important; }\n .btn-ghost { border-color: rgba(0,0,0,0.18) !important; color: #374151 !important; }\n .btn-ghost:hover { background: #e4e7eb !important; }\n table th { background: #e4e7eb !important; color: #374151 !important; }\n table td { border-bottom-color: rgba(0,0,0,0.06) !important; color: #111318 !important; }\n table tr:hover td { background: #f5f6f8 !important; }\n .table-wrap { background: #ffffff !important; }\n .badge-gray { background: #e5e7eb !important; color: #374151 !important; }\n .pay-row { border-bottom-color: rgba(0,0,0,0.08) !important; }\n .login-screen { background: #f0f2f5 !important; }\n .login-box { background: #ffffff !important; border-color: rgba(0,0,0,0.12) !important; }\n .page-title { color: #111318 !important; }\n .page-sub { color: #374151 !important; }\n .detail-label { color: #9ca3af !important; }\n .detail-value { color: #111318 !important; }\n .td-name { color: #111318 !important; }\n .card-title { color: #111318 !important; }\n .card-body { color: #374151 !important; }\n .empty-state p { color: #374151 !important; }\n #theme-toggle { background: #e4e7eb !important; border-color: rgba(0,0,0,0.18) !important; }\n .search-input { background: #fff !important; border-color: rgba(0,0,0,0.18) !important; color: #111 !important; }'; function toggleTheme() { const isLight = document.getElementById('light-theme').textContent.length > 0; applyTheme(isLight ? 'dark' : 'light'); } function applyTheme(theme) { const sheet = document.getElementById('light-theme'); const btn = document.getElementById('theme-toggle'); if (theme === 'light') { if (sheet) sheet.textContent = LIGHT_CSS; if (btn) btn.textContent = '\u2600\ufe0f'; } else { if (sheet) sheet.textContent = ''; if (btn) btn.textContent = '\U0001f319'; } localStorage.setItem('sc-theme', theme); } // ── Start ── init(); document.addEventListener('DOMContentLoaded', function() { applyTheme(localStorage.getItem('sc-theme') || 'dark'); });