// ============================================================ // Slip Manager — unified (Slips page + JC tab + fuel slips) // ============================================================ let slipsState = { page: 1, status: '', my_only: false , date_from: '', date_to: '', }; // ── Cached categories ───────────────────────────────────────── let _slipCategories = null; async function getSlipCategories() { if (_slipCategories) return _slipCategories; const res = await API.post('slips/categories', { action: 'list' }); _slipCategories = (res.data?.categories || []).map(c => c.name); if (!_slipCategories.length) _slipCategories = ['Hardware','Consumable','Fuel','Food','Accommodation','Other']; return _slipCategories; } function invalidateSlipCategories() { _slipCategories = null; } async function renderSlips(params = {}) { const content = document.getElementById('page-content'); const isAdmin = Auth.isAdmin() || Auth.isHR(); const now = new Date(); const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); const today = now.toISOString().slice(0, 10); slipsState.date_from = firstOfMonth; slipsState.date_to = today; slipsState.my_only = !isAdmin; content.innerHTML = `

Slip Manager

All expenses including fuel slips

${isAdmin ? `` : ''} ${Auth.can('slips','reports') ? `` : ''} ${Auth.can('slips','create') ? `` : ''} ${Auth.can('slips','create') ? `` : ''}
${[1,2,3,4,5].map(() => `
`).join('')}
${isAdmin ? `` : ''}
`; loadSlipStats(); await loadSlips(1); } async function loadSlipStats() { const res = await API.post('slips/stats', {}); const bar = document.getElementById('slip-stats-bar'); if (!bar || !res.success) return; const d = res.data; const mom = d.mom_change !== null ? `${d.mom_change > 0 ? '▲' : '▼'} ${Math.abs(d.mom_change)}%` : ''; bar.innerHTML = `
This Month
${Fmt.currency(d.this_month)}
${d.month_label} ${mom}
Last Month
${Fmt.currency(d.last_month)}
${d.last_month_label}
Fuel This Month
${Fmt.currency(d.fuel_this_month)}
Fleet fuel costs
Vehicle Costs
${Fmt.currency(d.vehicle_costs)}
All fleet this month
Unlinked Slips
${d.unlinked_count}
Needs attention
`; } function clearSlipDates() { const now = new Date(); const from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); const to = now.toISOString().slice(0, 10); slipsState.date_from = from; slipsState.date_to = to; const fromEl = document.getElementById('slip-date-from'); const toEl = document.getElementById('slip-date-to'); if (fromEl) fromEl.value = from; if (toEl) toEl.value = to; loadSlips(1); } function openSlipReport() { const token = Auth.getToken(); const from = slipsState.date_from || ''; const to = slipsState.date_to || ''; const status = slipsState.status || ''; const myOnly = slipsState.my_only ? 1 : 0; const url = `api/slips/report.php?token=${encodeURIComponent(token)}&date_from=${from}&date_to=${to}&status=${status}&my_only=${myOnly}`; window.open(url, '_blank'); } // ── Slip Settings ───────────────────────────────────────────── async function openSlipSettings() { const res = await API.post('slips/categories', { action: 'list' }); const cats = res.data?.categories || []; Modal.open({ id: 'slip-settings', title: '⚙️ Slip Settings', size: 'md', body: `

These appear in the category dropdown when adding/editing slips. Categories with existing records cannot be deleted.

