[OWL] Driver portal + Invoice PDF generation
Some checks are pending
FreightDesk CI/CD / Lint & Test (push) Waiting to run
FreightDesk CI/CD / Build Docker Image (push) Blocked by required conditions
FreightDesk CI/CD / Deploy to Coolify (push) Blocked by required conditions

Driver Portal:
- Refactored portal routes with shared auth + role-aware dashboard
- Driver dashboard (trips, earnings, advance, active loads)
- Driver load list (filterable, paginated)
- Driver load detail (with settlement summary: freight, commission, advance, balance)
- Shared login page detects role from credentials

Invoice PDF:
- Invoice PDF service (puppeteer-based, falls back to HTML)
- Professional invoice template (tricolor, Hindi+English, GSTIN)
- Commission calculation with TDS deduction (10%)
- GET /invoices (list, filterable by year/month)
- GET /invoices/:id (HTML preview with print button)
- GET /invoices/:id/pdf (PDF download)
- Invoices link in sidebar
This commit is contained in:
FreightDesk 2026-06-08 00:40:16 +00:00
parent a7e40ed83a
commit e74f321791
10 changed files with 855 additions and 143 deletions

View file

@ -0,0 +1,70 @@
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../middleware/auth');
const supabase = require('../services/supabase');
const { generateInvoicePDF } = require('../services/invoice-pdf');
const { asyncHandler } = require('../middleware/security');
// GET /invoices — list all invoices (generated from loads)
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { month, year, page = 1 } = req.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = supabase
.from('loads')
.select('*, shipper:shippers(name)', { count: 'exact' })
.order('date', { ascending: false })
.range(offset, offset + limit - 1);
if (year) {
const startDate = `${year}-${(month || '01').padStart(2, '0')}-01`;
const endMonth = month ? String(parseInt(month) + 1).padStart(2, '0') : '12';
const endDate = `${year}-${endMonth}-31`;
query = query.gte('date', startDate).lte('date', endDate);
}
const { data: loads, count } = await query;
res.render('pages/invoices/list', {
loads: loads || [],
page: parseInt(page),
totalPages: Math.ceil((count || 0) / limit),
total: count,
filters: { month, year },
});
}));
// GET /invoices/:loadId — preview invoice (HTML)
router.get('/:loadId', requireAuth, asyncHandler(async (req, res) => {
const { data: load } = await supabase
.from('loads')
.select('*, shipper:shippers(*)')
.eq('id', req.params.loadId)
.single();
if (!load) return res.status(404).render('pages/404');
res.render('pages/invoices/preview', { load });
}));
// GET /invoices/:loadId/pdf — download invoice as PDF
router.get('/:loadId/pdf', requireAuth, asyncHandler(async (req, res) => {
try {
const { pdf, html, data, isPDF } = await generateInvoicePDF(req.params.loadId);
if (isPDF) {
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="invoice-${data.invoiceNumber}.pdf"`);
res.send(pdf);
} else {
// Fallback: return HTML for browser print
res.set('Content-Type', 'text/html');
res.send(html);
}
} catch (err) {
res.status(500).render('pages/500', { error: 'Failed to generate invoice: ' + err.message });
}
}));
module.exports = router;

View file

@ -4,151 +4,10 @@ const bcrypt = require('bcryptjs');
const supabase = require('../services/supabase');
const { setAuditUser } = require('../services/audit');
const { asyncHandler } = require('../middleware/security');
const { formatINR, getStatusColor } = require('../lib/india');
// ============================================================
// SHIPPER PORTAL AUTH
// ============================================================
// GET /portal/login — shipper login page
router.get('/login', (req, res) => {
if (req.session.portalUser) {
return res.redirect('/portal/dashboard');
}
res.render('pages/portal/login', { error: null, portal: 'shipper' });
});
// POST /portal/login — shipper authenticate
router.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('pages/portal/login', { error: 'Username and password are required', portal: 'shipper' });
}
// Look up shipper by username (phone number or email)
const { data: shipper, error } = await supabase
.from('portal_users')
.select('*')
.eq('username', username)
.eq('role', 'shipper')
.eq('is_active', true)
.single();
if (error || !shipper) {
return res.render('pages/portal/login', { error: 'Invalid credentials', portal: 'shipper' });
}
const valid = await bcrypt.compare(password, shipper.password_hash);
if (!valid) {
return res.render('pages/portal/login', { error: 'Invalid credentials', portal: 'shipper' });
}
// Set session
req.session.portalUser = {
id: shipper.id,
username: shipper.username,
role: 'shipper',
shipper_id: shipper.shipper_id,
};
// Set audit context
await setAuditUser(shipper.id);
res.redirect('/portal/dashboard');
}));
// GET /portal/logout
router.get('/logout', (req, res) => {
req.session.portalUser = null;
res.redirect('/portal/login');
});
// ============================================================
// SHIPPER PORTAL DASHBOARD
// ============================================================
router.get('/dashboard', requirePortalAuth, asyncHandler(async (req, res) => {
const shipperId = req.session.portalUser.shipper_id;
// Get shipper info
const { data: shipper } = await supabase
.from('shippers')
.select('*')
.eq('id', shipperId)
.single();
// Get loads for this shipper
const { data: loads } = await supabase
.from('loads')
.select('*, payments(*)')
.eq('shipper_id', shipperId)
.order('created_at', { ascending: false })
.limit(50);
// Calculate totals
const totalFreight = loads?.reduce((sum, l) => sum + (l.freight_charged || 0), 0) || 0;
const totalPaid = loads?.reduce((sum, l) => {
return sum + (l.payments?.reduce((p, pay) => p + (pay.amount || 0), 0) || 0);
}, 0) || 0;
const totalPending = totalFreight - totalPaid;
res.render('pages/portal/shipper-dashboard', {
shipper,
loads: loads || [],
totalFreight,
totalPaid,
totalPending,
totalLoads: loads?.length || 0,
});
}));
// GET /portal/loads — all loads for this shipper
router.get('/loads', requirePortalAuth, asyncHandler(async (req, res) => {
const shipperId = req.session.portalUser.shipper_id;
const { status, page = 1 } = req.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = supabase
.from('loads')
.select('*, payments(*)', { count: 'exact' })
.eq('shipper_id', shipperId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (status) query = query.eq('status', status);
const { data: loads, count, error } = await query;
res.render('pages/portal/shipper-loads', {
loads: loads || [],
page: parseInt(page),
totalPages: Math.ceil((count || 0) / limit),
total: count,
filters: { status },
});
}));
// GET /portal/loads/:id — single load detail
router.get('/loads/:id', requirePortalAuth, asyncHandler(async (req, res) => {
const shipperId = req.session.portalUser.shipper_id;
const { data: load, error } = await supabase
.from('loads')
.select('*, payments(*)')
.eq('id', req.params.id)
.eq('shipper_id', shipperId)
.single();
if (error) {
return res.status(404).render('pages/404');
}
res.render('pages/portal/shipper-load-detail', { load });
}));
// ============================================================
// AUTH MIDDLEWARE
// SHARED AUTH MIDDLEWARE
// ============================================================
function requirePortalAuth(req, res, next) {
if (!req.session.portalUser) {
@ -157,4 +16,159 @@ function requirePortalAuth(req, res, next) {
next();
}
function requirePortalRole(role) {
return (req, res, next) => {
if (!req.session.portalUser || req.session.portalUser.role !== role) {
return res.redirect('/portal/login');
}
next();
};
}
// ============================================================
// LOGIN (shared page, detects role from credentials)
// ============================================================
router.get('/login', (req, res) => {
if (req.session.portalUser) {
return res.redirect('/portal/dashboard');
}
res.render('pages/portal/login', { error: null });
});
router.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('pages/portal/login', { error: 'Username and password are required' });
}
const { data: user, error } = await supabase
.from('portal_users')
.select('*')
.eq('username', username)
.eq('is_active', true)
.in('role', ['shipper', 'driver'])
.single();
if (error || !user) {
return res.render('pages/portal/login', { error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.render('pages/portal/login', { error: 'Invalid credentials' });
}
req.session.portalUser = {
id: user.id,
username: user.username,
role: user.role,
shipper_id: user.shipper_id,
driver_id: user.driver_id,
};
await setAuditUser(user.id);
res.redirect('/portal/dashboard');
}));
router.get('/logout', (req, res) => {
req.session.portalUser = null;
res.redirect('/portal/login');
});
// ============================================================
// DASHBOARD (role-aware)
// ============================================================
router.get('/dashboard', requirePortalAuth, asyncHandler(async (req, res) => {
const { role } = req.session.portalUser;
if (role === 'shipper') {
const shipperId = req.session.portalUser.shipper_id;
const { data: shipper } = await supabase.from('shippers').select('*').eq('id', shipperId).single();
const { data: loads } = await supabase.from('loads').select('*, payments(*)').eq('shipper_id', shipperId).order('created_at', { ascending: false }).limit(50);
const totalFreight = loads?.reduce((sum, l) => sum + (l.freight_charged || 0), 0) || 0;
const totalPaid = loads?.reduce((sum, l) => sum + (l.payments?.reduce((p, pay) => p + (pay.amount || 0), 0) || 0), 0) || 0;
return res.render('pages/portal/shipper-dashboard', {
shipper, loads: loads || [], totalFreight, totalPaid,
totalPending: totalFreight - totalPaid, totalLoads: loads?.length || 0,
formatINR, getStatusColor,
});
}
if (role === 'driver') {
const driverId = req.session.portalUser.driver_id;
const { data: driver } = await supabase.from('drivers').select('*').eq('id', driverId).single();
const { data: loads } = await supabase.from('loads').select('*').eq('driver_id', driverId).order('created_at', { ascending: false }).limit(50);
const totalEarnings = loads?.reduce((sum, l) => sum + (l.paid_to_driver || 0), 0) || 0;
const totalAdvance = loads?.reduce((sum, l) => sum + (l.advance_to_driver || 0), 0) || 0;
const pendingLoads = loads?.filter(l => l.status === 'loaded / in transit' || l.status === 'delivered / pending collection').length || 0;
return res.render('pages/portal/driver-dashboard', {
driver, loads: loads || [], totalEarnings, totalAdvance,
pendingLoads, totalLoads: loads?.length || 0,
formatINR, getStatusColor,
});
}
res.redirect('/portal/login');
}));
// ============================================================
// SHIPPER ROUTES
// ============================================================
router.get('/loads', requirePortalAuth, requirePortalRole('shipper'), asyncHandler(async (req, res) => {
const shipperId = req.session.portalUser.shipper_id;
const { status, page = 1 } = req.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = supabase.from('loads').select('*, payments(*)', { count: 'exact' })
.eq('shipper_id', shipperId).order('created_at', { ascending: false }).range(offset, offset + limit - 1);
if (status) query = query.eq('status', status);
const { data: loads, count } = await query;
res.render('pages/portal/shipper-loads', {
loads: loads || [], page: parseInt(page), totalPages: Math.ceil((count || 0) / limit),
total: count, filters: { status }, formatINR, getStatusColor,
});
}));
router.get('/loads/:id', requirePortalAuth, requirePortalRole('shipper'), asyncHandler(async (req, res) => {
const { data: load } = await supabase.from('loads').select('*, payments(*)')
.eq('id', req.params.id).eq('shipper_id', req.session.portalUser.shipper_id).single();
if (!load) return res.status(404).render('pages/404');
res.render('pages/portal/shipper-load-detail', { load, formatINR, getStatusColor });
}));
// ============================================================
// DRIVER ROUTES
// ============================================================
router.get('/my-loads', requirePortalAuth, requirePortalRole('driver'), asyncHandler(async (req, res) => {
const driverId = req.session.portalUser.driver_id;
const { status, page = 1 } = req.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = supabase.from('loads').select('*', { count: 'exact' })
.eq('driver_id', driverId).order('created_at', { ascending: false }).range(offset, offset + limit - 1);
if (status) query = query.eq('status', status);
const { data: loads, count } = await query;
res.render('pages/portal/driver-loads', {
loads: loads || [], page: parseInt(page), totalPages: Math.ceil((count || 0) / limit),
total: count, filters: { status }, formatINR, getStatusColor,
});
}));
router.get('/my-loads/:id', requirePortalAuth, requirePortalRole('driver'), asyncHandler(async (req, res) => {
const { data: load } = await supabase.from('loads').select('*')
.eq('id', req.params.id).eq('driver_id', req.session.portalUser.driver_id).single();
if (!load) return res.status(404).render('pages/404');
res.render('pages/portal/driver-load-detail', { load, formatINR, getStatusColor });
}));
module.exports = router;

View file

@ -206,6 +206,7 @@ app.use('/payments', require('./routes/payments'));
app.use('/reports', require('./routes/reports'));
app.use('/audit-logs', require('./routes/audit'));
app.use('/portal', require('./routes/portal'));
app.use('/invoices', require('./routes/invoices'));
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));

View file

@ -0,0 +1,255 @@
const supabase = require('./supabase');
const logger = require('./logger');
const { formatINR } = require('../lib/india');
/**
* Invoice PDF Generation Service
*
* Generates commission invoices as PDF using HTML-to-PDF approach.
* Uses puppeteer for production-quality PDF output.
*
* Falls back to HTML rendering if puppeteer is not available (dev mode).
*/
// Check if puppeteer is available
let puppeteer;
try {
puppeteer = require('puppeteer');
} catch (e) {
logger.warn('Puppeteer not installed — PDF generation will return HTML. Run: npm i puppeteer');
}
/**
* Generate invoice data from a load record
*/
async function getInvoiceData(loadId) {
const { data: load, error } = await supabase
.from('loads')
.select(`
*,
shipper:shippers(*),
vehicle:vehicles(*),
payments(*)
`)
.eq('id', loadId)
.single();
if (error) throw error;
if (!load) throw new Error('Load not found');
// Calculate amounts
const freightCharged = load.freight_charged || 0;
const commissionRate = load.commission_rate || 5; // default 5%
const commissionAmount = load.commission || Math.round(freightCharged * commissionRate / 100);
const tds = Math.round(commissionAmount * 10 / 100); // 10% TDS
const netCommission = commissionAmount - tds;
// Get payments for this load
const payments = load.payments || [];
const shipperPaid = payments.filter(p => p.payment_type === 'credit').reduce((s, p) => s + p.amount, 0);
const shipperPending = freightCharged - shipperPaid;
return {
load,
invoiceNumber: `FD-${load.date?.replace(/-/g, '') || '000000'}-${load.id?.slice(0, 8) || '0000'}`,
invoiceDate: new Date().toLocaleDateString('en-IN'),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-IN'),
freightCharged,
commissionRate,
commissionAmount,
tds,
netCommission,
shipperPaid,
shipperPending,
payments,
};
}
/**
* Generate HTML for commission invoice
*/
function generateInvoiceHTML(data) {
const { load, invoiceNumber, invoiceDate, dueDate, freightCharged, commissionAmount, tds, netCommission, shipperPaid, shipperPending, commissionRate } = data;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Invoice ${invoiceNumber}</title>
<style>
@page { margin: 40px; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; font-size: 14px; line-height: 1.5; }
.tricolor { display: flex; height: 4px; margin-bottom: 20px; }
.tricolor span { flex: 1; }
.tricolor span:nth-child(1) { background: #FF9933; }
.tricolor span:nth-child(2) { background: #FFFFFF; border: 1px solid #ddd; }
.tricolor span:nth-child(3) { background: #138808; }
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
.company { max-width: 400px; }
.company h1 { font-size: 24px; color: #000080; }
.company .hi { font-size: 16px; color: #666; }
.company p { font-size: 12px; color: #777; margin-top: 4px; }
.invoice-meta { text-align: right; }
.invoice-meta h2 { font-size: 28px; color: #000080; margin-bottom: 8px; }
.invoice-meta p { font-size: 12px; color: #777; }
.addresses { display: flex; gap: 40px; margin-bottom: 30px; padding: 15px; background: #f8f9fa; border-radius: 6px; }
.address-block { flex: 1; }
.address-block h4 { font-size: 11px; text-transform: uppercase; color: #999; margin-bottom: 6px; }
.address-block strong { font-size: 14px; }
.address-block p { font-size: 12px; color: #666; }
.route-box { background: #eef2ff; padding: 15px; border-radius: 6px; margin-bottom: 20px; text-align: center; }
.route-box .arrow { font-size: 20px; color: #000080; margin: 0 10px; }
.route-box .city { font-size: 18px; font-weight: bold; color: #000080; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th { background: #000080; color: white; padding: 10px 12px; text-align: left; font-size: 12px; text-transform: uppercase; }
td { padding: 10px 12px; border-bottom: 1px solid #eee; }
.text-right { text-align: right; }
.total-row td { font-weight: bold; font-size: 15px; border-top: 2px solid #333; }
.net-commission td { background: #f0fff0; font-size: 16px; color: #138808; }
.summary { display: flex; gap: 30px; margin-bottom: 30px; }
.summary-card { flex: 1; padding: 15px; border-radius: 6px; text-align: center; }
.summary-card.green { background: #f0fff0; border: 1px solid #138808; }
.summary-card.blue { background: #eef2ff; border: 1px solid #000080; }
.summary-card.orange { background: #fff8f0; border: 1px solid #FF9933; }
.summary-card .label { font-size: 11px; color: #777; text-transform: uppercase; }
.summary-card .value { font-size: 20px; font-weight: bold; margin-top: 4px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; }
.footer p { font-size: 11px; color: #999; text-align: center; }
.footer .tricolor { margin-top: 10px; }
</style>
</head>
<body>
<div class="tricolor"><span></span><span></span><span></span></div>
<div class="header">
<div class="company">
<h1>FreightDesk</h1>
<p class="hi">&#2347;&#2381;&#2352;&#2375;&#2335;&#2337;&#2375;&#2360;&#2381;&#2344;</p>
<p>Freight Forwarding Commission Agent</p>
<p>Kerala, India | GSTIN: 32AABCF1234A1Z5</p>
</div>
<div class="invoice-meta">
<h2>COMMISSION INVOICE</h2>
<p><strong>Invoice #:</strong> ${invoiceNumber}</p>
<p><strong>Date:</strong> ${invoiceDate}</p>
<p><strong>Due:</strong> ${dueDate}</p>
</div>
</div>
<div class="addresses">
<div class="address-block">
<h4>Bill To (Shipper)</h4>
<strong>${load.shipper?.name || 'N/A'}</strong>
<p>${load.shipper?.phone || ''} ${load.shipper?.email ? '| ' + load.shipper.email : ''}</p>
<p>${load.shipper?.city || ''}, ${load.shipper?.state || ''}</p>
</div>
<div class="address-block">
<h4>Load Details</h4>
<strong>Load ID:</strong> ${load.id?.slice(0, 8)}<br>
<strong>Vehicle:</strong> ${load.vehicle?.number || load.vehicle_number || 'N/A'}<br>
<strong>Date:</strong> ${load.date || 'N/A'}
</div>
</div>
<div class="route-box">
<span class="city">${load.from_city || '?'}</span>
<span class="arrow">&#8594;</span>
<span class="city">${load.to_city || '?'}</span>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="text-right">Amount (INR)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total Freight Charged to Shipper</td>
<td class="text-right">${formatINR(freightCharged)}</td>
</tr>
<tr>
<td>Commission Earned (${commissionRate}%)</td>
<td class="text-right">${formatINR(commissionAmount)}</td>
</tr>
<tr>
<td>TDS Deduction (10%)</td>
<td class="text-right">(-) ${formatINR(tds)}</td>
</tr>
<tr class="total-row net-commission">
<td>Net Commission Payable</td>
<td class="text-right">${formatINR(netCommission)}</td>
</tr>
</tbody>
</table>
<div class="summary">
<div class="summary-card blue">
<div class="label">Shipper Total Freight</div>
<div class="value" style="color:#000080">${formatINR(freightCharged)}</div>
</div>
<div class="summary-card green">
<div class="label">Commission Earned</div>
<div class="value" style="color:#138808">${formatINR(netCommission)}</div>
</div>
<div class="summary-card orange">
<div class="label">Shipper Pending</div>
<div class="value" style="color:#FF9933">${formatINR(shipperPending)}</div>
</div>
</div>
<div class="footer">
<p>This is a computer-generated invoice. No signature required.</p>
<p>FreightDesk Freight Forwarding Commission Agent Platform | Govt. of India Initiative</p>
<div class="tricolor"><span></span><span></span><span></span></div>
</div>
</body>
</html>`;
}
/**
* Generate PDF buffer from invoice data
* Returns puppeteer PDF buffer, or HTML string if puppeteer not available
*/
async function generateInvoicePDF(loadId) {
const data = await getInvoiceData(loadId);
const html = generateInvoiceHTML(data);
if (!puppeteer) {
logger.warn('Puppeteer not available — returning HTML');
return { html, data, isPDF: false };
}
let browser;
try {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
return { pdf, data, isPDF: true };
} catch (e) {
logger.error({ err: e }, 'PDF generation failed — falling back to HTML');
return { html, data, isPDF: false };
} finally {
if (browser) await browser.close();
}
}
module.exports = { generateInvoicePDF, generateInvoiceHTML, getInvoiceData };

View file

@ -0,0 +1,91 @@
<%- include('../partials/header', { activeMenu: 'invoices' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128196; Invoices</h1>
<p class="page-subtitle">Generate and download commission invoices</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/invoices" class="filter-bar">
<div class="form-group">
<label class="form-label">Year</label>
<select name="year" class="form-input" onchange="this.form.submit()">
<option value="">All Years</option>
<% for (const y of [2026, 2025, 2024]) { %>
<option value="<%= y %>" <%= filters.year == y ? 'selected' : '' %>><%= y %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">Month</label>
<select name="month" class="form-input" onchange="this.form.submit()">
<option value="">All Months</option>
<% const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; %>
<% for (let i = 0; i < 12; i++) { %>
<option value="<%= String(i+1).padStart(2,'0') %>" <%= filters.month === String(i+1).padStart(2,'0') ? 'selected' : '' %>><%= months[i] %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<a href="/invoices" class="btn btn-outline">Clear</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<% if (loads.length === 0) { %>
<p class="empty-state">No loads found for invoicing.</p>
<% } else { %>
<p class="text-muted mb-3">Showing <%= loads.length %> of <%= total %> loads</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Shipper</th>
<th>Route</th>
<th>Freight</th>
<th>Commission</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% for (const load of loads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.shipper?.name || '—' %></td>
<td><%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><%= formatINR(load.commission) %></td>
<td>
<a href="/invoices/<%= load.id %>" class="btn btn-sm btn-outline">Preview</a>
<a href="/invoices/<%= load.id %>/pdf" class="btn btn-sm btn-primary">&#8681; PDF</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% if (totalPages > 1) { %>
<div class="pagination mt-3">
<% if (page > 1) { %>
<a href="/invoices?page=<%= page-1 %>&year=<%= filters.year || '' %>&month=<%= filters.month || '' %>" class="btn btn-sm btn-outline">&larr; Prev</a>
<% } %>
<span class="text-muted">Page <%= page %> of <%= totalPages %></span>
<% if (page < totalPages) { %>
<a href="/invoices?page=<%= page+1 %>&year=<%= filters.year || '' %>&month=<%= filters.month || '' %>" class="btn btn-sm btn-outline">Next &rarr;</a>
<% } %>
</div>
<% } %>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,75 @@
<%- include('../partials/header', { activeMenu: 'invoices' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128196; Invoice Preview</h1>
<p class="page-subtitle"><%= load.shipper?.name || 'Unknown' %> &mdash; <%= load.date %></p>
</div>
<div class="page-actions">
<a href="/invoices/<%= load.id %>/pdf" class="btn btn-primary">&#8681; Download PDF</a>
<a href="/invoices" class="btn btn-outline">&larr; Back</a>
</div>
</div>
<div class="card">
<div class="card-body">
<!-- Invoice Header -->
<div style="display:flex;justify-content:space-between;margin-bottom:24px;padding-bottom:16px;border-bottom:3px solid #000080;">
<div>
<h2 style="color:#000080;margin:0;">FreightDesk</h2>
<p style="color:#666;font-size:12px;margin:4px 0;">Freight Forwarding Commission Agent | Kerala, India</p>
</div>
<div style="text-align:right;">
<h3 style="color:#000080;margin:0;">COMMISSION INVOICE</h3>
<p style="font-size:12px;color:#777;margin:4px 0;"><strong>Date:</strong> <%= new Date().toLocaleDateString('en-IN') %></p>
</div>
</div>
<!-- Bill To + Load Info -->
<div style="display:flex;gap:24px;margin-bottom:24px;">
<div style="flex:1;padding:12px;background:#f8f9fa;border-radius:6px;">
<h4 style="font-size:11px;color:#999;text-transform:uppercase;margin-bottom:6px;">Bill To</h4>
<strong><%= load.shipper?.name || 'N/A' %></strong>
<p style="font-size:12px;color:#666;"><%= load.shipper?.phone || '' %> <%= load.shipper?.city ? '| ' + load.shipper.city : '' %></p>
</div>
<div style="flex:1;padding:12px;background:#f8f9fa;border-radius:6px;">
<h4 style="font-size:11px;color:#999;text-transform:uppercase;margin-bottom:6px;">Load Info</h4>
<strong>Route:</strong> <%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %><br>
<strong>Vehicle:</strong> <%= load.vehicle_number || 'N/A' %><br>
<strong>Date:</strong> <%= load.date || 'N/A' %>
</div>
</div>
<!-- Amount Summary -->
<table class="table">
<thead>
<tr><th>Description</th><th style="text-align:right;">Amount</th></tr>
</thead>
<tbody>
<tr><td>Total Freight Charged</td><td style="text-align:right;"><%= formatINR(load.freight_charged) %></td></tr>
<tr><td>Commission Earned (5%)</td><td style="text-align:right;"><%= formatINR(load.commission) %></td></tr>
<tr><td>TDS Deduction (10%)</td><td style="text-align:right;">(-) <%= formatINR(Math.round((load.commission || 0) * 0.1)) %></td></tr>
<tr style="font-weight:bold;font-size:16px;background:#f0fff0;color:#138808;">
<td>Net Commission Payable</td><td style="text-align:right;"><%= formatINR(Math.round((load.freight_charged || 0) * 0.05 * 0.9)) %></td>
</tr>
</tbody>
</table>
<!-- Tricolor Footer -->
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #ddd;text-align:center;">
<p style="font-size:11px;color:#999;">This is a computer-generated invoice. No signature required.</p>
<div style="display:flex;height:3px;margin-top:12px;">
<div style="flex:1;background:#FF9933;"></div>
<div style="flex:1;background:#fff;border:1px solid #ddd;"></div>
<div style="flex:1;background:#138808;"></div>
</div>
</div>
</div>
</div>
<!-- Print Button -->
<div style="text-align:center;margin-top:16px;">
<button onclick="window.print()" class="btn btn-outline">&#128424; Print Invoice</button>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,81 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Driver Portal</h1>
<p class="page-subtitle">Welcome, <%= driver.name %></p>
</div>
<div class="page-actions">
<a href="/portal/logout" class="btn btn-outline">Logout</a>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value"><%= totalLoads %></div>
<div class="stat-label">Total Trips</div>
</div>
<div class="stat-card stat-green">
<div class="stat-value"><%= formatINR(totalEarnings) %></div>
<div class="stat-label">Total Earned</div>
</div>
<div class="stat-card stat-blue">
<div class="stat-value"><%= formatINR(totalAdvance) %></div>
<div class="stat-label">Total Advance</div>
</div>
<div class="stat-card stat-orange">
<div class="stat-value"><%= pendingLoads %></div>
<div class="stat-label">Active / Pending</div>
</div>
</div>
<!-- Vehicle Info -->
<% if (driver.vehicle_number) { %>
<div class="card mb-3">
<div class="card-body">
<strong>Vehicle:</strong> <%= driver.vehicle_number %>
<% if (driver.phone) { %> &middot; <strong>Phone:</strong> <%= driver.phone %><% } %>
</div>
</div>
<% } %>
<!-- Recent Loads -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Recent Trips</h3>
<a href="/portal/my-loads" class="btn btn-sm btn-outline">View All</a>
</div>
<div class="card-body">
<% if (loads.length === 0) { %>
<p class="empty-state">No trips assigned yet.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Route</th>
<th>Freight</th>
<th>Driver Pay</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% for (const load of loads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><%= formatINR(load.paid_to_driver) %></td>
<td><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,48 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Trip Detail</h1>
<p class="page-subtitle"><%= load.id %></p>
</div>
<div class="page-actions">
<a href="/portal/my-loads" class="btn btn-outline">&larr; Back</a>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="detail-grid">
<div class="detail-item"><label>Date</label><span><%= load.date || '—' %></span></div>
<div class="detail-item"><label>Route</label><span><%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %></span></div>
<div class="detail-item"><label>Pickup</label><span><%= load.from_city || '—' %></span></div>
<div class="detail-item"><label>Delivery</label><span><%= load.to_city || '—' %></span></div>
<div class="detail-item"><label>Vehicle</label><span><%= load.vehicle_number || '—' %></span></div>
<div class="detail-item"><label>Freight Charged</label><strong><%= formatINR(load.freight_charged) %></strong></div>
<div class="detail-item"><label>Driver Pay</label><strong class="text-green"><%= formatINR(load.paid_to_driver) %></strong></div>
<div class="detail-item"><label>Advance Received</label><span><%= formatINR(load.advance_to_driver) || '—' %></span></div>
<div class="detail-item"><label>Status</label><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></div>
<% if (load.notes) { %>
<div class="detail-item"><label>Notes</label><span><%= load.notes %></span></div>
<% } %>
</div>
</div>
</div>
<!-- Settlement Summary -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">&#128176; Settlement Summary</h3>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item"><label>Total Freight</label><span><%= formatINR(load.freight_charged) %></span></div>
<div class="detail-item"><label>Commission</label><span><%= formatINR(load.commission) %></span></div>
<div class="detail-item"><label>Driver Pay</label><span><%= formatINR(load.paid_to_driver) %></span></div>
<div class="detail-item"><label>Advance</label><span><%= formatINR(load.advance_to_driver) || '₹0' %></span></div>
<div class="detail-item"><label>Balance Due</label><strong class="text-<%= (load.paid_to_driver - (load.advance_to_driver || 0)) > 0 ? 'green' : 'gray' %>"><%= formatINR((load.paid_to_driver || 0) - (load.advance_to_driver || 0)) %></strong></div>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,76 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; My Trips</h1>
<p class="page-subtitle">All your assigned loads</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/portal/my-loads" class="filter-bar">
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-input" onchange="this.form.submit()">
<option value="">All</option>
<option value="pending" <%= filters.status === 'pending' ? 'selected' : '' %>>Pending</option>
<option value="loaded / in transit" <%= filters.status === 'loaded / in transit' ? 'selected' : '' %>>In Transit</option>
<option value="delivered / pending collection" <%= filters.status === 'delivered / pending collection' ? 'selected' : '' %>>Delivered</option>
<option value="settled" <%= filters.status === 'settled' ? 'selected' : '' %>>Settled</option>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<a href="/portal/my-loads" class="btn btn-outline">Clear</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<% if (loads.length === 0) { %>
<p class="empty-state">No trips found.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Route</th>
<th>Freight</th>
<th>Driver Pay</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% for (const load of loads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><%= formatINR(load.paid_to_driver) %></td>
<td><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% if (totalPages > 1) { %>
<div class="pagination mt-3">
<% if (page > 1) { %>
<a href="/portal/my-loads?page=<%= page-1 %>&status=<%= filters.status || '' %>" class="btn btn-sm btn-outline">&larr; Prev</a>
<% } %>
<span class="text-muted">Page <%= page %> of <%= totalPages %></span>
<% if (page < totalPages) { %>
<a href="/portal/my-loads?page=<%= page+1 %>&status=<%= filters.status || '' %>" class="btn btn-sm btn-outline">Next &rarr;</a>
<% } %>
</div>
<% } %>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -43,6 +43,7 @@
<div class="sidebar-section">
<span class="sidebar-title">Reports</span>
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">&#128200; Reports</a>
<a href="/invoices" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'invoices' ? 'active' : '' %>">&#128196; Invoices</a>
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">&#128220; Audit Logs</a>
</div>
</aside>