// ============================================================ // Elegant Work โ€” Cash Flow Planner // ============================================================ let cfState = { monthYear: null, month: null, income: [], expenses: [], unmatchedSlips: [], summary: {}, history: [], }; async function renderCashflow() { const content = document.getElementById('page-content'); const isAdmin = Auth.can('cashflow','edit'); // Default to current month const now = new Date(); cfState.monthYear = cfState.monthYear || `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; content.innerHTML = `

Cash Flow Planner

Monthly income, expenses & profit planning

${Auth.can('cashflow','reports') ? `` : ''} ${isAdmin ? `` : ''} ${isAdmin ? `` : ''}
`; await cfLoad(); } async function cfLoad() { const res = await API.post('cashflow/month', { action: 'get', month_year: cfState.monthYear }); if (!res.success) { document.getElementById('cf-content').innerHTML = `
${res.message}
`; return; } cfState.month = res.data.month; cfState.income = res.data.income; cfState.expenses = res.data.expenses; cfState.unmatchedSlips = res.data.unmatched_slips; cfState.summary = res.data.summary; cfState.history = res.data.history; const isAdmin = Auth.isAdmin() || Auth.isHR(); const isClosed = cfState.month.status === 'closed'; // Show/hide close button const closeBtn = document.getElementById('cf-close-btn'); if (closeBtn) closeBtn.style.display = (!isClosed && isAdmin) ? 'inline-flex' : 'none'; cfRender(); } function cfRender() { const m = cfState.month; const sum = cfState.summary; const isClosed = m.status === 'closed'; const isAdmin = Auth.isAdmin() || Auth.isHR(); // Month nav const prevMy = cfPrevMonth(m.month_year); const nextMy = cfNextMonth(m.month_year); const hasNext = cfState.history.some(h => h.month_year === nextMy); const el = document.getElementById('cf-content'); el.innerHTML = `
${m.label} ${isClosed ? `๐Ÿ”’ Closed` : `Active`}
${Auth.can('cashflow','reports') ? `
${cfStatCard('In Bank', Fmt.currency(m.opening_balance), '#1b4b8a', isAdmin && !isClosed ? 'cfOpenMonthSettings()' : null)} ${cfStatCard('Income Total', Fmt.currency(sum.total_income), '#15803d')} ${cfStatCard('Expenses Total', Fmt.currency(sum.total_expenses), '#dc2626')} ${cfStatCard('Still to Receive', Fmt.currency(sum.pending_income), '#0369a1')} ${cfStatCard('Still to Pay', Fmt.currency(sum.pending_expenses), '#b45309')} ${cfStatCard('Net Pending', Fmt.currency(sum.net), sum.net >= 0 ? '#15803d' : '#dc2626')} ${cfStatCard('Projected Balance', Fmt.currency(sum.closing), sum.closing >= 0 ? '#15803d' : '#dc2626')}
` : ''} ${m.payroll_amount > 0 ? `
๐Ÿ’ผ Payroll โ€” ${Fmt.currency(m.payroll_amount)}
${!isClosed && isAdmin ? `` : ''}
` : (isAdmin && !isClosed ? `
+ Set payroll amount for ${m.label}
` : '')}
๐Ÿ’ฐ Income
${!isClosed ? ` ` : ''}
${cfIncomeTable(isClosed, isAdmin)}
๐Ÿ’ธ Expenses
${!isClosed ? ` ` : ''}
${cfExpenseTable(isClosed, isAdmin)}
${cfState.unmatchedSlips.length && !isClosed ? cfUnmatchedTray() : ''} `; } function cfStatCard(label, value, color, onclick = null) { const clickAttr = onclick ? `onclick="${onclick}" style="cursor:pointer"` : ''; return `
${label}
${value}
${onclick ? `
Click to edit
` : ''}
`; } function cfIncomeTable(isClosed, isAdmin) { const planned = cfState.income.filter(r => r.type === 'planned'); const actual = cfState.income.filter(r => r.type === 'actual'); const rows = [...planned, ...actual]; if (!rows.length) return `
No income lines yet. ${!isClosed ? 'Add planned or actual income above.' : ''}
`; return `
${!isClosed ? '' : ''}${rows.map(r => cfIncomeRow(r, isClosed, isAdmin)).join('')}
TypeDescriptionClient / JCRecurringExpectedActualStatus
TOTAL ${Fmt.currency(rows.reduce((s,r)=>s+(parseFloat(r.expected_amount)||0),0))} ${Fmt.currency(rows.reduce((s,r)=>s+(parseFloat(r.actual_amount)||0),0))}
`; } function cfIncomeRow(r, isClosed, isAdmin) { const carriedBadge = r.is_carried_over ? `
โ†ฉ from ${r.carried_from}
` : ''; const recurBadge = r.is_recurring ? `๐Ÿ”${r.recur_end_month?' until '+r.recur_end_month:''}` : 'โ€”'; const typeBadge = `${r.type}`; const linked = r.client_name ? `${r.client_name}` : (r.job_number ? `๐Ÿ”ง ${r.job_number}` : 'โ€”'); const paidBtn = isClosed ? (r.is_paid?'โœ…':'โ€”') : ``; return ` ${typeBadge}${carriedBadge} ${r.description} ${linked} ${recurBadge} ${r.expected_amount ? Fmt.currency(r.expected_amount) : 'โ€”'} ${r.actual_amount ? Fmt.currency(r.actual_amount) : 'โ€”'} ${paidBtn} ${!isClosed ? ` ` : ''} `; } function cfExpenseTable(isClosed, isAdmin) { const planned = cfState.expenses.filter(r => r.type === 'planned'); const actual = cfState.expenses.filter(r => r.type === 'actual'); const rows = [...planned, ...actual]; const totalExp = rows.reduce((s,r)=>s+(parseFloat(r.expected_amount)||parseFloat(r.actual_amount)||0),0) + (parseFloat(cfState.month.payroll_amount)||0); if (!rows.length && !cfState.month.payroll_amount) return `
No expense lines yet.
`; return `
${!isClosed ? '' : ''} ${rows.map(r => cfExpenseRow(r, isClosed, isAdmin)).join('')}
TypeDescriptionCategoryRecurringSlipExpectedActualStatus
TOTAL ${Fmt.currency(totalExp)}
`; } function cfExpenseRow(r, isClosed, isAdmin) { const carriedBadge = r.is_carried_over ? `
โ†ฉ from ${r.carried_from}
` : ''; const recurBadge = r.is_recurring ? `๐Ÿ”${r.recur_end_month?' until '+r.recur_end_month:''}` : 'โ€”'; const typeBadge = `${r.type}`; const slipBadge = r.slip_merchant ? `๐Ÿงพ ${r.slip_merchant}` : (isClosed ? 'โ€”' : ``); const paidBtn = isClosed ? (r.is_paid?'โœ…':'โ€”') : ``; const amount = parseFloat(r.actual_amount) || parseFloat(r.expected_amount) || 0; return ` ${typeBadge}${carriedBadge} ${r.description} ${r.category || 'โ€”'} ${recurBadge} ${slipBadge} ${r.expected_amount ? Fmt.currency(r.expected_amount) : 'โ€”'} ${r.actual_amount ? Fmt.currency(r.actual_amount) : 'โ€”'} ${paidBtn} ${!isClosed ? ` ` : ''} `; } function cfUnmatchedTray() { return `
๐Ÿงพ ${cfState.unmatchedSlips.length} Unmatched Slip${cfState.unmatchedSlips.length!==1?'s':''} This Month
Match to an expense line, accept as new, or ignore
${cfState.unmatchedSlips.map(s => `
${s.merchant || s.description || 'Slip'}
${Fmt.date(s.slip_date)} ยท ${s.category||'โ€”'} ยท ${s.user_name}
${Fmt.currency(s.amount)}
`).join('')}
`; } // โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function cfNav(my) { cfState.monthYear = my; cfLoad(); } function cfMonthLabel(my) { return new Date(my + '-01').toLocaleDateString('en-ZA', { month: 'short', year: 'numeric' }); } function cfPrevMonth(my) { const d = new Date(my + '-01'); d.setMonth(d.getMonth() - 1); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } function cfNextMonth(my) { const d = new Date(my + '-01'); d.setMonth(d.getMonth() + 1); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } // โ”€โ”€ Month Settings Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function cfOpenMonthSettings() { const m = cfState.month; Modal.open({ id: 'cf-month-settings', title: `โš™๏ธ ${m.label} Settings`, body: `
`, footer: ` ` }); } async function cfSaveMonthSettings() { const data = getFormData('cf-month-form'); const res = await API.post('cashflow/month', { action: 'update', id: cfState.month.id, ...data }); if (res.success) { Toast.show('Month settings saved.', 'success'); Modal.close(); cfLoad(); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Add/Edit Income or Expense โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function cfAddLine(section, type) { cfOpenLineModal(section, type, null); } function cfEditLine(section, id) { const row = section === 'income' ? cfState.income.find(r => r.id == id) : cfState.expenses.find(r => r.id == id); if (row) cfOpenLineModal(section, row.type, row); } async function cfOpenLineModal(section, type, existing) { const isIncome = section === 'income'; const isEdit = !!existing; const title = `${isEdit?'Edit':'Add'} ${isIncome?'Income':'Expense'} โ€” ${type.charAt(0).toUpperCase()+type.slice(1)}`; const cats = await getSlipCategories().catch(() => ['Hardware','Consumable','Fuel','Other']); const now = new Date().toISOString().slice(0,7); const e = existing || {}; let clientOptions = ''; try { const cr = await API.post('clients/list', { page:1, limit:200 }); clientOptions += (cr.data?.clients || []).map(c => ``).join(''); } catch(err) {} Modal.open({ id: 'cf-line-modal', title, body: `
${type==='planned' ? `
` : `
`}
${!isIncome ? `
` : ''} ${isIncome ? `
` : ''}
`, footer: ` ` }); } let _cfJcTimer; function cfSearchJc(val) { clearTimeout(_cfJcTimer); const results = document.getElementById('cf-jc-results'); document.getElementById('cf-jc-id').value = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _cfJcTimer = setTimeout(async () => { const res = await API.post('jobcards/list', { search: val, limit: 8, page: 1 }); const jcs = res.data?.job_cards || []; if (!results) return; results.innerHTML = jcs.length ? jcs.map(j => `
${j.job_number} โ€” ${j.title||''}
`).join('') : '
No results
'; }, 300); } function cfSelectJc(id, number) { document.getElementById('cf-jc-id').value = id; document.getElementById('cf-jc-search').value = number; document.getElementById('cf-jc-results').innerHTML = ''; } async function cfSaveLine(section, type, id) { const btn = document.querySelector('#modal-cf-line-modal .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('cf-line-form'); if (!data.description) { Toast.show('Description required.', 'error'); if(btn) btn.classList.remove('loading'); return; } if (data.jc_search !== undefined) delete data.jc_search; const action = id ? 'update' : 'add'; const payload = { action, month_id: cfState.month.id, type, ...data }; if (id) payload.id = id; const res = await API.post(`cashflow/${section}`, payload); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(id ? 'Updated.' : 'Added.', 'success'); Modal.close(); cfLoad(); } else Toast.show(res.message, 'error'); } async function cfDeleteLine(section, id) { if (!confirm('Delete this line?')) return; const res = await API.post(`cashflow/${section}`, { action: 'delete', id, month_id: cfState.month.id }); if (res.success) { Toast.show('Deleted.', 'success'); cfLoad(); } else Toast.show(res.message, 'error'); } async function cfTogglePaid(section, id) { const res = await API.post(`cashflow/${section}`, { action: 'toggle_paid', id, month_id: cfState.month.id }); if (res.success) cfLoad(); else Toast.show(res.message, 'error'); } // โ”€โ”€ Slip Matching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function cfOpenMatchSlip(expenseId) { if (!cfState.unmatchedSlips.length) { Toast.show('No unmatched slips available.', 'info'); return; } Modal.open({ id: 'cf-match-slip', title: '๐Ÿ”— Match Slip to Expense', body: `

