[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
This commit is contained in:
parent
a7e40ed83a
commit
e74f321791
10 changed files with 855 additions and 143 deletions
70
webapp/src/routes/invoices.js
Normal file
70
webapp/src/routes/invoices.js
Normal 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;
|
||||||
|
|
@ -4,151 +4,10 @@ const bcrypt = require('bcryptjs');
|
||||||
const supabase = require('../services/supabase');
|
const supabase = require('../services/supabase');
|
||||||
const { setAuditUser } = require('../services/audit');
|
const { setAuditUser } = require('../services/audit');
|
||||||
const { asyncHandler } = require('../middleware/security');
|
const { asyncHandler } = require('../middleware/security');
|
||||||
|
const { formatINR, getStatusColor } = require('../lib/india');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SHIPPER PORTAL AUTH
|
// SHARED AUTH MIDDLEWARE
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
function requirePortalAuth(req, res, next) {
|
function requirePortalAuth(req, res, next) {
|
||||||
if (!req.session.portalUser) {
|
if (!req.session.portalUser) {
|
||||||
|
|
@ -157,4 +16,159 @@ function requirePortalAuth(req, res, next) {
|
||||||
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,7 @@ app.use('/payments', require('./routes/payments'));
|
||||||
app.use('/reports', require('./routes/reports'));
|
app.use('/reports', require('./routes/reports'));
|
||||||
app.use('/audit-logs', require('./routes/audit'));
|
app.use('/audit-logs', require('./routes/audit'));
|
||||||
app.use('/portal', require('./routes/portal'));
|
app.use('/portal', require('./routes/portal'));
|
||||||
|
app.use('/invoices', require('./routes/invoices'));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
|
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
|
||||||
|
|
|
||||||
255
webapp/src/services/invoice-pdf.js
Normal file
255
webapp/src/services/invoice-pdf.js
Normal 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">फ्रेटडेस्न</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">→</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 };
|
||||||
91
webapp/src/views/pages/invoices/list.ejs
Normal file
91
webapp/src/views/pages/invoices/list.ejs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'invoices' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">📄 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"> </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 || '?' %> → <%= 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">⇩ 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">← 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 →</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('../partials/footer') %>
|
||||||
75
webapp/src/views/pages/invoices/preview.ejs
Normal file
75
webapp/src/views/pages/invoices/preview.ejs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'invoices' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">📄 Invoice Preview</h1>
|
||||||
|
<p class="page-subtitle"><%= load.shipper?.name || 'Unknown' %> — <%= load.date %></p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a href="/invoices/<%= load.id %>/pdf" class="btn btn-primary">⇩ Download PDF</a>
|
||||||
|
<a href="/invoices" class="btn btn-outline">← 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 || '?' %> → <%= 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">🖨 Print Invoice</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('../partials/footer') %>
|
||||||
81
webapp/src/views/pages/portal/driver-dashboard.ejs
Normal file
81
webapp/src/views/pages/portal/driver-dashboard.ejs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 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) { %> · <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 || '?' %> → <%= 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') %>
|
||||||
48
webapp/src/views/pages/portal/driver-load-detail.ejs
Normal file
48
webapp/src/views/pages/portal/driver-load-detail.ejs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 Trip Detail</h1>
|
||||||
|
<p class="page-subtitle"><%= load.id %></p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a href="/portal/my-loads" class="btn btn-outline">← 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 || '?' %> → <%= 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">💰 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') %>
|
||||||
76
webapp/src/views/pages/portal/driver-loads.ejs
Normal file
76
webapp/src/views/pages/portal/driver-loads.ejs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 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"> </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 || '?' %> → <%= 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">← 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 →</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('../partials/footer') %>
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<span class="sidebar-title">Reports</span>
|
<span class="sidebar-title">Reports</span>
|
||||||
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">📈 Reports</a>
|
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">📈 Reports</a>
|
||||||
|
<a href="/invoices" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'invoices' ? 'active' : '' %>">📄 Invoices</a>
|
||||||
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">📜 Audit Logs</a>
|
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">📜 Audit Logs</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue