// ============================================================ // Job Cards Module — v2 (role-aware, mobile-first) // ============================================================ let jcState = { page: 1, search: '', status: '', type: '', internal: '', myOnly: false, date_from: '', date_to: '' }; let _jcTypes = null; // cached job card types async function getJcTypes() { if (_jcTypes) return _jcTypes; const res = await API.post('jobcard_types/list', {}); _jcTypes = res.success ? res.data.types.filter(t => t.is_active) : [ { slug: 'installation', name: 'Installation' }, { slug: 'maintenance', name: 'Maintenance' }, { slug: 'repair', name: 'Repair' }, { slug: 'site_survey', name: 'Site Survey' }, { slug: 'other', name: 'Other' } ]; return _jcTypes; } function jcTypeOptions(selectedSlug) { const types = _jcTypes || [ { slug: 'installation', name: 'Installation' }, { slug: 'maintenance', name: 'Maintenance' }, { slug: 'repair', name: 'Repair' }, { slug: 'site_survey', name: 'Site Survey' }, { slug: 'other', name: 'Other' } ]; return types.map(t => ``).join(''); } // ── Helpers ────────────────────────────────────────────────── const JC_PRIORITY_ICON = { low: '🟢', normal: '🔵', high: '🟠', urgent: '🔴' }; const JC_STATUS_FLOW = ['draft', 'assigned', 'travelling', 'on_site', 'working', 'completed', 'internal_complete', 'invoiced', 'no_charge']; const JC_STATUS_FLOW_INTERNAL = ['draft', 'assigned', 'working', 'internal_complete']; const JC_STATUS_LABELS = { draft: 'Draft', assigned: 'Assigned', travelling: 'Travelling', on_site: 'On Site', working: 'Working', completed: 'Completed', internal_complete: 'Internal (Done)', invoiced: 'Invoiced', no_charge: 'No Charge', cancelled: 'Cancelled' }; const JC_EVENT_LABELS = { depart: 'Departed to Site', arrive_site: 'Arrived on Site', start_work: 'Started Work', depart_site: 'Departed Site', arrive_base: 'Arrived at Base', pause_travel: 'Travel Paused', resume_travel: 'Travel Resumed', pause_work: 'Work Paused', resume_work: 'Work Resumed', completed: 'Job Completed', internal_complete: 'Job Complete (Internal)', invoiced: 'Invoiced', no_charge: 'No Charge', cancelled: 'Cancelled' }; const JC_EVENT_ICONS = { depart: '🚗', arrive_site: '📍', start_work: '🔧', depart_site: '🚗', arrive_base: '🏠', pause_travel: '⏸', resume_travel: '▶️', pause_work: '⏸', resume_work: '▶️', completed: '✅', internal_complete: '✅', invoiced: '🧾', no_charge: '🎁', cancelled: '❌' }; const PLAN_ICONS = { task: '☑️', tool: '🔧', part: '📦', note: '📝' }; function isAdmin() { return Auth.isAdmin(); } function isTechOnly() { return Auth.hasRole(4) && !Auth.isAdmin(); } function jcViewOwnOnly() { if (Auth.isAdmin()) return false; // view_own ticked = always restrict, regardless of whether view is also ticked return Auth.can('jobcards', 'view_own') || (!Auth.can('jobcards', 'view') && Auth.hasRole(4)); } function isAdminOrDev() { return Auth.isDev(); } // legacy function jcCan(action) { return Auth.can('jobcards', action); } // ── LIST VIEW ──────────────────────────────────────────────── async function renderJobCards(params = {}) { if (params.id) return renderJobCardDetail(params.id); jcState = { page: 1, search: '', status: '', type: '', internal: '', myOnly: jcViewOwnOnly(), date_from: '', date_to: '' }; await getJcTypes(); // pre-load types const content = document.getElementById('page-content'); content.innerHTML = `

Job Cards

Field operations and technician dispatching

