[OWL] Client portal: shipper login + dashboard + load views
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

- Shipper portal auth (login/logout with bcrypt sessions)
- Shipper dashboard (stats: total loads, freight, paid, pending)
- Shipper load list (filterable by status, paginated)
- Shipper load detail (with payment history)
- Audit service helper (setAuditUser for session context)
- Wire /portal route into server.js
This commit is contained in:
FreightDesk 2026-06-07 20:05:52 +00:00
parent 795cc86b5a
commit 63ed6c445f
7 changed files with 440 additions and 0 deletions

160
webapp/src/routes/portal.js Normal file
View file

@ -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;

View file

@ -205,6 +205,7 @@ app.use('/vehicles', require('./routes/vehicles'));
app.use('/payments', require('./routes/payments')); 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'));
// 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() }));

View file

@ -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 };

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shipper Portal — <%= appName %></title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="auth-page">
<div class="login-page">
<div class="login-container">
<div class="login-header">
<div class="login-emblem">&#127760;</div>
<h1 class="login-title-hi"><%= appNameHi %></h1>
<h2 class="login-title-en">Shipper Portal</h2>
<p class="login-tagline">Track your shipments and payments</p>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form method="POST" action="/portal/login" class="login-form">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-input" required autofocus placeholder="Phone number or email">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" required placeholder="Your password">
</div>
<button type="submit" class="btn btn-primary btn-block">Login to Portal</button>
</form>
<div class="login-footer">
<div class="footer-tricolor"><span></span><span></span><span></span></div>
<p>Govt. of India Initiative &middot; FreightDesk</p>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,73 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#127970; Shipper Portal</h1>
<p class="page-subtitle">Welcome, <%= shipper.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 Loads</div>
</div>
<div class="stat-card stat-blue">
<div class="stat-value"><%= formatINR(totalFreight) %></div>
<div class="stat-label">Total Freight</div>
</div>
<div class="stat-card stat-green">
<div class="stat-value"><%= formatINR(totalPaid) %></div>
<div class="stat-label">Paid</div>
</div>
<div class="stat-card stat-orange">
<div class="stat-value"><%= formatINR(totalPending) %></div>
<div class="stat-label">Pending</div>
</div>
</div>
<!-- Recent Loads -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Recent Loads</h3>
<a href="/portal/loads" class="btn btn-sm btn-outline">View All</a>
</div>
<div class="card-body">
<% if (loads.length === 0) { %>
<p class="empty-state">No loads found.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Route</th>
<th>Vehicle</th>
<th>Freight</th>
<th>Status</th>
<th>Paid</th>
</tr>
</thead>
<tbody>
<% for (const load of loads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.from_city || '?' %> &rarr; <%= load.to_city || '?' %></td>
<td><%= load.vehicle_number || '—' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></td>
<td><%= formatINR(load.payments?.reduce((s, p) => s + (p.amount || 0), 0)) %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,75 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Load Detail</h1>
<p class="page-subtitle"><%= load.id %></p>
</div>
<div class="page-actions">
<a href="/portal/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>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>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>
<!-- Payment History -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">&#128176; Payment History</h3>
</div>
<div class="card-body">
<% if (!load.payments || load.payments.length === 0) { %>
<p class="empty-state">No payments recorded yet.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Amount</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
<% for (const pay of load.payments) { %>
<tr>
<td><%= pay.date || '—' %></td>
<td><span class="badge badge-<%= pay.payment_type === 'credit' ? 'green' : 'blue' %>"><%= pay.payment_type %></span></td>
<td><%= formatINR(pay.amount) %></td>
<td><%= pay.reference || '—' %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,75 @@
<%- include('../partials/header', { activeMenu: 'portal' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; My Loads</h1>
<p class="page-subtitle">All your freight loads</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/portal/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>
<% for (const s of ['pending', 'loaded / in transit', 'delivered / pending collection', 'cancelled']) { %>
<option value="<%= s %>" <%= filters.status === s ? 'selected' : '' %>><%= s %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<a href="/portal/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 loads found.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Route</th>
<th>Vehicle</th>
<th>Freight</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><%= load.vehicle_number || '—' %></td>
<td><%= formatINR(load.freight_charged) %></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/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/loads?page=<%= page+1 %>&status=<%= filters.status || '' %>" class="btn btn-sm btn-outline">Next &rarr;</a>
<% } %>
</div>
<% } %>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>