// ============================================================ // Dashboard — permission-aware, role-personalised // ============================================================ async function renderDashboard() { const content = document.getElementById('page-content'); const user = Auth.getCurrentUser(); content.innerHTML = `
`; try { const res = await API.post('dashboard/stats'); if (!res.success) throw new Error(res.message); const { stats, upcoming_jobs, today_jobs, low_stock, recent_activity, permissions } = res.data; const firstName = user?.full_name?.split(' ')[0] || 'there'; const isViewOwn = permissions.jobcards?.view_own && !permissions.jobcards?.view; // ── Greeting ──────────────────────────────────────────── const greeting = `

Good ${getGreeting()}, ${firstName} 👋

${new Date().toLocaleDateString('en-ZA',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}

`; // ── Stat Cards ────────────────────────────────────────── const mkStat = (label, value, icon, color, bg, sub='', link='') => ` `; const mkMoneyStat = (label, value, color, bg, icon, sub='') => { const fmt = v => 'R ' + Math.abs(v).toLocaleString('en-ZA',{minimumFractionDigits:0,maximumFractionDigits:0}); return `
${icon}
${label}
${fmt(value)}
${sub ? `
${sub}
` : ''}
`; }; const ICONS = { clients: '', employees: '', projects: '', bugs: '', jobs: '', check: '', clock: '', leave: '', stock: '', fleet: '', slips: '', calendar: '', money: '', trending: '', alert: '', todo: '', }; let statCards = ''; if (stats.active_jobs !== undefined) statCards += mkStat('Active Jobs', stats.active_jobs, ICONS.jobs, '#d97706','rgba(217,119,6,.1)', `${stats.pending_jobs||0} pending`, 'jobcards'); if (stats.completed_today !== undefined) statCards += mkStat('Completed Today', stats.completed_today, ICONS.check, '#16a34a','rgba(22,163,74,.1)', '', 'jobcards'); if (stats.total_clients !== undefined) statCards += mkStat('Active Clients', stats.total_clients, ICONS.clients, '#0284c7','rgba(2,132,199,.1)', '', 'clients'); if (stats.total_employees !== undefined) statCards += mkStat('Active Staff', stats.total_employees, ICONS.employees, '#7c3aed','rgba(124,58,237,.1)', stats.on_leave_today ? `${stats.on_leave_today} on leave today` : '', 'employees'); if (stats.active_projects !== undefined) statCards += mkStat('Active Projects', stats.active_projects, ICONS.projects, '#1b4b8a','rgba(27,75,138,.1)', '', 'projects'); if (stats.open_bugs !== undefined) statCards += mkStat('Open Bugs', stats.open_bugs, ICONS.bugs, '#dc2626','rgba(220,38,38,.1)', '', 'projects'); if (stats.my_todos !== undefined) statCards += mkStat('My Open Tasks', stats.my_todos, ICONS.todo, '#0891b2','rgba(8,145,178,.1)', '', 'projects'); if (stats.pending_leave !== undefined) statCards += mkStat('Pending Leave', stats.pending_leave, ICONS.leave, '#7c3aed','rgba(124,58,237,.1)', '', 'employees'); if (stats.low_stock !== undefined) statCards += mkStat('Low Stock Items', stats.low_stock, ICONS.alert, '#dc2626','rgba(220,38,38,.1)', 'needs reorder', 'stock'); if (stats.fleet_vehicles !== undefined) statCards += mkStat('Active Vehicles', stats.fleet_vehicles, ICONS.fleet, '#0891b2','rgba(8,145,178,.1)', '', 'fleet'); if (stats.unlinked_slips !== undefined) statCards += mkStat('Unlinked Slips', stats.unlinked_slips, ICONS.slips, '#d97706','rgba(217,119,6,.1)', 'needs review', 'slips'); if (stats.events_today !== undefined) statCards += mkStat('Events Today', stats.events_today, ICONS.calendar, '#16a34a','rgba(22,163,74,.1)', `${stats.events_this_week||0} this week`, 'calendar'); // Money stats (need reports) if (stats.jc_revenue !== undefined) statCards += mkMoneyStat('Revenue (This Month)', stats.jc_revenue, '#16a34a','rgba(22,163,74,.1)', ICONS.trending, 'invoiced jobs'); if (stats.jc_profit !== undefined) statCards += mkMoneyStat('Net Profit (This Month)', stats.jc_profit, stats.jc_profit >= 0 ? '#16a34a':'#dc2626', stats.jc_profit >= 0 ? 'rgba(22,163,74,.1)':'rgba(220,38,38,.1)', ICONS.money, 'after costs'); if (stats.slips_this_month !== undefined) statCards += mkMoneyStat('Slips (This Month)', stats.slips_this_month, '#d97706','rgba(217,119,6,.1)', ICONS.slips, 'total spend'); if (stats.fleet_costs_month!== undefined) statCards += mkMoneyStat('Fleet Costs (This Month)', stats.fleet_costs_month, '#0891b2','rgba(8,145,178,.1)', ICONS.fleet, ''); if (stats.cf_net !== undefined) statCards += mkMoneyStat('Cash Flow Net', stats.cf_net, stats.cf_net >= 0 ? '#16a34a':'#dc2626', stats.cf_net >= 0 ? 'rgba(22,163,74,.1)':'rgba(220,38,38,.1)', ICONS.money, 'income vs expenses'); if (stats.stock_value !== undefined) statCards += mkMoneyStat('Stock Value', stats.stock_value, '#1b4b8a','rgba(27,75,138,.1)', ICONS.stock, 'on hand'); // ── Today's Jobs (for view_own users) ─────────────────── let todayHtml = ''; if (isViewOwn && today_jobs?.length) { todayHtml = `

📋 My Jobs Today

${today_jobs.length} scheduled
${today_jobs.map((j,i) => `
${j.title}
${j.client_name||'Internal'} ${j.site_name ? '· '+j.site_name : ''}
${j.scheduled_time ? `
${j.scheduled_time.slice(0,5)}
` : ''} ${Fmt.statusBadge(j.status)}
`).join('')}
`; } // ── Upcoming Jobs table ────────────────────────────────── let jobsHtml = ''; if (upcoming_jobs?.length && (permissions.jobcards?.view || permissions.jobcards?.view_own)) { const isMobile = window.innerWidth < 768; jobsHtml = `

${isViewOwn ? 'My Upcoming Jobs' : 'Upcoming Job Cards'}

View All →
${isMobile ? `
${upcoming_jobs.map(j => `
${j.title}
${j.client_name||'—'} · ${j.scheduled_date ? Fmt.date(j.scheduled_date) : '—'}
${Fmt.statusBadge(j.status)}
`).join('')}
` : `
${!isViewOwn ? '' : ''} ${upcoming_jobs.map(j => ` ${!isViewOwn ? `` : ''} `).join('')}
Job #TitleClientScheduledAssignedPriorityStatus
${j.job_number} ${j.title} ${j.client_name||'—'} ${j.scheduled_date ? Fmt.date(j.scheduled_date)+(j.scheduled_time?' '+j.scheduled_time.slice(0,5):'') : '—'}${j.assigned_name||'—'}${j.priority||'normal'} ${Fmt.statusBadge(j.status)}
`}
`; } // ── Low Stock Alert ────────────────────────────────────── let lowStockHtml = ''; if (low_stock?.length) { lowStockHtml = `

⚠️ Low Stock

View Stock →
${low_stock.map(s => ` `).join('')}
ItemSKUOn HandMin
${s.name} ${s.sku||'—'} ${s.qty_on_hand} ${s.min_qty}
`; } // ── Cash Flow Mini Chart ───────────────────────────────── let cashflowHtml = ''; if (stats.cf_income !== undefined) { const income = stats.cf_income, expenses = stats.cf_expenses, net = stats.cf_net; const max = Math.max(income, expenses, 1); const fmt = v => 'R '+v.toLocaleString('en-ZA',{minimumFractionDigits:0,maximumFractionDigits:0}); cashflowHtml = `

💰 Cash Flow — ${new Date().toLocaleDateString('en-ZA',{month:'long',year:'numeric'})}

Details →
Income
${fmt(income)}
Expenses
${fmt(expenses)}
Income${fmt(income)}
Expenses${fmt(expenses)}
Net ${net<0?'-':''}${fmt(Math.abs(net))}
`; } // ── Recent Activity ────────────────────────────────────── let activityHtml = ''; if (recent_activity?.length) { activityHtml = `

Recent Activity

${recent_activity.map(a => `
${a.user_name} ${Fmt.capitalize((a.action||'').replace(/_/g,' '))} in ${a.project_name}
${Fmt.ago(a.created_at)}
`).join('')}
`; } // ── Empty state if nothing to show ────────────────────── if (!statCards) { statCards = `
Dashboard
No data yet
`; } // ── Layout ─────────────────────────────────────────────── const hasRight = activityHtml || cashflowHtml; const mainCol = `${todayHtml}${jobsHtml}${lowStockHtml}`; // Inject responsive style once if (!document.getElementById('db-style')) { const st = document.createElement('style'); st.id = 'db-style'; st.textContent = ` .db-grid { display:grid; grid-template-columns:1fr 290px; gap:1.25rem; align-items:start; width:100%; box-sizing:border-box; } .db-sidebar { display:flex; flex-direction:column; gap:1.25rem; min-width:0; } .db-main { min-width:0; display:flex; flex-direction:column; gap:1.25rem; } .db-table-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; } .db-table-scroll table { min-width:550px; width:100%; } @media (max-width:1100px) { .db-grid { grid-template-columns:1fr; } } `; document.head.appendChild(st); } content.innerHTML = ` ${greeting}
${statCards}
${hasRight ? `
${mainCol}
${cashflowHtml} ${activityHtml}
` : `
${mainCol}
`} `; } catch (e) { console.error(e); content.innerHTML = `
⚠️
Failed to load dashboard
${e.message}
`; } } function getGreeting() { const h = new Date().getHours(); if (h < 12) return 'morning'; if (h < 17) return 'afternoon'; return 'evening'; }