diff --git a/webapp/src/routes/portal.js b/webapp/src/routes/portal.js new file mode 100644 index 0000000..bbbd3d1 --- /dev/null +++ b/webapp/src/routes/portal.js @@ -0,0 +1,160 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const supabase = require('../services/supabase'); +const { setAuditUser } = require('../services/audit'); +const { asyncHandler } = require('../middleware/security'); + +// ============================================================ +// 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 +// ============================================================ +function requirePortalAuth(req, res, next) { + if (!req.session.portalUser) { + return res.redirect('/portal/login'); + } + next(); +} + +module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index bc80c9e..31e48ee 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -205,6 +205,7 @@ app.use('/vehicles', require('./routes/vehicles')); 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')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); diff --git a/webapp/src/services/audit.js b/webapp/src/services/audit.js new file mode 100644 index 0000000..fc3528d --- /dev/null +++ b/webapp/src/services/audit.js @@ -0,0 +1,12 @@ +const supabase = require('./supabase'); + +async function setAuditUser(userId) { + if (!userId) return; + try { + await supabase.rpc('set_audit_user', { user_id: userId }); + } catch (e) { + // Audit function may not exist yet (migration not run) — silently ignore + } +} + +module.exports = { setAuditUser }; diff --git a/webapp/src/views/pages/portal/login.ejs b/webapp/src/views/pages/portal/login.ejs new file mode 100644 index 0000000..9bf3193 --- /dev/null +++ b/webapp/src/views/pages/portal/login.ejs @@ -0,0 +1,44 @@ + + + + + + Shipper Portal — <%= appName %> + + + + +
+
+ + + <% if (error) { %> +
<%= error %>
+ <% } %> + + + + +
+
+ + + diff --git a/webapp/src/views/pages/portal/shipper-dashboard.ejs b/webapp/src/views/pages/portal/shipper-dashboard.ejs new file mode 100644 index 0000000..4e529b7 --- /dev/null +++ b/webapp/src/views/pages/portal/shipper-dashboard.ejs @@ -0,0 +1,73 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + + +
+
+
<%= totalLoads %>
+
Total Loads
+
+
+
<%= formatINR(totalFreight) %>
+
Total Freight
+
+
+
<%= formatINR(totalPaid) %>
+
Paid
+
+
+
<%= formatINR(totalPending) %>
+
Pending
+
+
+ + +
+
+

Recent Loads

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

No loads found.

+ <% } else { %> +
+ + + + + + + + + + + + + <% for (const load of loads) { %> + + + + + + + + + <% } %> + +
DateRouteVehicleFreightStatusPaid
<%= load.date || '—' %><%= load.from_city || '?' %> → <%= load.to_city || '?' %><%= load.vehicle_number || '—' %><%= formatINR(load.freight_charged) %><%= load.status %><%= formatINR(load.payments?.reduce((s, p) => s + (p.amount || 0), 0)) %>
+
+ <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/portal/shipper-load-detail.ejs b/webapp/src/views/pages/portal/shipper-load-detail.ejs new file mode 100644 index 0000000..4a625fb --- /dev/null +++ b/webapp/src/views/pages/portal/shipper-load-detail.ejs @@ -0,0 +1,75 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + +
+
+
+
+ <%= load.date || '—' %> +
+
+ <%= load.from_city || '?' %> → <%= load.to_city || '?' %> +
+
+ <%= load.vehicle_number || '—' %> +
+
+ <%= formatINR(load.freight_charged) %> +
+
+ <%= load.status %> +
+ <% if (load.notes) { %> +
+ <%= load.notes %> +
+ <% } %> +
+
+
+ + +
+
+

💰 Payment History

+
+
+ <% if (!load.payments || load.payments.length === 0) { %> +

No payments recorded yet.

+ <% } else { %> +
+ + + + + + + + + + + <% for (const pay of load.payments) { %> + + + + + + + <% } %> + +
DateTypeAmountReference
<%= pay.date || '—' %><%= pay.payment_type %><%= formatINR(pay.amount) %><%= pay.reference || '—' %>
+
+ <% } %> +
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/portal/shipper-loads.ejs b/webapp/src/views/pages/portal/shipper-loads.ejs new file mode 100644 index 0000000..93ab196 --- /dev/null +++ b/webapp/src/views/pages/portal/shipper-loads.ejs @@ -0,0 +1,75 @@ +<%- include('../partials/header', { activeMenu: 'portal' }) %> + + + + +
+
+
+
+ + +
+
+ + Clear +
+
+
+
+ +
+
+ <% if (loads.length === 0) { %> +

No loads found.

+ <% } else { %> +
+ + + + + + + + + + + + <% for (const load of loads) { %> + + + + + + + + <% } %> + +
DateRouteVehicleFreightStatus
<%= load.date || '—' %><%= load.from_city || '?' %> → <%= load.to_city || '?' %><%= load.vehicle_number || '—' %><%= formatINR(load.freight_charged) %><%= load.status %>
+
+ <% if (totalPages > 1) { %> + + <% } %> + <% } %> +
+
+ +<%- include('../partials/footer') %>