Select a slip to match to this expense line. The slip amount will become the actual cost.

${cfState.unmatchedSlips.map(s => `
${s.merchant||'Slip'}
${Fmt.date(s.slip_date)} ยท ${s.category||'โ€”'} ยท ${s.user_name}
${Fmt.currency(s.amount)}
`).join('')}
`, footer: `` }); } async function cfConfirmMatchSlip(expenseId, slipId) { const res = await API.post('cashflow/expenses', { action: 'match_slip', id: expenseId, slip_id: slipId, month_id: cfState.month.id }); if (res.success) { Toast.show('Slip matched! โœ…', 'success'); Modal.close(); cfLoad(); } else Toast.show(res.message, 'error'); } function cfMatchSlipToExpense(slipId) { const expenses = cfState.expenses.filter(e => !e.slip_id); if (!expenses.length) { Toast.show('No unmatched expense lines.', 'info'); return; } Modal.open({ id: 'cf-match-expense', title: '๐Ÿ”— Match Slip to Expense Line', body: `

Select which expense line this slip belongs to:

${expenses.map(e => `
${e.type} ${e.description}
${Fmt.currency(e.expected_amount||e.actual_amount||0)}
`).join('')}
`, footer: `` }); } async function cfAcceptSlip(slipId) { const res = await API.post('cashflow/expenses', { action: 'accept_slip', slip_id: slipId, month_id: cfState.month.id }); if (res.success) { Toast.show('Slip added as expense.', 'success'); cfLoad(); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Close Month โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function cfCloseMonth() { const unpaidIncome = cfState.income.filter(r => !r.is_paid); Modal.open({ id: 'cf-close-month', title: `๐Ÿ”’ Close ${cfState.month.label}`, size: 'lg', body: `
โš ๏ธ This action is permanent
Once closed, this month's data becomes read-only. Make sure all entries are correct before proceeding.
${unpaidIncome.length ? `

Tick any income that was not received and should appear in the next month.

${unpaidIncome.map(r => ` `).join('')}
` : `

All income lines are marked as paid. โœ…

`}
Projected closing: ${Fmt.currency(cfState.summary.closing||0)} โ€” edit this to your real bank figure
`, footer: ` ` }); } async function cfConfirmClose() { const btn = document.querySelector('#modal-cf-close-month .btn-danger'); if (btn) btn.classList.add('loading'); const checkboxes = document.querySelectorAll('input[name="carry_ids"]:checked'); const carryIds = Array.from(checkboxes).map(cb => parseInt(cb.value)); const nextOpening = parseFloat(document.getElementById('cf-next-opening')?.value || 0); const res = await API.post('cashflow/month', { action: 'close', id: cfState.month.id, carry_income_ids: JSON.stringify(carryIds), next_opening_balance: nextOpening }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(`${cfState.month.label} closed. ${cfNextMonth(cfState.month.month_year).replace('-',' ')} is ready.`, 'success'); Modal.close(); cfState.monthYear = res.data.next_month_year; cfLoad(); } else Toast.show(res.message, 'error'); } // โ”€โ”€ History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function cfOpenHistory() { const res = await API.post('cashflow/month', { action: 'history' }); if (!res.success) { Toast.show('Failed to load history.', 'error'); return; } const months = res.data.months; Modal.open({ id: 'cf-history', title: '๐Ÿ“… Cash Flow History', size: 'lg', body: `
${months.map(m => { const s = m.summary; const net = (s.net||0); return ``; }).join('')}
MonthStatusOpeningIncomeExpensesNet P/L
${m.label} ${m.status==='closed'?'๐Ÿ”’ Closed':'Active'} ${Fmt.currency(m.opening_balance)} ${Fmt.currency(s.total_income||0)} ${Fmt.currency(s.total_expenses||0)} ${Fmt.currency(net)} ${Auth.can('cashflow','reports') ? `` : ''}
`, footer: `` }); } function cfGoToMonth(my) { cfState.monthYear = my; Modal.close(); cfLoad(); } // โ”€โ”€ Report โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function cfOpenReport(monthId = null) { const id = monthId || cfState.month?.id; const token = Auth.getToken(); if (!id) { Toast.show('Load a month first.', 'error'); return; } window.open(`api/cashflow/report.php?token=${encodeURIComponent(token)}&month_id=${id}`, '_blank'); }