// ============================================================
// 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 = `
${[1,2,3,4,5].map(() => `
`).join('')}
`;
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: `
`,
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 = ``; return; }
const slips = res.data.slips;
if (!slips.length) {
wrap.innerHTML = ``;
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 + `
| Image | Date | Merchant | Category | Amount | Method | Linked To | Status | By | |
${slips.map(s => `
| ${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' ? `` : ''} |
`).join('')}
`;
}
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: `
`,
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: `
`,
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: `
`,
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(); }