Compare commits
2 commits
071f759b8a
...
63ed6c445f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ed6c445f | ||
|
|
795cc86b5a |
12 changed files with 762 additions and 0 deletions
109
supabase/migrations/004_audit_logging.sql
Normal file
109
supabase/migrations/004_audit_logging.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
-- ============================================================
|
||||
-- FreightDesk — Migration 004: Audit Logging
|
||||
-- Adapted from Hermes agent's soft-delete-audit branch
|
||||
-- Adds audit_logs table + triggers for core tables
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- AUDIT LOG TABLE
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
|
||||
table_name TEXT NOT NULL, -- e.g., 'loads', 'shippers'
|
||||
row_id UUID, -- UUID of the affected row
|
||||
before_json JSONB, -- state before change
|
||||
after_json JSONB, -- state after change
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id UUID, -- admin who made change (nullable)
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
-- Indexes for fast lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table ON audit_logs(table_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_time ON audit_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_row ON audit_logs(table_name, row_id);
|
||||
|
||||
-- ============================================================
|
||||
-- AUDIT TRIGGER FUNCTION
|
||||
-- Captures INSERT, UPDATE, DELETE on core tables
|
||||
-- Uses PostgreSQL session variable app.current_user_id for user tracking
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION fn_audit_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
BEGIN
|
||||
-- Get user_id from session variable (set by app before DB ops)
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.current_user_id', true)::UUID;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_user_id := NULL;
|
||||
END;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO audit_logs(action, table_name, row_id, after_json, user_id)
|
||||
VALUES ('INSERT', TG_TABLE_NAME, NEW.id, to_jsonb(NEW), v_user_id);
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Skip if only deleted_at changed (soft-delete is logged separately)
|
||||
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
|
||||
INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id, notes)
|
||||
VALUES ('SOFT_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id, 'Soft delete via trigger');
|
||||
ELSE
|
||||
INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id)
|
||||
VALUES ('UPDATE', TG_TABLE_NAME, NEW.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO audit_logs(action, table_name, row_id, before_json, user_id, notes)
|
||||
VALUES ('HARD_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), v_user_id, 'Hard delete (bypasses soft-delete)');
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- ATTACH TRIGGERS TO CORE TABLES
|
||||
-- ============================================================
|
||||
DROP TRIGGER IF EXISTS trg_audit_loads ON loads;
|
||||
CREATE TRIGGER trg_audit_loads
|
||||
AFTER INSERT OR UPDATE OR DELETE ON loads
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_shippers ON shippers;
|
||||
CREATE TRIGGER trg_audit_shippers
|
||||
AFTER INSERT OR UPDATE OR DELETE ON shippers
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_vehicles ON vehicles;
|
||||
CREATE TRIGGER trg_audit_vehicles
|
||||
AFTER INSERT OR UPDATE OR DELETE ON vehicles
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_payments ON payments;
|
||||
CREATE TRIGGER trg_audit_payments
|
||||
AFTER INSERT OR UPDATE OR DELETE ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_portal_users ON portal_users;
|
||||
CREATE TRIGGER trg_audit_portal_users
|
||||
AFTER INSERT OR UPDATE OR DELETE ON portal_users
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger();
|
||||
|
||||
-- ============================================================
|
||||
-- HELPER: Function to set current user for audit context
|
||||
-- Call this from Express middleware before DB operations:
|
||||
-- await supabase.rpc('set_audit_user', { user_id: req.session.user.id })
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION set_audit_user(user_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('app.current_user_id', user_id::TEXT, false);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
50
webapp/src/routes/audit.js
Normal file
50
webapp/src/routes/audit.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const supabase = require('../services/supabase');
|
||||
const { asyncHandler } = require('../middleware/security');
|
||||
|
||||
// GET /audit-logs — view audit trail (admin only)
|
||||
router.get('/', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => {
|
||||
const { table, user_id, action, page = 1, limit = 50 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = supabase
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (table) query = query.eq('table_name', table);
|
||||
if (user_id) query = query.eq('user_id', user_id);
|
||||
if (action) query = query.eq('action', action);
|
||||
|
||||
const { data: logs, count, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
|
||||
res.render('pages/audit/list', {
|
||||
logs: logs || [],
|
||||
page: parseInt(page),
|
||||
totalPages,
|
||||
total: count,
|
||||
filters: { table, user_id, action },
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /audit-logs/:id — single log detail
|
||||
router.get('/:id', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => {
|
||||
const { data: log, error } = await supabase
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('id', req.params.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.render('pages/audit/detail', { log });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
160
webapp/src/routes/portal.js
Normal file
160
webapp/src/routes/portal.js
Normal 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;
|
||||
|
|
@ -204,6 +204,8 @@ app.use('/shippers', require('./routes/shippers'));
|
|||
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() }));
|
||||
|
|
|
|||
12
webapp/src/services/audit.js
Normal file
12
webapp/src/services/audit.js
Normal 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 };
|
||||
68
webapp/src/views/pages/audit/detail.ejs
Normal file
68
webapp/src/views/pages/audit/detail.ejs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<%- include('../partials/header', { activeMenu: 'audit' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">📜 Audit Log Detail</h1>
|
||||
<p class="page-subtitle"><%= log.id %></p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a href="/audit-logs" class="btn btn-outline">← Back to Logs</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Action</label>
|
||||
<span class="badge badge-<%= log.action === 'INSERT' ? 'green' : log.action === 'UPDATE' ? 'blue' : log.action === 'SOFT_DELETE' ? 'orange' : 'red' %>"><%= log.action %></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Table</label>
|
||||
<code><%= log.table_name %></code>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Row ID</label>
|
||||
<code><%= log.row_id || '—' %></code>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Timestamp</label>
|
||||
<span><%= new Date(log.created_at).toLocaleString('en-IN') %></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>User ID</label>
|
||||
<code><%= log.user_id || 'System' %></code>
|
||||
</div>
|
||||
<% if (log.notes) { %>
|
||||
<div class="detail-item">
|
||||
<label>Notes</label>
|
||||
<span><%= log.notes %></span>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (log.before_json) { %>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">🗂 Before (Old Values)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block"><%= JSON.stringify(log.before_json, null, 2) %></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (log.after_json) { %>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">🗃 After (New Values)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block"><%= JSON.stringify(log.after_json, null, 2) %></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
93
webapp/src/views/pages/audit/list.ejs
Normal file
93
webapp/src/views/pages/audit/list.ejs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<%- include('../partials/header', { activeMenu: 'audit' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">📜 Audit Logs</h1>
|
||||
<p class="page-subtitle">Track all changes across the platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/audit-logs" class="filter-bar">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Table</label>
|
||||
<select name="table" class="form-input" onchange="this.form.submit()">
|
||||
<option value="">All Tables</option>
|
||||
<option value="loads" <%= filters.table === 'loads' ? 'selected' : '' %>>Loads</option>
|
||||
<option value="shippers" <%= filters.table === 'shippers' ? 'selected' : '' %>>Shippers</option>
|
||||
<option value="vehicles" <%= filters.table === 'vehicles' ? 'selected' : '' %>>Vehicles</option>
|
||||
<option value="payments" <%= filters.table === 'payments' ? 'selected' : '' %>>Payments</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Action</label>
|
||||
<select name="action" class="form-input" onchange="this.form.submit()">
|
||||
<option value="">All Actions</option>
|
||||
<option value="INSERT" <%= filters.action === 'INSERT' ? 'selected' : '' %>>Insert</option>
|
||||
<option value="UPDATE" <%= filters.action === 'UPDATE' ? 'selected' : '' %>>Update</option>
|
||||
<option value="SOFT_DELETE" <%= filters.action === 'SOFT_DELETE' ? 'selected' : '' %>>Soft Delete</option>
|
||||
<option value="HARD_DELETE" <%= filters.action === 'HARD_DELETE' ? 'selected' : '' %>>Hard Delete</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label"> </label>
|
||||
<a href="/audit-logs" class="btn btn-outline">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<% if (!logs || logs.length === 0) { %>
|
||||
<p class="empty-state">No audit logs found matching your filters.</p>
|
||||
<% } else { %>
|
||||
<p class="text-muted mb-3">Showing <%= logs.length %> of <%= total %> logs (page <%= page %> of <%= totalPages %>)</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Action</th>
|
||||
<th>Table</th>
|
||||
<th>Row ID</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const log of logs) { %>
|
||||
<tr>
|
||||
<td><%= new Date(log.created_at).toLocaleString('en-IN', { dateStyle: 'short', timeStyle: 'short' }) %></td>
|
||||
<td>
|
||||
<span class="badge badge-<%= log.action === 'INSERT' ? 'green' : log.action === 'UPDATE' ? 'blue' : log.action === 'SOFT_DELETE' ? 'orange' : 'red' %>">
|
||||
<%= log.action %>
|
||||
</span>
|
||||
</td>
|
||||
<td><code><%= log.table_name %></code></td>
|
||||
<td><code><%= log.row_id ? log.row_id.slice(0,8) + '...' : '—' %></code></td>
|
||||
<td><a href="/audit-logs/<%= log.id %>" class="btn btn-sm btn-outline">View</a></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<% if (totalPages > 1) { %>
|
||||
<div class="pagination mt-3">
|
||||
<% if (page > 1) { %>
|
||||
<a href="/audit-logs?page=<%= page-1 %>&table=<%= filters.table || '' %>&action=<%= filters.action || '' %>" class="btn btn-sm btn-outline">← Prev</a>
|
||||
<% } %>
|
||||
<span class="text-muted mx-2">Page <%= page %> of <%= totalPages %></span>
|
||||
<% if (page < totalPages) { %>
|
||||
<a href="/audit-logs?page=<%= page+1 %>&table=<%= filters.table || '' %>&action=<%= filters.action || '' %>" class="btn btn-sm btn-outline">Next →</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
44
webapp/src/views/pages/portal/login.ejs
Normal file
44
webapp/src/views/pages/portal/login.ejs
Normal 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">🌐</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 · FreightDesk</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
webapp/src/views/pages/portal/shipper-dashboard.ejs
Normal file
73
webapp/src/views/pages/portal/shipper-dashboard.ejs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">🏢 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 || '?' %> → <%= 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') %>
|
||||
75
webapp/src/views/pages/portal/shipper-load-detail.ejs
Normal file
75
webapp/src/views/pages/portal/shipper-load-detail.ejs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">🚚 Load Detail</h1>
|
||||
<p class="page-subtitle"><%= load.id %></p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a href="/portal/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>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">💰 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') %>
|
||||
75
webapp/src/views/pages/portal/shipper-loads.ejs
Normal file
75
webapp/src/views/pages/portal/shipper-loads.ejs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<%- include('../partials/header', { activeMenu: 'portal' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">🚚 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"> </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 || '?' %> → <%= 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">← 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 →</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
<div class="sidebar-section">
|
||||
<span class="sidebar-title">Reports</span>
|
||||
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">📈 Reports</a>
|
||||
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">📜 Audit Logs</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue