From e74f3217912e716f21ac46975e3b05d371c68aa5 Mon Sep 17 00:00:00 2001 From: FreightDesk Date: Mon, 8 Jun 2026 00:40:16 +0000 Subject: [PATCH] [OWL] Driver portal + Invoice PDF generation 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 --- webapp/src/routes/invoices.js | 70 ++++ webapp/src/routes/portal.js | 300 +++++++++--------- webapp/src/server.js | 1 + webapp/src/services/invoice-pdf.js | 255 +++++++++++++++ webapp/src/views/pages/invoices/list.ejs | 91 ++++++ webapp/src/views/pages/invoices/preview.ejs | 75 +++++ .../views/pages/portal/driver-dashboard.ejs | 81 +++++ .../views/pages/portal/driver-load-detail.ejs | 48 +++ .../src/views/pages/portal/driver-loads.ejs | 76 +++++ webapp/src/views/partials/header.ejs | 1 + 10 files changed, 855 insertions(+), 143 deletions(-) create mode 100644 webapp/src/routes/invoices.js create mode 100644 webapp/src/services/invoice-pdf.js create mode 100644 webapp/src/views/pages/invoices/list.ejs create mode 100644 webapp/src/views/pages/invoices/preview.ejs create mode 100644 webapp/src/views/pages/portal/driver-dashboard.ejs create mode 100644 webapp/src/views/pages/portal/driver-load-detail.ejs create mode 100644 webapp/src/views/pages/portal/driver-loads.ejs diff --git a/webapp/src/routes/invoices.js b/webapp/src/routes/invoices.js new file mode 100644 index 0000000..47e7005 --- /dev/null +++ b/webapp/src/routes/invoices.js @@ -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; diff --git a/webapp/src/routes/portal.js b/webapp/src/routes/portal.js index bbbd3d1..7f7b54f 100644 --- a/webapp/src/routes/portal.js +++ b/webapp/src/routes/portal.js @@ -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; diff --git a/webapp/src/server.js b/webapp/src/server.js index 31e48ee..743edbe 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -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() })); diff --git a/webapp/src/services/invoice-pdf.js b/webapp/src/services/invoice-pdf.js new file mode 100644 index 0000000..0fad868 --- /dev/null +++ b/webapp/src/services/invoice-pdf.js @@ -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 ` + + + + Invoice ${invoiceNumber} + + + +
+ +
+
+

FreightDesk

+

फ्रेटडेस्न

+

Freight Forwarding Commission Agent

+

Kerala, India | GSTIN: 32AABCF1234A1Z5

+
+
+

COMMISSION INVOICE

+

Invoice #: ${invoiceNumber}

+

Date: ${invoiceDate}

+

Due: ${dueDate}

+
+
+ +
+
+

Bill To (Shipper)

+ ${load.shipper?.name || 'N/A'} +

${load.shipper?.phone || ''} ${load.shipper?.email ? '| ' + load.shipper.email : ''}

+

${load.shipper?.city || ''}, ${load.shipper?.state || ''}

+
+
+

Load Details

+ Load ID: ${load.id?.slice(0, 8)}
+ Vehicle: ${load.vehicle?.number || load.vehicle_number || 'N/A'}
+ Date: ${load.date || 'N/A'} +
+
+ +
+ ${load.from_city || '?'} + + ${load.to_city || '?'} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
DescriptionAmount (INR)
Total Freight Charged to Shipper${formatINR(freightCharged)}
Commission Earned (${commissionRate}%)${formatINR(commissionAmount)}
TDS Deduction (10%)(-) ${formatINR(tds)}
Net Commission Payable${formatINR(netCommission)}
+ +
+
+
Shipper Total Freight
+
${formatINR(freightCharged)}
+
+
+
Commission Earned
+
${formatINR(netCommission)}
+
+
+
Shipper Pending
+
${formatINR(shipperPending)}
+
+
+ + + +`; +} + +/** + * 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 }; diff --git a/webapp/src/views/pages/invoices/list.ejs b/webapp/src/views/pages/invoices/list.ejs new file mode 100644 index 0000000..4a4ad75 --- /dev/null +++ b/webapp/src/views/pages/invoices/list.ejs @@ -0,0 +1,91 @@ +<%- include('../partials/header', { activeMenu: 'invoices' }) %> + + + + +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +
+
+ <% if (loads.length === 0) { %> +

No loads found for invoicing.

+ <% } else { %> +

Showing <%= loads.length %> of <%= total %> loads

+
+ + + + + + + + + + + + + <% for (const load of loads) { %> + + + + + + + + + <% } %> + +
DateShipperRouteFreightCommissionActions
<%= load.date || '—' %><%= load.shipper?.name || '—' %><%= load.from_city || '?' %> → <%= load.to_city || '?' %><%= formatINR(load.freight_charged) %><%= formatINR(load.commission) %> + Preview + ⇩ PDF +
+
+ <% if (totalPages > 1) { %> + + <% } %> + <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/invoices/preview.ejs b/webapp/src/views/pages/invoices/preview.ejs new file mode 100644 index 0000000..118f060 --- /dev/null +++ b/webapp/src/views/pages/invoices/preview.ejs @@ -0,0 +1,75 @@ +<%- include('../partials/header', { activeMenu: 'invoices' }) %> + + + +
+
+ +
+
+

FreightDesk

+

Freight Forwarding Commission Agent | Kerala, India

+
+
+

COMMISSION INVOICE

+

Date: <%= new Date().toLocaleDateString('en-IN') %>

+
+
+ + +
+
+

Bill To

+ <%= load.shipper?.name || 'N/A' %> +

<%= load.shipper?.phone || '' %> <%= load.shipper?.city ? '| ' + load.shipper.city : '' %>

+
+
+

Load Info

+ Route: <%= load.from_city || '?' %> → <%= load.to_city || '?' %>
+ Vehicle: <%= load.vehicle_number || 'N/A' %>
+ Date: <%= load.date || 'N/A' %> +
+
+ + + + + + + + + + + + + + +
DescriptionAmount
Total Freight Charged<%= formatINR(load.freight_charged) %>
Commission Earned (5%)<%= formatINR(load.commission) %>
TDS Deduction (10%)(-) <%= formatINR(Math.round((load.commission || 0) * 0.1)) %>
Net Commission Payable<%= formatINR(Math.round((load.freight_charged || 0) * 0.05 * 0.9)) %>
+ + +
+

This is a computer-generated invoice. No signature required.

+
+
+
+
+
+
+
+
+ + +
+ +
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/portal/driver-dashboard.ejs b/webapp/src/views/pages/portal/driver-dashboard.ejs new file mode 100644 index 0000000..f48ee5f --- /dev/null +++ b/webapp/src/views/pages/portal/driver-dashboard.ejs @@ -0,0 +1,81 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + + +
+
+
<%= totalLoads %>
+
Total Trips
+
+
+
<%= formatINR(totalEarnings) %>
+
Total Earned
+
+
+
<%= formatINR(totalAdvance) %>
+
Total Advance
+
+
+
<%= pendingLoads %>
+
Active / Pending
+
+
+ + +<% if (driver.vehicle_number) { %> +
+
+ Vehicle: <%= driver.vehicle_number %> + <% if (driver.phone) { %> · Phone: <%= driver.phone %><% } %> +
+
+<% } %> + + +
+
+

Recent Trips

+ View All +
+
+ <% if (loads.length === 0) { %> +

No trips assigned yet.

+ <% } else { %> +
+ + + + + + + + + + + + <% for (const load of loads) { %> + + + + + + + + <% } %> + +
DateRouteFreightDriver PayStatus
<%= load.date || '—' %><%= load.from_city || '?' %> → <%= load.to_city || '?' %><%= formatINR(load.freight_charged) %><%= formatINR(load.paid_to_driver) %><%= load.status %>
+
+ <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/portal/driver-load-detail.ejs b/webapp/src/views/pages/portal/driver-load-detail.ejs new file mode 100644 index 0000000..d39112f --- /dev/null +++ b/webapp/src/views/pages/portal/driver-load-detail.ejs @@ -0,0 +1,48 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + +
+
+
+
<%= load.date || '—' %>
+
<%= load.from_city || '?' %> → <%= load.to_city || '?' %>
+
<%= load.from_city || '—' %>
+
<%= load.to_city || '—' %>
+
<%= load.vehicle_number || '—' %>
+
<%= formatINR(load.freight_charged) %>
+
<%= formatINR(load.paid_to_driver) %>
+
<%= formatINR(load.advance_to_driver) || '—' %>
+
<%= load.status %>
+ <% if (load.notes) { %> +
<%= load.notes %>
+ <% } %> +
+
+
+ + +
+
+

💰 Settlement Summary

+
+
+
+
<%= formatINR(load.freight_charged) %>
+
<%= formatINR(load.commission) %>
+
<%= formatINR(load.paid_to_driver) %>
+
<%= formatINR(load.advance_to_driver) || '₹0' %>
+
<%= formatINR((load.paid_to_driver || 0) - (load.advance_to_driver || 0)) %>
+
+
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/portal/driver-loads.ejs b/webapp/src/views/pages/portal/driver-loads.ejs new file mode 100644 index 0000000..7e25232 --- /dev/null +++ b/webapp/src/views/pages/portal/driver-loads.ejs @@ -0,0 +1,76 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + + +
+
+
+
+ + +
+
+ + Clear +
+
+
+
+ +
+
+ <% if (loads.length === 0) { %> +

No trips found.

+ <% } else { %> +
+ + + + + + + + + + + + <% for (const load of loads) { %> + + + + + + + + <% } %> + +
DateRouteFreightDriver PayStatus
<%= load.date || '—' %><%= load.from_city || '?' %> → <%= load.to_city || '?' %><%= formatINR(load.freight_charged) %><%= formatINR(load.paid_to_driver) %><%= load.status %>
+
+ <% if (totalPages > 1) { %> + + <% } %> + <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/partials/header.ejs b/webapp/src/views/partials/header.ejs index 2859b95..d2b92dd 100644 --- a/webapp/src/views/partials/header.ejs +++ b/webapp/src/views/partials/header.ejs @@ -43,6 +43,7 @@