${jcCan('edit') ? ` ` : ''}
${Auth.can('jobcards', 'view_costing') ? `
Last Month Net P/L
This Month Net P/L
` : ''}
${jcCan('view') ? ` ${Auth.can('jobcards', 'reports') ? `` : ''}` : ''} ${jcViewOwnOnly() ? `` : (Auth.can('jobcards', 'view') ? `` : '')}
`; if (jcViewOwnOnly()) jcState.status = ''; await loadJobCards(1); if (Auth.can('jobcards', 'view_costing')) loadJcPlSummary(); } async function loadJcPlSummary() { try { const res = await API.post('jobcards/pl_summary', {}); if (!res.success) return; const { this_month, last_month, this_month_label, last_month_label } = res.data; function renderBlock(elId, subId, data, label) { const el = document.getElementById(elId); const sub = document.getElementById(subId); if (!el) return; const pl = parseFloat(data.net_pl); const color = pl > 0 ? 'var(--success)' : pl < 0 ? 'var(--danger)' : 'var(--text-muted)'; const sign = pl > 0 ? '+' : ''; el.style.color = color; el.textContent = `${sign}R ${Math.abs(pl).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; if (sub) sub.textContent = `${label} · ${data.job_count} jobs · R ${parseFloat(data.total_invoice).toLocaleString('en-ZA', { minimumFractionDigits: 2 })} invoiced`; } renderBlock('jc-pl-last', 'jc-pl-last-sub', last_month, last_month_label); renderBlock('jc-pl-this', 'jc-pl-this-sub', this_month, this_month_label); } catch (e) { // Non-critical — blocks just stay as — } } const jcSearchDebounced = debounce(val => { jcState.search = val; loadJobCards(1); }, 350); async function loadJobCards(page = 1) { jcState.page = page; const wrap = document.getElementById('jc-table-wrap'); if (!wrap) return; const res = await API.post('jobcards/list', { page, limit: 20, search: jcState.search, status: jcState.status, type: jcState.type, internal: jcState.internal, my_only: (jcState.myOnly || jcViewOwnOnly()) ? 1 : 0, date_from: jcState.date_from, date_to: jcState.date_to }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const jobs = res.data.job_cards; if (!jobs.length) { wrap.innerHTML = `
📋
No job cards found
`; document.getElementById('jc-pagination').innerHTML = ''; return; } // Tech sees cards, admin sees table if (isTechOnly()) { wrap.innerHTML = `
${jobs.map(j => `
${j.job_number}

${j.title}

${j.client_name || ''} ${j.site_name ? '· ' + j.site_name : ''}
${Fmt.statusBadge(j.status)}
${j.scheduled_date ? Fmt.date(j.scheduled_date) : ''}
${['assigned', 'travelling', 'on_site', 'working'].includes(j.status) ? `
${j.status === 'assigned' && !j.is_internal && j.vehicle_id ? `` : ''} ${j.status === 'assigned' && (j.is_internal || !j.vehicle_id) ? `` : ''} ${j.status === 'travelling' ? (() => { const _lte = [...(j.time_logs || [])].reverse().find(t => ['depart', 'pause_travel', 'resume_travel', 'depart_site'].includes(t.event_type)); if (_lte?.event_type === 'pause_travel') return ``; return `
`; })() : ''} ${j.status === 'on_site' ? `` : ''} ${j.status === 'working' ? `` : ''}
` : ''}
`).join('')}
`; } else if (window.innerWidth < 768) { // Mobile card view for admin wrap.innerHTML = `
${jobs.map(j => { const pl = (j.net_profit !== null && j.net_profit !== undefined) ? parseFloat(j.net_profit) : 0; const plColor = pl > 0 ? 'var(--success)' : pl < 0 ? 'var(--danger)' : 'var(--text-muted)'; const plPrefix = pl > 0 ? '+' : ''; return `
${j.job_number}
${j.title}
${j.client_name || 'No client'}${j.site_name ? ' · ' + j.site_name : ''}
${Fmt.statusBadge(j.status)} ${plPrefix}R ${Math.abs(pl).toFixed(2)}
${j.scheduled_date ? Fmt.date(j.scheduled_date) : 'No date'} · ${j.assigned_name || 'Unassigned'}
${jcCan('delete') ? `` : ''}
`; }).join('')}
`; } else { wrap.innerHTML = `
${jobs.map(j => ` `).join('')}
Job #TitleClientSiteScheduledAssigned ToPriorityStatusNet P/L
${j.job_number} ${j.title} ${j.client_name || '—'} ${j.site_name || '—'} ${j.scheduled_date ? Fmt.date(j.scheduled_date) : '—'} ${j.assigned_name || 'Unassigned'} ${JC_PRIORITY_ICON[j.priority] || ''} ${Fmt.capitalize(j.priority)} ${Fmt.statusBadge(j.status)} ${(() => { const pl = (j.net_profit !== null && j.net_profit !== undefined) ? parseFloat(j.net_profit) : 0; const color = pl > 0 ? 'var(--success)' : pl < 0 ? 'var(--danger)' : 'var(--text-muted)'; const prefix = pl > 0 ? '+' : ''; return `${prefix}R ${Math.abs(pl).toFixed(2)}`; })()} ${jcCan('delete') ? `` : ''}
`; } renderPagination('jc-pagination', res.data.pagination, `p => loadJobCards(p)`); } async function quickLogTime(jobId, eventType) { await logTimeWithOdo(jobId, eventType, () => loadJobCards(jcState.page)); } // ── DETAIL VIEW ────────────────────────────────────────────── async function renderJobCardDetail(id) { const content = document.getElementById('page-content'); content.innerHTML = `
`; const res = await API.post('jobcards/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const j = res.data.job_card; if (jcViewOwnOnly() || window.innerWidth < 900) { renderTechView(j, content); } else { renderAdminView(j, content); } } // ── TECH VIEW (mobile-first, action-focused) ───────────────── function renderTechView(j, content) { const currentIdx = JC_STATUS_FLOW.indexOf(j.status); const planDone = (j.planning || []).filter(i => i.is_checked).length; const planTotal = (j.planning || []).length; const clDone = (j.checklist || []).filter(i => i.is_checked).length; const clTotal = (j.checklist || []).length; content.innerHTML = `
${j.job_number}

${j.title}

${j.client_name || ''} ${j.site_name ? '· 📍 ' + j.site_name : ''}
${Fmt.statusBadge(j.status)} ${jcCan('edit') ? `
` : ''}
${JC_STATUS_FLOW.map((s, i) => `
${i < currentIdx ? '✓' : i + 1}
${JC_STATUS_LABELS[s]}
`).join('')}
${j.status === 'assigned' && j.is_internal ? ` ` : ''} ${j.status === 'assigned' && !j.is_internal && j.vehicle_id ? ` ` : ''} ${j.status === 'assigned' && !j.is_internal && !j.vehicle_id ? ` ` : ''} ${j.status === 'travelling' ? (() => { const lastTravelEvent = [...(j.time_logs || [])].reverse().find(t => ['depart', 'arrive_site', 'depart_site', 'pause_travel', 'resume_travel'].includes(t.event_type)); const travelPaused = lastTravelEvent?.event_type === 'pause_travel'; const returningHome = (j.time_logs || []).some(t => t.event_type === 'depart_site'); if (travelPaused) return ` `; if (returningHome) return `
`; return `
`; })() : ''} ${j.status === 'on_site' ? ` ` : ''} ${j.status === 'working' ? (() => { const lastWorkEvent = [...(j.time_logs || [])].reverse().find(t => ['start_work', 'pause_work', 'resume_work'].includes(t.event_type)); const workPaused = lastWorkEvent?.event_type === 'pause_work'; const _arrivedBack = (j.time_logs || []).some(t => t.event_type === 'arrive_base'); // After returning to base: just show Complete if (_arrivedBack && !j.is_internal) return ` `; // Report submitted + no vehicle: skip travel, complete directly if (!j.is_internal && j.has_report && !j.vehicle_id) return ` `; // Report submitted + has vehicle: show depart if (!j.is_internal && j.has_report && j.vehicle_id) return ` `; // Work paused (no report yet) if (workPaused) return ` `; return `
${!j.is_internal ? `
Submit report & signature first to depart
` : ''} ${j.is_internal ? `` : ''}`; })() : ''} ${isAdminOrDev() ? ` ${j.status === 'draft' ? `` : ''} ${j.status === 'completed' || j.status === 'internal_complete' ? `
` : ''} ${j.status === 'invoiced' ? `` : ''} ${j.status === 'no_charge' ? `` : ''} ` : ''}
${Auth.can('jobcards', 'view_costing') ? `` : ''}

${j.scheduled_date ? Fmt.date(j.scheduled_date) : '—'} ${j.scheduled_time ? j.scheduled_time.slice(0, 5) : ''}

${JC_PRIORITY_ICON[j.priority]} ${Fmt.capitalize(j.priority)}

${Fmt.capitalize(j.job_type)}

${j.client_name || '—'}

${j.vehicle_reg ? `

${j.vehicle_make} ${j.vehicle_model} (${j.vehicle_reg})

` : ''} ${j.odo_start ? `

${parseInt(j.odo_start).toLocaleString()} km

` : ''} ${j.odo_end ? `

${parseInt(j.odo_end).toLocaleString()} km

` : ''} ${j.odo_start && j.odo_end ? `

${(parseInt(j.odo_end) - parseInt(j.odo_start)).toLocaleString()} km

` : ''} ${j.completed_at ? `

${Fmt.datetime(j.completed_at)}

` : ''} ${j.invoice_no ? `

${j.invoice_no}

` : ''} ${j.invoice_amount ? `

R ${parseFloat(j.invoice_amount).toFixed(2)}

` : ''}

${j.site_name || '—'} ${j.site_address ? '— ' + j.site_address : ''}

${j.site_address ? `🗺 Open in Maps` : ''}
${j.description ? `

${j.description}

` : ''}
${isAdminOrDev() ? `` : ''}
${j.technicians?.length ? `
${j.technicians.map(t => `${avatarHTML(t.full_name)} ${t.full_name}`).join('')}
` : `

No team assigned

`}
Pre-Job Planning
On-Site Checklist
Notes
${renderNotes(j.notes || [], j.id)}
Site Photos
${renderImagesGrid(j.images || [], j.id)}
Location
${renderLocationList(j.locations || [])}
Job Card Slips
${isAdminOrDev() ? `
` : ''}
`; } function switchTechTab(tab, btn) { document.querySelectorAll('#jc-tech-tabs button').forEach(b => b.classList.remove('active')); document.querySelectorAll('.jc-tech-pane').forEach(p => p.classList.remove('active')); if (btn) btn.classList.add('active'); else { const target = document.querySelector(`#jc-tech-tabs button[data-tab="${tab}"]`); if (target) target.classList.add('active'); } const pane = document.getElementById(`jc-tech-tab-${tab}`); if (pane) pane.classList.add('active'); } // ── ADMIN VIEW (full tabs, reporting) ──────────────────────── function renderAdminView(j, content) { const currentIdx = JC_STATUS_FLOW.indexOf(j.status); const planDone = (j.planning || []).filter(i => i.is_checked).length; const clDone = (j.checklist || []).filter(i => i.is_checked).length; // ── Pre-compute all dynamic sidebar buttons (avoids nested backtick IIFEs) ── const travelPaused = [...(j.time_logs || [])].reverse().find(t => ['depart', 'arrive_site', 'depart_site', 'pause_travel', 'resume_travel'].includes(t.event_type))?.event_type === 'pause_travel'; const returning = (j.time_logs || []).some(t => t.event_type === 'depart_site'); const workPaused = [...(j.time_logs || [])].reverse().find(t => ['start_work', 'pause_work', 'resume_work'].includes(t.event_type))?.event_type === 'pause_work'; const arrivedBack = (j.time_logs || []).some(t => t.event_type === 'arrive_base'); const jid = j.id; let sidebarActions = ''; if (j.status === 'draft') { sidebarActions = ``; } else if (j.status === 'assigned' && !j.is_internal && j.vehicle_id) { sidebarActions = ``; } else if (j.status === 'assigned' && (!j.is_internal && !j.vehicle_id || j.is_internal)) { sidebarActions = ``; } else if (j.status === 'travelling' && !j.is_internal) { if (travelPaused) { sidebarActions = ``; } else if (returning) { sidebarActions = `` + ``; } else { sidebarActions = `` + ``; } } else if (j.status === 'on_site' && !j.is_internal) { sidebarActions = ``; } else if (j.status === 'working') { if (arrivedBack && !j.is_internal) { sidebarActions = ``; } else if (!j.is_internal && j.has_report && j.vehicle_id) { // Has vehicle + report done — show depart sidebarActions = ``; } else if (!j.is_internal && j.has_report && !j.vehicle_id) { // No vehicle + report done — skip travel, go straight to complete sidebarActions = ``; } else if (workPaused) { sidebarActions = ``; } else { sidebarActions = ``; if (!j.is_internal) { sidebarActions += `
Submit report & signature first to depart
`; } if (j.is_internal) sidebarActions += ``; } } else if (j.status === 'completed' || j.status === 'internal_complete') { sidebarActions = `` + ``; } else if (j.status === 'invoiced') { sidebarActions = ``; } else if (j.status === 'no_charge') { sidebarActions = ``; } sidebarActions += ''; // ── Progress steps ── const progressSteps = JC_STATUS_FLOW.map((s, i) => { const done = i < currentIdx, cur = i === currentIdx; return '
' + '
' + (done ? '✓' : i + 1) + '
' + '' + JC_STATUS_LABELS[s] + '' + (cur ? 'Current' : '') + '
'; }).join(''); // ── Team ── const teamHTML = !j.assigned_to ? `
👤
No technician assigned
` + (isAdminOrDev() ? `` : '') + '
' : `
` + avatarHTML(j.assigned_name, 'lg') + `
${j.assigned_name}
Lead Technician
` + (j.technicians || []).filter(t => t.user_id != j.assigned_to).map(t => `
${avatarHTML(t.full_name, 'lg')}` + `
${t.full_name}
${Fmt.capitalize(t.role)}
` ).join(''); content.innerHTML = `

${j.title}

${j.job_number} · ${j.client_name || 'No client'} · ${Fmt.capitalize(j.job_type)}
${Fmt.statusBadge(j.status)} ${isAdminOrDev() ? `` : ''} ${isAdminOrDev() ? `` : ''}
${Auth.can('jobcards', 'view_costing') ? `` : ''}
${j.description ? `

${j.description}

` : ''}

${j.client_name || 'None'}

${isAdminOrDev() ? `` : ''}

${j.project_name || '—'}

${Fmt.capitalize(j.job_type)}

${Fmt.priorityBadge(j.priority)}

${j.scheduled_date ? Fmt.date(j.scheduled_date) : '—'} ${j.scheduled_time ? j.scheduled_time.slice(0, 5) : ''}

${j.assigned_to ? `
${avatarHTML(j.assigned_name)} ${j.assigned_name}
` : `Not assigned`} ${isAdminOrDev() ? `` : ''}

${j.completed_at ? Fmt.datetime(j.completed_at) : '—'}

${j.is_internal ? 'Internal — Not Invoiced' : 'Billable'}

${j.vehicle_reg ? j.vehicle_make + ' ' + j.vehicle_model + ' (' + j.vehicle_reg + ')' : '—'}

${j.odo_start ? parseInt(j.odo_start).toLocaleString() + ' km' : '—'}

${j.odo_end ? parseInt(j.odo_end).toLocaleString() + ' km' : '—'}

${j.odo_start && j.odo_end ? (parseInt(j.odo_end) - parseInt(j.odo_start)).toLocaleString() + ' km' : '—'}

${j.invoice_no || '—'}

${j.invoice_amount ? 'R ' + parseFloat(j.invoice_amount).toFixed(2) : '—'}

${j.site_name || '—'}

${j.site_address || ''}

${j.site_address ? `🗺 Open in Maps` : ''}
Pre-Job Planning

Tasks, tools and parts needed before job starts

On-Site Checklist

Items to tick off during the job

Time Log
${buildAdminTimeline(j)}
Notes
${renderNotes(j.notes || [], j.id)}
Site Photos
${renderImagesGrid(j.images || [], j.id)}
Location Tracking
${renderLocationList(j.locations || [])}
Job Card Slips

Expenses captured on this job — all slips require an image

Job Progress
${progressSteps}

Quick Actions

${sidebarActions}

Team

${isAdminOrDev() ? '' : ''}
${teamHTML}
`; } // ── Shared render helpers ───────────────────────────────────── function renderNotes(notes, jobId) { if (!notes.length) return `
No notes yet
`; return notes.map(n => `
${avatarHTML(n.user_name)} ${n.user_name} ${n.is_private ? 'Private' : ''}
${Fmt.ago(n.created_at)}

${n.note}

`).join(''); } function renderImagesGrid(images, jobId) { if (!images.length) return `
📷
No photos yet

Upload before/after photos of the site.

`; return `
${images.map(img => `
${img.caption || ''}
${img.image_type || 'site'} ${img.caption ? `

${img.caption}

` : ''}
${isAdminOrDev() ? `` : ''}
`).join('')}
`; } function renderLocationList(locations) { if (!locations.length) return `
📍
No location data yet
`; const last = locations[locations.length - 1]; return `
${locations.slice().reverse().map(l => `
${Fmt.capitalize(l.event_type.replace(/_/g, ' '))} ${parseFloat(l.latitude).toFixed(5)}, ${parseFloat(l.longitude).toFixed(5)} ${l.accuracy ? `±${Math.round(l.accuracy)}m` : ''} ${l.user_name ? `· ${l.user_name}` : ''}
${Fmt.ago(l.captured_at)}
`).join('')}`; } // ── Action handlers ─────────────────────────────────────────── async function jcTechAction(jobId, eventType) { await logTimeWithOdo(jobId, eventType, () => renderJobCardDetail(jobId)); } async function logTimeEvent(jobId, eventType) { await logTimeWithOdo(jobId, eventType, () => renderJobCardDetail(jobId)); } // Events that require an ODO reading const ODO_EVENTS = new Set(['depart', 'arrive_site', 'depart_site', 'arrive_base', 'pause_travel', 'resume_travel']); // Central time logging — prompts ODO for all travel-related events async function logTimeWithOdo(jobId, eventType, onSuccess) { if (!ODO_EVENTS.has(eventType)) { // No ODO needed — log directly const res = await API.post('jobcards/log_time', { job_card_id: jobId, event_type: eventType }); if (res.success) { Toast.show(JC_EVENT_LABELS[eventType] || 'Updated.', 'success'); if (onSuccess) onSuccess(); } else Toast.show(res.message, 'error'); return; } // Fetch latest ODO for hint let latestOdo = 0; const jcRes = await API.post('jobcards/get', { id: jobId }); if (jcRes.success && jcRes.data.job_card?.vehicle_id) { const odoRes = await API.post('fleet/latest_odo', { vehicle_id: jcRes.data.job_card.vehicle_id }); if (odoRes.success) latestOdo = odoRes.data.latest_odo || 0; } const icon = JC_EVENT_ICONS[eventType] || '🔢'; const label = JC_EVENT_LABELS[eventType] || 'Event'; const hint = latestOdo > 0 ? `
Latest recorded: ${latestOdo.toLocaleString()} km — must be ≥ this
` : `
Enter current odometer reading in km
`; Modal.open({ id: 'odo-prompt', title: `${icon} ${label}`, body: `
${hint}
`, footer: ` ` }); window._jcOdoSuccess = onSuccess; setTimeout(() => document.getElementById('odo-input')?.focus(), 150); } async function submitOdoLog(jobId, eventType, latestOdo = 0, onSuccess) { const odo = parseInt(document.getElementById('odo-input')?.value?.trim() || '0'); if (!odo) { Toast.show('Please enter the odometer reading.', 'error'); return; } if (latestOdo > 0 && odo < latestOdo) { Toast.show(`ODO must be ≥ latest reading (${latestOdo.toLocaleString()} km).`, 'error'); return; } const btn = document.querySelector('#modal-odo-prompt .btn-primary'); if (btn) btn.classList.add('loading'); const res = await API.post('jobcards/log_time', { job_card_id: jobId, event_type: eventType, odo_reading: odo }); if (btn) btn.classList.remove('loading'); if (!res.success) { Toast.show(res.message, 'error'); return; } Toast.show(JC_EVENT_LABELS[eventType] + ' — ODO saved!', 'success'); Modal.close(); if (onSuccess) onSuccess(); else renderJobCardDetail(jobId); } // ── Admin Timeline Helpers ─────────────────────────────────── function buildAdminTimeline(j) { const statusMilestones = { completed: { icon: '✅', label: 'Job Completed', color: 'var(--success)' }, internal_complete: { icon: '✅', label: 'Internal Job Completed', color: 'var(--success)' }, invoiced: { icon: '🧾', label: 'Marked as Invoiced', color: 'var(--info)' }, no_charge: { icon: '🎁', label: 'Marked No Charge', color: 'var(--success)' }, cancelled: { icon: '❌', label: 'Job Cancelled', color: 'var(--danger)' }, }; let items = (j.time_logs || []).map(tl => ({ id: tl.id, time: tl.event_time, icon: JC_EVENT_ICONS[tl.event_type] || '🕐', label: JC_EVENT_LABELS[tl.event_type] || Fmt.capitalize(tl.event_type.replace(/_/g, ' ')), sub: tl.user_name + (tl.odo_reading ? ` · ODO: ${parseInt(tl.odo_reading).toLocaleString()} km` : ''), color: 'var(--primary)', type: 'event', odo: tl.odo_reading, event_type: tl.event_type })); if (j.odo_start) items.push({ id: null, time: j.time_logs?.find(t => t.event_type === 'depart')?.event_time || j.created_at, icon: '🔢', label: `ODO Start: ${parseInt(j.odo_start).toLocaleString()} km`, sub: '', color: 'var(--text-muted)', type: 'odo' }); if (j.odo_end) items.push({ id: null, time: j.time_logs?.find(t => t.event_type === 'arrive_base')?.event_time || j.updated_at, icon: '🔢', label: `ODO End: ${parseInt(j.odo_end).toLocaleString()} km`, sub: j.odo_start ? `${(parseInt(j.odo_end) - parseInt(j.odo_start)).toLocaleString()} km total` : '', color: 'var(--text-muted)', type: 'odo' }); if (statusMilestones[j.status]) { const ms = statusMilestones[j.status]; items.push({ id: null, time: j.completed_at || j.updated_at, icon: ms.icon, label: ms.label, sub: '', color: ms.color, type: 'milestone' }); } items.sort((a, b) => new Date(a.time) - new Date(b.time)); if (!items.length) return '
No events logged yet
'; return '
' + items.map(item => `
${item.icon}
${item.label}
${item.sub ? `
${item.sub}
` : ''}
${Fmt.datetime(item.time)}
${item.type === 'event' && item.id ? `
` : ''}
`).join('') + '
'; } function openAddTimelineEvent(jobId) { const eventsHtml = Object.entries(JC_EVENT_LABELS).map(([k, v]) => ``).join(''); Modal.open({ id: 'add-timeline', title: '➕ Add Timeline Event', body: `
`, footer: ` ` }); toggleAtOdo(document.getElementById('at-event')?.value); } function toggleAtOdo(eventType) { const wrap = document.getElementById('at-odo-wrap'); if (!wrap) return; const needed = ['depart', 'arrive_site', 'depart_site', 'arrive_base', 'pause_travel', 'resume_travel']; wrap.style.display = needed.includes(eventType) ? '' : 'none'; const input = wrap.querySelector('input'); if (input) input.required = needed.includes(eventType); } async function submitAddTimelineEvent(jobId) { const btn = document.querySelector('#modal-add-timeline .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('add-timeline-form'); const res = await API.post('jobcards/edit_timeline', { action: 'add', job_card_id: jobId, event_type: data.event_type, event_time: data.event_time, odo_reading: data.odo_reading || null }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Event added.', 'success'); Modal.close(); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } function openEditTimelineEntry(entryId, eventType, odoReading, eventTime) { const localTime = new Date(new Date(eventTime) - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16); Modal.open({ id: 'edit-timeline', title: '✏️ Edit Timeline Entry', body: `

${JC_EVENT_ICONS[eventType] || ''} ${JC_EVENT_LABELS[eventType] || eventType}

${['depart', 'arrive_site', 'depart_site', 'arrive_base', 'pause_travel', 'resume_travel'].includes(eventType) ? `
` : ''}
`, footer: ` ` }); } async function submitEditTimelineEntry(entryId) { const btn = document.querySelector('#modal-edit-timeline .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('edit-timeline-form'); const res = await API.post('jobcards/edit_timeline', { action: 'update', id: entryId, event_time: data.event_time, odo_reading: data.odo_reading || null }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Entry updated.', 'success'); Modal.close(); const jcId = document.querySelector('[data-jcid]')?.dataset.jcid; if (jcId) renderJobCardDetail(parseInt(jcId)); } else Toast.show(res.message, 'error'); } async function deleteTimelineEntry(entryId, jobId) { if (!await Modal.confirm('Delete this timeline entry? This cannot be undone.', 'Delete Entry', true)) return; const res = await API.post('jobcards/edit_timeline', { action: 'delete', id: entryId }); if (res.success) { Toast.show('Entry deleted.', 'success'); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } function openEditOdos(jobId, odoStart, odoEnd) { Modal.open({ id: 'edit-odos', title: '🔢 Edit Odometer Readings', body: `
`, footer: ` ` }); } async function submitEditOdos(jobId) { const btn = document.querySelector('#modal-edit-odos .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('edit-odos-form'); const res = await API.post('jobcards/update', { id: jobId, odo_start: data.odo_start || null, odo_end: data.odo_end || null }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('ODOs updated.', 'success'); Modal.close(); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } async function updateJcStatus(jobId, status) { const timelineStatuses = ['completed', 'internal_complete', 'invoiced', 'no_charge', 'cancelled']; if (timelineStatuses.includes(status)) { const res = await API.post('jobcards/log_time', { job_card_id: jobId, event_type: status }); if (res.success) { Toast.show(JC_STATUS_LABELS[status] + ' ✓', 'success'); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } else { const res = await API.post('jobcards/update', { id: jobId, status }); if (res.success) { Toast.show('Status updated.', 'success'); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } } // ── Image upload ────────────────────────────────────────────── function openImageUpload(jobId) { Modal.open({ id: 'upload-image', title: '📷 Upload Photo', body: `
`, footer: ` ` }); } function previewUploadImage(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { const preview = document.getElementById('img-preview'); const img = document.getElementById('img-preview-img'); if (preview && img) { img.src = e.target.result; preview.style.display = 'block'; } }; reader.readAsDataURL(file); } async function submitImageUpload(jobId) { const fileInput = document.getElementById('img-file'); if (!fileInput?.files[0]) { Toast.show('Please select a photo.', 'error'); return; } const btn = document.getElementById('upload-btn'); if (btn) btn.classList.add('loading'); const formData = new FormData(); formData.append('token', Auth.getToken()); formData.append('job_card_id', jobId); formData.append('image', fileInput.files[0]); formData.append('image_type', document.getElementById('img-type')?.value || 'site'); formData.append('caption', document.getElementById('img-caption')?.value || ''); try { const uploadUrl = window.location.pathname.replace(/\/[^\/]*$/, '/') + 'api/jobcards/image_upload.php'; const response = await fetch(uploadUrl, { method: 'POST', body: formData }); const data = await response.json(); if (btn) btn.classList.remove('loading'); if (data.success) { Toast.show('Photo uploaded!', 'success'); Modal.close(); // refresh images grid const grid = document.getElementById('jc-images-grid'); if (grid) { const res2 = await API.post('jobcards/get', { id: jobId }); if (res2.success) grid.innerHTML = renderImagesGrid(res2.data.job_card.images || [], jobId); } } else { Toast.show(data.message || 'Upload failed.', 'error'); } } catch (e) { if (btn) btn.classList.remove('loading'); Toast.show('Upload error: ' + e.message, 'error'); } } async function deleteJcImage(imgId, jobId) { const ok = await Modal.confirm('Delete this photo?'); if (!ok) return; const res = await API.post('jobcards/image_upload', { action: 'delete', image_id: imgId, job_card_id: jobId }); if (res.success) { Toast.show('Deleted.', 'success'); const grid = document.getElementById('jc-images-grid'); if (grid) { const res2 = await API.post('jobcards/get', { id: jobId }); if (res2.success) grid.innerHTML = renderImagesGrid(res2.data.job_card.images || [], jobId); } } else Toast.show(res.message, 'error'); } function openImageLightbox(src) { Modal.open({ id: 'lightbox', title: '', body: `
`, footer: `` }); } // ── GPS Location ────────────────────────────────────────────── async function captureLocation(jobId) { if (!navigator.geolocation) { Toast.show('Geolocation not supported on this device.', 'error'); return; } Toast.show('Getting your location...', 'info'); navigator.geolocation.getCurrentPosition(async pos => { const { latitude, longitude, accuracy } = pos.coords; const res = await API.post('jobcards/location_save', { job_card_id: jobId, latitude, longitude, accuracy, event_type: 'manual' }); if (res.success) { Toast.show(`📍 Location captured (±${Math.round(accuracy)}m)`, 'success'); const wrap = document.getElementById('jc-location-list'); if (wrap) { const res2 = await API.post('jobcards/get', { id: jobId }); if (res2.success) wrap.innerHTML = renderLocationList(res2.data.job_card.locations || []); } } else Toast.show(res.message, 'error'); }, err => Toast.show('Location error: ' + err.message, 'error'), { enableHighAccuracy: true, timeout: 15000 }); } // ── Assign technicians ──────────────────────────────────────── async function openManageTechModal(jobId) { const [jcRes, usersRes] = await Promise.all([ API.post('jobcards/get', { id: jobId }), API.post('auth/users_list', {}) ]); if (!jcRes.success || !usersRes.success) return; const j = jcRes.data.job_card; const users = usersRes.data.users; const techs = j.technicians || []; Modal.open({ id: 'manage-tech', title: '👤 Assign Technician', body: `

Select who will lead this job. They will see it in their "My Jobs" list.

Other staff working on this job.

${techs.filter(t => t.user_id != j.assigned_to).map(t => `
${avatarHTML(t.full_name)} ${t.full_name}
`).join('') || `

None added

`}
`, footer: ` ` }); } async function saveLeadTech(jobId) { const sel = document.getElementById('lead-tech-select'); const res = await API.post('jobcards/update', { id: jobId, assigned_to: sel?.value || '' }); if (res.success) { Toast.show('Lead technician updated.', 'success'); Modal.close(); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } async function addTechToJob(jobId) { const sel = document.getElementById('add-tech-select'); if (!sel?.value) return; const res = await API.post('jobcards/assign_tech', { action: 'add', job_card_id: jobId, user_id: sel.value }); if (res.success) { Toast.show('Added.', 'success'); openManageTechModal(jobId); } else Toast.show(res.message, 'error'); } async function removeTechFromJob(userId, jobId) { const res = await API.post('jobcards/assign_tech', { action: 'remove', job_card_id: jobId, user_id: userId }); if (res.success) { Toast.show('Removed.', 'success'); openManageTechModal(jobId); } else Toast.show(res.message, 'error'); } // ── Create / Edit modals ────────────────────────────────────── // ── Delete Job Card ────────────────────────────────────────── async function deleteJobCard(jobId, jobNumber) { if (!await Modal.confirm( `

Permanently delete job card ${jobNumber}?

All time logs, notes, photos, slips, planning and reports will be deleted. This cannot be undone.

`, 'Delete Job Card', true )) return; const res = await API.post('jobcards/delete', { id: jobId }); if (res.success) { Toast.show('Job card deleted.', 'success'); Router.navigate('jobcards'); } else { Toast.show(res.message || 'Failed to delete.', 'error'); } } function openAddJobCardModal() { Modal.open({ id: 'add-jobcard', title: 'New Job Card', size: 'modal-lg', body: `
`, footer: ` ` }); // Populate dropdowns API.post('clients/list', { limit: 999 }).then(r => { const sel = document.getElementById('jc-client-sel'); if (sel) sel.innerHTML = `` + (r.data?.clients || []).map(c => ``).join(''); }); API.post('auth/users_list', {}).then(r => { const sel = document.getElementById('jc-tech-sel'); if (sel) sel.innerHTML = `` + (r.data?.users || []).map(u => ``).join(''); }); API.post('fleet/vehicles', { action: 'list' }).then(r => { const sel = document.getElementById('jc-vehicle-sel'); if (sel) sel.innerHTML = `` + (r.data?.vehicles || []).filter(v => v.status === 'active').map(v => ``).join(''); }); } async function submitAddJobCard() { const btn = document.querySelector('#modal-add-jobcard .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('add-jc-form'); const res = await API.post('jobcards/create', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(`Job Card ${res.data.job_number} created!`, 'success'); Modal.close(); // Auto-apply job type defaults (planning + checklist) if (data.job_type) await _applyJobTypeDefaultsToCard(res.data.id, data.job_type); Router.navigate('jobcards', { id: res.data.id }); } else Toast.show(res.message, 'error'); } // Show preview badge when type is changed in create modal function applyJobTypeDefaults(slug) { const type = (_jcTypes || []).find(t => t.slug === slug); const previewEl = document.getElementById('jc-type-defaults-preview'); if (!previewEl) return; if (!type || (!type.default_planning_items?.length && !type.default_checklist_items?.length)) { previewEl.innerHTML = ''; return; } const planCount = (type.default_planning_items || []).length; const checkCount = (type.default_checklist_items || []).length; previewEl.innerHTML = `
${planCount ? `📋 ${planCount} planning task${planCount > 1 ? 's' : ''} will be added` : ''} ${checkCount ? `☑️ ${checkCount} checklist item${checkCount > 1 ? 's' : ''} will be added` : ''}
`; } async function _applyJobTypeDefaultsToCard(jobCardId, jobTypeSlug) { const type = (_jcTypes || []).find(t => t.slug === jobTypeSlug); if (!type) return; const planning = type.default_planning_items || []; const checklist = type.default_checklist_items || []; // Add planning items for (let i = 0; i < planning.length; i++) { const it = planning[i]; if (it.item?.trim()) { await API.post('jobcards/planning', { action: 'add', job_card_id: jobCardId, item: it.item, item_type: it.item_type || 'task', sort_order: i }); } } // Add checklist items for (let i = 0; i < checklist.length; i++) { const it = checklist[i]; if (it.item?.trim()) { await API.post('jobcards/checklist', { action: 'add', job_card_id: jobCardId, item: it.item, sort_order: i }); } } } async function openEditJobCardModal(id) { const res = await API.post('jobcards/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const j = res.data.job_card; Modal.open({ id: 'edit-jobcard', title: 'Edit Job Card', size: 'modal-lg', body: `
`, footer: ` ` }); API.post('clients/list', { limit: 999 }).then(r => { const sel = document.getElementById('jc-edit-client'); if (sel) sel.innerHTML = `` + (r.data?.clients || []).map(c => ``).join(''); }); API.post('auth/users_list', {}).then(r => { const sel = document.getElementById('jc-edit-tech'); if (sel) sel.innerHTML = `` + (r.data?.users || []).map(u => ``).join(''); }); API.post('fleet/vehicles', { action: 'list' }).then(r => { const sel = document.getElementById('jc-edit-vehicle'); if (sel) sel.innerHTML = `` + (r.data?.vehicles || []).filter(v => v.status === 'active').map(v => ``).join(''); }); } async function submitEditJobCard(id) { const btn = document.querySelector('#modal-edit-jobcard .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('edit-jc-form'); const res = await API.post('jobcards/update', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Job card updated.', 'success'); Modal.close(); renderJobCardDetail(id); } else Toast.show(res.message, 'error'); } // ── Notes ───────────────────────────────────────────────────── function openAddJcNoteModal(jobId) { Modal.open({ id: 'add-jc-note', title: 'Add Note', body: `
`, footer: ` ` }); } async function submitJcNote(jobId) { const data = getFormData('jc-note-form'); const res = await API.post('jobcards/add_note', data); if (res.success) { Toast.show('Note added.', 'success'); Modal.close(); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } // ── Planning ────────────────────────────────────────────────── async function loadJcPlanning(jobId) { const wrap = document.getElementById('jc-planning-list'); if (!wrap) return; const res = await API.post('jobcards/planning', { action: 'list', job_card_id: jobId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } renderPlanningItems(wrap, res.data.items, jobId); } function renderPlanningItems(wrap, items, jobId) { if (!items.length) { wrap.innerHTML = `
📋
No planning items yet

Add tasks, tools, and parts you'll need for this job.

`; return; } const done = items.filter(i => i.is_checked).length; const pct = Math.round(done / items.length * 100); wrap.innerHTML = `
${pct}% ${done}/${items.length}
${items.map(item => `
${PLAN_ICONS[item.item_type] || '☑️'} ${item.item} ${item.item_type}
`).join('')}`; } async function togglePlanningItem(id, jobId, checked) { await API.post('jobcards/planning', { action: 'check', id, job_card_id: jobId, is_checked: checked ? 1 : 0 }); loadJcPlanning(jobId); } async function deletePlanningItem(id, jobId) { await API.post('jobcards/planning', { action: 'delete', id, job_card_id: jobId }); loadJcPlanning(jobId); } function openAddPlanningItem(jobId) { Modal.open({ id: 'add-planning', title: 'Add Planning Item', body: `
`, footer: ` ` }); } async function submitPlanningItem(jobId) { const data = getFormData('planning-form'); const res = await API.post('jobcards/planning', data); if (res.success) { Toast.show('Added.', 'success'); Modal.close(); loadJcPlanning(jobId); } else Toast.show(res.message, 'error'); } // ── Checklist ───────────────────────────────────────────────── async function loadJcChecklist(jobId) { const wrap = document.getElementById('jc-checklist-list'); if (!wrap) return; const res = await API.post('jobcards/checklist', { action: 'list', job_card_id: jobId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } renderChecklistItems(wrap, res.data.items, jobId); } function renderChecklistItems(wrap, items, jobId) { if (!items.length) { wrap.innerHTML = `
☑️
No checklist items

Add items to tick off while on site.

`; return; } const done = items.filter(i => i.is_checked).length; const pct = Math.round(done / items.length * 100); wrap.innerHTML = `
${pct}% ${done}/${items.length}
${items.map(item => `
${item.item} ${item.checked_at ? `${Fmt.ago(item.checked_at)}` : ''}
`).join('')} ${pct === 100 ? `
All items complete!
` : ''}`; } async function toggleChecklistItem(id, jobId, checked) { await API.post('jobcards/checklist', { action: 'check', id, job_card_id: jobId, is_checked: checked ? 1 : 0 }); loadJcChecklist(jobId); } async function deleteChecklistItem(id, jobId) { await API.post('jobcards/checklist', { action: 'delete', id, job_card_id: jobId }); loadJcChecklist(jobId); } function openAddChecklistItem(jobId) { Modal.open({ id: 'add-checklist', title: 'Add Checklist Item', body: `
`, footer: ` ` }); } async function submitChecklistItem(jobId) { const data = getFormData('checklist-form'); const res = await API.post('jobcards/checklist', data); if (res.success) { Toast.show('Added.', 'success'); Modal.close(); loadJcChecklist(jobId); } else Toast.show(res.message, 'error'); } // ── Report ──────────────────────────────────────────────────── async function loadJcReport(jobId) { const wrap = document.getElementById('jc-report-wrap'); if (!wrap) return; const res = await API.post('jobcards/report', { action: 'get', job_card_id: jobId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const { report, checklist_total, checklist_done, planning_total, planning_done } = res.data; const clPct = checklist_total ? Math.round(checklist_done / checklist_total * 100) : 100; const plPct = planning_total ? Math.round(planning_done / planning_total * 100) : 100; if (report) { wrap.innerHTML = `
Report submitted by ${report.submitted_by_name || 'technician'} · ${Fmt.datetime(report.submitted_at)}
${plPct}%
Planning Done
${clPct}%
Checklist Done

${report.client_name_signed || '—'}

${report.client_satisfied ? '✅ Yes' : '❌ No'}

${report.work_performed || '—'}

${report.materials_used || '—'}

${report.issues_found || '—'}

${report.recommendations || '—'}

${isAdminOrDev() ? `` : ''}
`; } else { wrap.innerHTML = `
${plPct}%
Planning Complete
${clPct}%
Checklist Complete
${clPct < 100 ? `
⚠️ Checklist not fully complete — ${checklist_done}/${checklist_total} items ticked. You can still submit.
` : ''} `; } } function openReportModal(jobId, isEdit = false) { Modal.open({ id: 'submit-report', title: isEdit ? 'Edit Report' : '📋 Completion Report', size: 'modal-lg', body: `
Client Sign-Off *
Have the client sign above with their finger or mouse
`, footer: ` ` }); // Init signature pad after modal renders setTimeout(() => initSignaturePad(), 100); } // ── Signature Pad ───────────────────────────────────────────── let _sigPad = null; function initSignaturePad() { const canvas = document.getElementById('sig-canvas'); if (!canvas) return; // Resize canvas to actual pixel dimensions const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * (window.devicePixelRatio || 1); canvas.height = rect.height * (window.devicePixelRatio || 1); const ctx = canvas.getContext('2d'); ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); ctx.strokeStyle = '#1a1a2e'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; let drawing = false, lastX = 0, lastY = 0; function getPos(e) { const r = canvas.getBoundingClientRect(); const src = e.touches ? e.touches[0] : e; return { x: src.clientX - r.left, y: src.clientY - r.top }; } function start(e) { e.preventDefault(); drawing = true; const p = getPos(e); lastX = p.x; lastY = p.y; } function draw(e) { if (!drawing) return; e.preventDefault(); const p = getPos(e); ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(p.x, p.y); ctx.stroke(); lastX = p.x; lastY = p.y; } function stop() { drawing = false; saveSignature(canvas); } canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', draw); canvas.addEventListener('mouseup', stop); canvas.addEventListener('mouseleave', stop); canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchmove', draw, { passive: false }); canvas.addEventListener('touchend', stop); _sigPad = canvas; } function clearSignature() { if (!_sigPad) return; const ctx = _sigPad.getContext('2d'); ctx.clearRect(0, 0, _sigPad.width, _sigPad.height); const inp = document.getElementById('sig-data'); if (inp) inp.value = ''; } function saveSignature(canvas) { const inp = document.getElementById('sig-data'); if (inp) inp.value = canvas.toDataURL('image/png'); } function isSignatureEmpty(canvas) { if (!canvas) return true; const ctx = canvas.getContext('2d'); const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; return !data.some(v => v !== 0); } async function submitReport(jobId) { const btn = document.querySelector('#modal-submit-report .btn-primary'); // Validate signature const canvas = document.getElementById('sig-canvas'); if (isSignatureEmpty(canvas)) { Toast.show('Please have the client sign before submitting.', 'error'); return; } saveSignature(canvas); // ensure latest data captured // Validate client name const clientName = document.querySelector('#report-form [name="client_name_signed"]')?.value?.trim(); if (!clientName) { Toast.show('Client name is required for sign-off.', 'error'); return; } if (btn) btn.classList.add('loading'); const data = getFormData('report-form'); const res = await API.post('jobcards/report', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Report submitted! Now depart site and return to base to complete the job.', 'success'); Modal.close(); renderJobCardDetail(jobId); } else Toast.show(res.message, 'error'); } // ── PDF Generation ─────────────────────────────────────────── function openJcPdf(jobId, type) { const url = `api/jobcards/pdf.php?job_card_id=${jobId}&type=${type}&token=${encodeURIComponent(Auth.getToken() || '')}`; window.open(url, '_blank'); } async function syncStockCost(jobId) { const btn = document.getElementById('sync-stock-btn'); if (btn) { btn.classList.add('loading'); btn.disabled = true; } const res = await API.post('jobcards/sync_stock_cost', { job_card_id: jobId }); if (btn) { btn.classList.remove('loading'); btn.disabled = false; } if (res.success) { const { updated, changes } = res.data; if (updated > 0) { const detail = changes.map(c => `${c.item}: R${(c.old_cost || 0).toFixed(2)} → R${c.new_cost.toFixed(2)}`).join('\n'); Toast.show(`${updated} cost(s) updated.`, 'success'); if (detail) console.info('Sync Stock Cost changes:\n' + detail); } else { Toast.show('All costs already up to date.', 'info'); } loadJcCosting(jobId); // Refresh costing tab } else { Toast.show(res.message || 'Sync failed.', 'error'); } } async function openReturnFromJobCard(jobId) { // Fetch job card details to pre-fill the return modal const jcRes = await API.post('jobcards/detail', { id: jobId }); if (!jcRes.success) { Toast.show('Could not load job card', 'error'); return; } const jc = jcRes.data.job_card; // Load items booked out to this job const itemsRes = await API.post('stock/jobcard_items', { job_card_id: jobId }); Modal.open({ id: 'stock-return-jc', title: `↩️ Return Items — ${jc.job_number}`, size: 'modal-lg', body: (() => { if (!itemsRes.success || !itemsRes.data.items.length) { return '
No items currently booked out to this job card.
'; } const lines = itemsRes.data.items.map(item => `
${item.item_name}
${item.sku} · ${parseFloat(item.net_issued).toFixed(2)} ${item.unit} on job
of ${parseFloat(item.net_issued).toFixed(2)} ${item.unit}
`).join(''); return `
Select quantities to return to stock. Items will be credited back to inventory.
${lines}
`; })(), footer: ` ${itemsRes.success && itemsRes.data.items.length ? `` : ''}` }); } function openJcFinancialReport() { const p = new URLSearchParams({ date_from: jcState.date_from || '', date_to: jcState.date_to || '', status: jcState.status || '', type: jcState.type || '', internal: jcState.internal !== '' ? jcState.internal : '', token: Auth.getToken() || '', }); window.open(`api/jobcards/financial_report.php?${p.toString()}`, '_blank'); } // ── JC Slips ────────────────────────────────────────────────── async function loadJcSlips(jobId) { const wrap = document.getElementById('jc-slips-list'); if (!wrap) return; wrap.innerHTML = '
'; const res = await API.post('jobcards/slips', { action: 'list', job_card_id: jobId }); if (!res.success) { wrap.innerHTML = '

Failed to load slips.

'; return; } const slips = res.data.slips || []; if (!slips.length) { wrap.innerHTML = '
No slips captured yet
Tap "+ Capture Slip" to add an expense
'; return; } const total = slips.reduce((s, sl) => s + parseFloat(sl.amount || 0), 0); wrap.innerHTML = `
${slips.map(s => ` `).join('')}
ImageDateMerchantCategoryAmountBy
${slipImageCell(s)} ${Fmt.date(s.slip_date)} ${s.merchant || '—'} ${s.category || '—'} R ${parseFloat(s.amount || 0).toFixed(2)} ${s.captured_by_name || '—'}
TotalR ${total.toFixed(2)}
`; } async function deleteJcSlip(slipId, jobId) { if (!confirm('Delete this slip?')) return; const res = await API.post('jobcards/slips', { action: 'delete', id: slipId, job_card_id: jobId }); if (res.success) { Toast.show('Deleted', 'success'); loadJcSlips(jobId); } else Toast.show(res.message, 'error'); } // ── JC Costing Report ───────────────────────────────────────── async function loadJcCosting(jobId) { const wrap = document.getElementById('jc-costing-wrap'); if (!wrap) return; wrap.innerHTML = '
'; const res = await API.post('jobcards/costing', { job_card_id: jobId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const d = res.data; const s = d.summary; const profit = s.net_profit >= 0; wrap.innerHTML = `
Costing Report — ${d.job_card.job_number}
${!d.job_card.vehicle_id ? '
⚠ No vehicle assigned — travel costs not calculated. Edit the job card to assign a vehicle and ODO readings.
' : ''} ${d.job_card.is_internal ? '
ℹ️ Internal job — marked as not invoiced.
' : ''}
Invoice Number
${d.job_card.invoice_no || 'Not set'}
Invoice Amount
${d.job_card.invoice_amount ? 'R ' + parseFloat(d.job_card.invoice_amount).toFixed(2) : 'Not set'}
Total Cost
R ${s.total_cost.toFixed(2)}
Net ${d.job_card.invoice_amount && (parseFloat(d.job_card.invoice_amount) - s.total_cost) >= 0 ? 'Profit' : 'Loss'}
${d.job_card.invoice_amount ? (parseFloat(d.job_card.invoice_amount) - s.total_cost >= 0 ? '+' : '') + 'R ' + (parseFloat(d.job_card.invoice_amount) - s.total_cost).toFixed(2) : '—'}
Labour & Time
${d.labour.length ? (() => { // All team members share the same timeline — grab time from first record const t0 = d.labour[0]; const pausedMin = (t0.paused_travel_min || 0) + (t0.paused_work_min || 0); const pausedHrs = (pausedMin / 60).toFixed(2); const travelPauseParts = t0.paused_travel_min > 0 ? `${t0.paused_travel_min}min travel` : ''; const workPauseParts = t0.paused_work_min > 0 ? `${t0.paused_work_min}min work` : ''; const pauseBreakdown = [travelPauseParts, workPauseParts].filter(Boolean).join(' + '); const totalLabourCost = d.labour.reduce((s, t) => s + (t.labour_cost || 0), 0); // ── Shared timeline block ── const timelineBlock = `
Shared Job Timeline — ${d.labour.length > 1 ? `${d.labour.length} team members worked simultaneously` : 'Solo technician'}
🚗 Driving ${t0.driving_hours}h (${t0.driving_minutes} min)
🔧 Working ${t0.working_hours}h (${t0.working_minutes} min)
${pausedMin > 0 ? `
⏸ Paused${pauseBreakdown ? ' (' + pauseBreakdown + ')' : ''} ${pausedHrs}h (${pausedMin} min) — not costed
` : ''}
Billable time per person ${t0.total_hours}h (${t0.total_minutes} min)
`; // ── Per-person rate × shared time rows ── const memberRows = d.labour.map(t => `
${avatarHTML ? avatarHTML(t.name) : '👤'}
${t.name}
${t.total_hours}h × R ${t.hourly_rate.toFixed(2)}/hr
R ${t.labour_cost.toFixed(2)}
${!t.hourly_rate ? '
⚠ No salary set
' : ''}
`).join(''); // ── Combined total (always shown) ── const combinedRow = `
Total Labour Cost R ${totalLabourCost.toFixed(2)}
${d.labour.length > 1 ? `
${d.labour.length} team members × ${t0.total_hours}h billable
` : ''}`; return timelineBlock + memberRows + combinedRow; })() : '

No time logs recorded yet

'}
Travel

${d.travel.vehicle || 'Not assigned'}

${d.travel.km_travelled} km

R ${d.travel.travel_cost.toFixed(2)} @ R${d.travel.cost_per_km}/km

R ${d.travel.travel_quote.toFixed(2)} @ R${d.travel.quote_per_km}/km

${!d.travel.km_travelled && d.job_card.vehicle_id ? '

Set ODO start/end on job card to calculate travel.

' : ''}
Consumables (Stock)
${d.consumables.length ? `` : ''}
${d.consumables.length ? (() => { // Net transactions by item — group by stock_item_id + unit_cost const netMap = {}; d.consumables.forEach(c => { const key = `${c.stock_item_id}_${c.unit_cost}`; if (!netMap[key]) { netMap[key] = { ...c, netQty: 0, netTotal: 0 }; } const mult = c.transaction_type === 'return' ? -1 : 1; netMap[key].netQty += mult * parseFloat(c.quantity || 0); netMap[key].netTotal += mult * parseFloat(c.unit_cost || 0) * parseFloat(c.quantity || 0); }); const rows = Object.values(netMap).filter(r => r.netQty !== 0); if (!rows.length) return '

All items have been returned

'; return ` ${rows.map(r => ``).join('')}
ItemNet QtyUnit CostNet Total
${r.item_name} ${r.sku || ''} ${r.netQty % 1 === 0 ? r.netQty : r.netQty.toFixed(2)} ${r.unit || ''} R ${parseFloat(r.unit_cost || 0).toFixed(2)} R ${r.netTotal.toFixed(2)}
`; })() : '

No stock issued to this job card

'}
Expense Slips
${d.slips.length ? ` ${d.slips.map(sl => ``).join('')}
DateMerchantCategoryAmount
${Fmt.date(sl.slip_date)}${sl.merchant || '—'}${sl.category || '—'}R ${parseFloat(sl.amount || 0).toFixed(2)}
` : '

No slips captured

'}
Cost Summary
Labour R ${s.labour_cost.toFixed(2)}
Travel R ${s.travel_cost.toFixed(2)}
Consumables (Stock) R ${s.consumables_cost.toFixed(2)}
Expense Slips R ${s.slips_total.toFixed(2)}
Total Cost R ${s.total_cost.toFixed(2)}
Invoice Amount ${d.job_card.invoice_amount ? 'R ' + parseFloat(d.job_card.invoice_amount).toFixed(2) : 'Not set — edit job card'}
${(() => { if (!d.job_card.invoice_amount) return '
Enter an invoice amount in the job card edit to see net profit/loss
'; const net = parseFloat(d.job_card.invoice_amount) - s.total_cost; const isProfit = net >= 0; return '
Net ' + (isProfit ? 'Profit' : 'Loss') + '
' + (isProfit ? '+' : '') + 'R ' + net.toFixed(2) + '
Invoice R ' + parseFloat(d.job_card.invoice_amount).toFixed(2) + ' - Cost R ' + s.total_cost.toFixed(2) + '
'; })()}
`; }