// ============================================================
// 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 = `
${Auth.can('jobcards', 'view_costing') ? `
` : ''}
`;
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 = ``; return; }
const jobs = res.data.job_cards;
if (!jobs.length) {
wrap.innerHTML = ``;
document.getElementById('jc-pagination').innerHTML = '';
return;
}
// Tech sees cards, admin sees table
if (isTechOnly()) {
wrap.innerHTML = `
${jobs.map(j => `
${['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 = `
| Job # | Title | Client | Site | Scheduled | Assigned To | Priority | Status | Net P/L | |
${jobs.map(j => `
| ${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') ? `` : ''}
|
`).join('')}
`;
}
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 = `
${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_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
`}
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 = `
${Auth.can('jobcards', 'view_costing') ? `` : ''}
${j.description ? `
${j.description}
` : ''}
${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}
`;
}
// ── Shared render helpers ─────────────────────────────────────
function renderNotes(notes, jobId) {
if (!notes.length) return ``;
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 => `
${isAdminOrDev() ? `
` : ''}
`).join('')}
`;
}
function renderLocationList(locations) {
if (!locations.length) return ``;
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 '';
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: `
`,
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)}
${report.work_performed || '—'}
${report.materials_used || '—'}
${report.issues_found || '—'}
${report.recommendations || '—'}
${isAdminOrDev() ? `` : ''}
`;
} else {
wrap.innerHTML = `
${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: ``,
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 = `
| Image | Date | Merchant | Category | Amount | By | |
${slips.map(s => `
| ${slipImageCell(s)} |
${Fmt.date(s.slip_date)} |
${s.merchant || '—'} |
${s.category || '—'} |
R ${parseFloat(s.amount || 0).toFixed(2)} |
${s.captured_by_name || '—'} |
|
`).join('')}
| Total | R ${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) : '—'}
${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
'}
${!d.travel.km_travelled && d.job_card.vehicle_id ? '
Set ODO start/end on job card to calculate travel.
' : ''}
${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 `
| Item | Net Qty | Unit Cost | Net Total |
${rows.map(r => `
| ${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)} |
`).join('')}
`;
})() : '
No stock issued to this job card
'}
${d.slips.length ? `
| Date | Merchant | Category | Amount |
${d.slips.map(sl => `| ${Fmt.date(sl.slip_date)} | ${sl.merchant || '—'} | ${sl.category || '—'} | R ${parseFloat(sl.amount || 0).toFixed(2)} |
`).join('')}
` : '
No slips captured
'}
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) + '
';
})()}
`;
}