${cats.length ? cats.map(c => `
${c.name}
`).join('') : '
No categories yet.
'}
`, footer: `` }); } async function addSlipCategory() { const input = document.getElementById('new-slip-cat-input'); const name = input?.value?.trim(); if (!name) return; const res = await API.post('slips/categories', { action: 'add', name }); if (res.success) { Toast.show(`Category "${name}" added.`, 'success'); input.value = ''; invalidateSlipCategories(); // Append to list without closing modal const list = document.getElementById('slip-cat-list'); if (list) { const emptyMsg = list.querySelector('div[style*="color:#9ca3af"]'); if (emptyMsg) emptyMsg.remove(); const div = document.createElement('div'); div.id = `slip-cat-${res.data.id}`; div.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--border)'; div.innerHTML = `${name}`; list.appendChild(div); } } else { Toast.show(res.message, 'error'); } } async function deleteSlipCategory(id, name) { const res = await API.post('slips/categories', { action: 'delete', id }); if (res.success) { Toast.show(`"${name}" removed.`, 'success'); document.getElementById(`slip-cat-${id}`)?.remove(); invalidateSlipCategories(); } else { Toast.show(res.message, 'error'); } } async function loadSlips(page = 1) { slipsState.page = page; const wrap = document.getElementById('slips-table-wrap'); if (!wrap) return; const res = await API.post('slips/list', { page, limit: 25, status: slipsState.status, my_only: slipsState.my_only ? 1 : 0, date_from: slipsState.date_from || '', date_to: slipsState.date_to || '' }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const slips = res.data.slips; if (!slips.length) { wrap.innerHTML = `
🧾
No slips found
`; return; } const total = slips.reduce((s, sl) => s + parseFloat(sl.amount || 0), 0); const summaryBar = `
Showing ${slips.length} slips
Total: ${Fmt.currency(total)}
`; if (window.innerWidth < 768) { wrap.innerHTML = summaryBar + `
${slips.map(s => `
${slipImageCell(s)}
${s.merchant || '—'}
${Fmt.date(s.slip_date)} · ${s.category || '—'}
${s.project_name ? '📁 '+s.project_name : s.job_number ? '🔧 '+s.job_number : s.vehicle_reg ? '🚗 '+s.vehicle_reg : ''}
${s.amount ? Fmt.currency(s.amount) : '—'}
${Fmt.statusBadge(s.status)} ${s.source_type === 'slip' ? `
` : ''}
`).join('')}
`; } else { wrap.innerHTML = summaryBar + `
${slips.map(s => ` `).join('')}
ImageDateMerchantCategoryAmountMethodLinked ToStatusBy
${slipImageCell(s)} ${Fmt.date(s.slip_date)} ${s.merchant || '—'} ${s.category || '—'} ${s.amount ? Fmt.currency(s.amount) : '—'} ${Fmt.capitalize(s.payment_method || '—')} ${s.project_name ? '📁 ' + s.project_name : s.job_number ? '🔧 ' + s.job_number : s.vehicle_reg ? '🚗 ' + s.vehicle_reg : '—'} ${Fmt.statusBadge(s.status)} ${s.user_name || '—'} ${s.source_type === 'slip' ? `` : ''}
`; } renderPagination('slips-pagination', res.data.pagination, `p => loadSlips(p)`); } // ── Shared image cell ───────────────────────────────────────── function slipImageCell(s) { if (!s.filename && !s.image_path) return `No image`; const url = s.image_path || `uploads/slips/${s.filename}`; const isPdf = s.filename && s.filename.toLowerCase().endsWith('.pdf'); if (isPdf) { return ` 📄 PDF `; } return ``; } // ── Edit slip modal ─────────────────────────────────────────── async function openEditSlipModal(slipId, slipData) { const slip = slipData; if (!slip) { Toast.show('Slip data missing.', 'error'); return; } const cats = await getSlipCategories(); const methods = ['cash','card','eft','company_card','credit']; Modal.open({ id: 'edit-slip', title: '✏️ Edit Slip', body: `
${slip.image_path ? `
${slipImageCell(slip)}
` : ''}
📎

Tap to replace image or PDF

`, footer: ` ` }); } function previewEditSlipImg(input) { if (!input.files[0]) return; const prev = document.getElementById('edit-slip-img-preview'); if (!prev) return; const file = input.files[0]; if (file.type === 'application/pdf') { prev.innerHTML = `📄
${file.name}
`; } else { prev.innerHTML = ``; } } async function submitEditSlip(slipId) { const btn = document.querySelector('#modal-edit-slip .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('edit-slip-form'); if (!data.amount) { Toast.show('Amount is required.', 'error'); if (btn) btn.classList.remove('loading'); return; } const fd = new FormData(); fd.append('id', slipId); Object.entries(data).forEach(([k, v]) => { if (v !== '') fd.append(k, v); }); const imgFile = document.getElementById('edit-slip-img-input')?.files[0]; if (imgFile) fd.append('slip_image', imgFile); const res = await API.postForm('slips/update', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Slip updated! ✅', 'success'); Modal.close(); loadSlips(slipsState.page); } else { Toast.show(res.message, 'error'); } } // ── Unified slip modal ───────────────────────────────────────── // jobCardId: if set, slip is linked to JC, image required, no search async function openSlipModal(jobCardId = null, onSuccess = null) { if (onSuccess) window._slipCallback = onSuccess; const cats = await getSlipCategories(); Modal.open({ id: 'add-slip', title: jobCardId ? 'Capture Job Card Slip' : 'Add Expense Slip', body: `
${jobCardId ? `` : `
`}
📷

Tap to upload or capture slip image

`, footer: ` ` }); } // JC search for slip modal let _jcSearchTimer; function searchJcForSlip(val) { clearTimeout(_jcSearchTimer); const results = document.getElementById('jc-search-results'); const hidden = document.getElementById('slip-jc-id'); const chosen = document.getElementById('jc-search-chosen'); hidden.value = ''; if (chosen) chosen.textContent = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _jcSearchTimer = setTimeout(async () => { const res = await API.post('jobcards/list', { search: val, limit: 8, page: 1 }); const jcs = res.data?.job_cards || []; if (!results) return; if (!jcs.length) { results.innerHTML = '
No job cards found
'; return; } results.innerHTML = jcs.map(j => `
${j.job_number} — ${j.title} (${j.status})
`).join(''); }, 300); } function selectJcForSlip(id, number, title) { document.getElementById('slip-jc-id').value = id; document.getElementById('jc-search-input').value = number; const chosen = document.getElementById('jc-search-chosen'); if (chosen) chosen.textContent = '✓ Linked to: ' + number + ' — ' + title; document.getElementById('jc-search-results').innerHTML = ''; } let _projSearchTimer; function searchProjForSlip(val) { clearTimeout(_projSearchTimer); const results = document.getElementById('proj-search-results'); const hidden = document.getElementById('slip-proj-id'); const chosen = document.getElementById('proj-search-chosen'); hidden.value = ''; if (chosen) chosen.textContent = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _projSearchTimer = setTimeout(async () => { const res = await API.post('projects/list', { search: val, limit: 8, page: 1 }); const projs = res.data?.projects || []; if (!results) return; if (!projs.length) { results.innerHTML = '
No projects found
'; return; } results.innerHTML = projs.map(p => `
${p.name} ${p.client_name || ''}
`).join(''); }, 300); } function selectProjForSlip(id, name) { document.getElementById('slip-proj-id').value = id; document.getElementById('proj-search-input').value = name; const chosen = document.getElementById('proj-search-chosen'); if (chosen) chosen.textContent = '✓ Linked to: ' + name; document.getElementById('proj-search-results').innerHTML = ''; } function previewSlipImg(input) { if (!input.files[0]) return; const prev = document.getElementById('slip-img-preview'); if (!prev) return; const file = input.files[0]; if (file.type === 'application/pdf') { prev.innerHTML = `
📄
${file.name}
`; } else { prev.innerHTML = ``; } } async function autoCalcVat(val, targetId) { let rate = 15; if (window._vatRate) { rate = window._vatRate; } else { try { const r = await API.post('settings/get', { group: 'finance' }); window._vatRate = rate = parseFloat(r.data?.map?.vat_rate || 15); } catch (e) { } } const vatEl = targetId ? document.getElementById(targetId) : document.getElementById('vat-input') || document.getElementById('slip-vat'); if (vatEl) vatEl.value = ((parseFloat(val) || 0) * rate / (100 + rate)).toFixed(2); } async function submitSlip(jobCardId = null) { const btn = document.querySelector('#modal-add-slip .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('add-slip-form'); if (!data.amount) { Toast.show('Amount is required.', 'error'); if (btn) btn.classList.remove('loading'); return; } const imgFile = document.getElementById('slip-img-input')?.files[0]; if (!imgFile) { Toast.show('Slip image is required.', 'error'); if (btn) btn.classList.remove('loading'); return; } // Validate the hidden IDs match what was searched (prevent raw text in search field) if (!jobCardId) { const jcSearch = document.getElementById('jc-search-input')?.value?.trim(); const jcId = document.getElementById('slip-jc-id')?.value; if (jcSearch && !jcId) { Toast.show('Select a valid job card from the search results.', 'error'); if (btn) btn.classList.remove('loading'); return; } const projSearch = document.getElementById('proj-search-input')?.value?.trim(); const projId = document.getElementById('slip-proj-id')?.value; if (projSearch && !projId) { Toast.show('Select a valid project from the search results.', 'error'); if (btn) btn.classList.remove('loading'); return; } } const fd = new FormData(); if (imgFile) fd.append('slip_image', imgFile); Object.entries(data).forEach(([k, v]) => { // Skip the display-only search text fields if (k === 'jc_search' || k === 'proj_search') return; if (v !== '') fd.append(k, v); }); if (jobCardId) fd.append('job_card_id', jobCardId); const res = await API.postForm('slips/create', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Slip saved! ✅', 'success'); Modal.close(); if (jobCardId) { loadJcSlips(jobCardId); if (typeof window._slipCallback === 'function') window._slipCallback(); } else { if (typeof loadSlips === 'function') loadSlips(slipsState.page); } } else { Toast.show(res.message, 'error'); } } // ── Fuel Slip Modal (for all roles) ────────────────────────── function openFuelSlipModal() { Modal.open({ id: 'fuel-slip', title: '⛽ Capture Fuel Slip', body: `

Tap to capture fuel slip image

`, footer: ` ` }); API.post('fleet/vehicles', { action: 'list' }).then(r => { const sel = document.getElementById('fuel-vehicle-sel'); if (sel) sel.innerHTML = `` + (r.data?.vehicles || []).filter(v => v.status === 'active') .map(v => ``).join(''); }); } async function onFuelVehicleChange(vehicleId) { const odoInput = document.getElementById('fuel-odo-input'); const hint = document.getElementById('fuel-odo-hint'); if (!vehicleId || !odoInput || !hint) return; const res = await API.post('fleet/latest_odo', { vehicle_id: vehicleId }); if (res.success && res.data.latest_odo > 0) { const latest = res.data.latest_odo; odoInput.min = latest; hint.style.display = 'block'; hint.innerHTML = `Latest recorded: ${latest.toLocaleString()} km — must be ≥ this.`; } else { odoInput.removeAttribute('min'); hint.style.display = 'none'; } } function previewFuelSlipImg(input) { if (!input.files[0]) return; const prev = document.getElementById('fuel-img-preview'); if (prev) prev.innerHTML = ``; } async function submitFuelSlip() { const btn = document.querySelector('#modal-fuel-slip .btn-primary'); const data = getFormData('fuel-slip-form'); if (!data.vehicle_id) { Toast.show('Select a vehicle.', 'error'); return; } if (!data.amount) { Toast.show('Amount required.', 'error'); return; } const imgFile = document.getElementById('fuel-img-input')?.files[0]; if (!imgFile) { Toast.show('Slip image required.', 'error'); return; } // Client-side ODO validation const odoInput = document.getElementById('fuel-odo-input'); if (odoInput && odoInput.min && parseInt(data.odo_reading) < parseInt(odoInput.min)) { Toast.show(`ODO cannot be less than the latest reading (${parseInt(odoInput.min).toLocaleString()} km).`, 'error'); return; } if (btn) btn.classList.add('loading'); const fd = new FormData(); fd.append('slip_image', imgFile); fd.append('action', 'fuel_slip'); Object.entries(data).forEach(([k, v]) => fd.append(k, v)); const res = await API.postForm('fleet/costs', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Fuel slip submitted! ⛽', 'success'); Modal.close(); // Refresh whichever page is open if (typeof loadSlips === 'function') loadSlips(slipsState.page); } else Toast.show(res.message, 'error'); } // Legacy aliases function openAddSlipModal() { openSlipModal(); } function submitAddSlip() { submitSlip(); }