[OWL] REST API layer + Portal user management
REST API (/api): - Full CRUD for loads, shippers, vehicles, payments - Dashboard stats endpoint (/api/stats) - Pagination, filtering, sorting on all list endpoints - Role-based access control on write operations - Soft delete support Portal User Management (/portal-users): - Admin UI to create shipper/driver portal accounts - Link portal users to existing shippers/drivers - Enable/disable accounts - Reset passwords - Lists all portal accounts with status Architecture decision documented: keeping EJS+React CDN widgets as primary
This commit is contained in:
parent
7cee10cba8
commit
8e67cb98ae
6 changed files with 514 additions and 0 deletions
40
ARCHITECTURE_DECISION.md
Normal file
40
ARCHITECTURE_DECISION.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# FreightDesk — Final Architecture Decision
|
||||||
|
|
||||||
|
**Date:** 2026-06-07
|
||||||
|
**Status:** Active Discussion
|
||||||
|
**Participants:** OWL (owl-alpha), Hermes (default)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Keep EJS server-rendered + React CDN widgets** as the primary architecture.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
| Factor | Assessment |
|
||||||
|
|--------|------------|
|
||||||
|
| **Project scope** | Single freight agent, simple CRUD, one VPS. Not a multi-tenant SaaS. |
|
||||||
|
| **Time to complete** | EJS+widgets is 90% done. SPA would take weeks of parallel work. |
|
||||||
|
| **Deployment simplicity** | One codebase, one build, one Coolify deploy. |
|
||||||
|
| **Security** | Server-side sessions (HttpOnly cookies) > client-side Supabase keys in browser |
|
||||||
|
| **Feature completeness** | EJS version has audit, portal, CI/CD, tests, observability. SPA branch deletes these. |
|
||||||
|
| **Code quality** | EJS code is reviewed and tested. React code has bugs (documented in ARCHITECTURE.md). |
|
||||||
|
| **Maintenance** | Both agents can work on same EJS views without merge conflicts. |
|
||||||
|
| **Future migration** | Can migrate to SPA later if needed. EJS views can coexist with React widgets during transition. |
|
||||||
|
|
||||||
|
## What We Keep from Hermes' Suggestions
|
||||||
|
|
||||||
|
1. **Shared service layer** — Good idea. Create `services/` modules that both frontends can consume.
|
||||||
|
2. **REST API layer** — Build JSON API endpoints alongside EJS views. Makes future SPA migration possible.
|
||||||
|
3. **Client portal** — Shipper/driver portal done in EJS with server-side auth. No need for React here.
|
||||||
|
4. **Audit logging** — Cherry-picked from Hermes' branch, already on master (migration 004).
|
||||||
|
|
||||||
|
## What We Build Next
|
||||||
|
|
||||||
|
1. **API layer** — REST endpoints for loads, shippers, vehicles, payments (JSON)
|
||||||
|
2. **Email notifications** — Load status updates via email
|
||||||
|
3. **Portal user management** — Admin UI to create shipper/driver portal accounts
|
||||||
|
4. **Invoice PDF polish** — Better templates, batch invoice generation
|
||||||
|
5. **Dashboard charts** — Recharts via CDN for visual analytics
|
||||||
|
6. **WhatsApp parser improvements** — Better regex, support more message formats
|
||||||
|
7. **Mobile responsiveness** — Ensure all views work well on phone screens
|
||||||
|
8. **i18n** — Hindi + Malayalam language support
|
||||||
215
webapp/src/routes/api.js
Normal file
215
webapp/src/routes/api.js
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const supabase = require('../services/supabase');
|
||||||
|
const { asyncHandler } = require('../middleware/security');
|
||||||
|
|
||||||
|
// All API routes require authentication
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LOADS API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// GET /api/loads — list loads with filters
|
||||||
|
router.get('/loads', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => {
|
||||||
|
const { status, shipper_id, page = 1, limit = 50, sort = 'created_at', order = 'desc' } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('loads')
|
||||||
|
.select('*, shipper:shippers(name), vehicle:vehicles(number), payments(*)', { count: 'exact' })
|
||||||
|
.order(sort, { ascending: order === 'asc' })
|
||||||
|
.range(offset, offset + parseInt(limit) - 1);
|
||||||
|
|
||||||
|
if (status) query = query.eq('status', status);
|
||||||
|
if (shipper_id) query = query.eq('shipper_id', shipper_id);
|
||||||
|
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: data || [],
|
||||||
|
pagination: { page: parseInt(page), limit: parseInt(limit), total: count, pages: Math.ceil((count || 0) / limit) },
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/loads/:id — single load
|
||||||
|
router.get('/loads/:id', asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('loads')
|
||||||
|
.select('*, shipper:shippers(*), vehicle:vehicles(*), payments(*)')
|
||||||
|
.eq('id', req.params.id).single();
|
||||||
|
if (error) return res.status(404).json({ error: 'Load not found' });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/loads — create load
|
||||||
|
router.post('/loads', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => {
|
||||||
|
const load = { ...req.body, created_by: req.session?.userId };
|
||||||
|
const { data, error } = await supabase.from('loads').insert(load).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.status(201).json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PUT /api/loads/:id — update load
|
||||||
|
router.put('/loads/:id', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('loads').update(req.body).eq('id', req.params.id).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /api/loads/:id — soft delete
|
||||||
|
router.delete('/loads/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { error } = await supabase.from('loads').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id);
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SHIPPERS API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
router.get('/shippers', asyncHandler(async (req, res) => {
|
||||||
|
const { search, page = 1, limit = 50 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = supabase.from('shippers').select('*', { count: 'exact' }).order('name').range(offset, offset + parseInt(limit) - 1);
|
||||||
|
if (search) query = query.or(`name.ilike.%${search}%,phone.ilike.%${search}%,city.ilike.%${search}%`);
|
||||||
|
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/shippers/:id', asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('shippers').select('*, loads(*, payments(*))').eq('id', req.params.id).single();
|
||||||
|
if (error) return res.status(404).json({ error: 'Shipper not found' });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/shippers', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('shippers').insert(req.body).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.status(201).json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/shippers/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('shippers').update(req.body).eq('id', req.params.id).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete('/shippers/:id', requireRole('admin'), asyncHandler(async (req, res) => {
|
||||||
|
const { error } = await supabase.from('shippers').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id);
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VEHICLES API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
router.get('/vehicles', asyncHandler(async (req, res) => {
|
||||||
|
const { search, page = 1, limit = 50 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = supabase.from('vehicles').select('*', { count: 'exact' }).order('number').range(offset, offset + parseInt(limit) - 1);
|
||||||
|
if (search) query = query.or(`number.ilike.%${search}%,driver_name.ilike.%${search}%`);
|
||||||
|
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/vehicles/:id', asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('vehicles').select('*, loads(*)').eq('id', req.params.id).single();
|
||||||
|
if (error) return res.status(404).json({ error: 'Vehicle not found' });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/vehicles', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('vehicles').insert(req.body).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.status(201).json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/vehicles/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('vehicles').update(req.body).eq('id', req.params.id).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete('/vehicles/:id', requireRole('admin'), asyncHandler(async (req, res) => {
|
||||||
|
const { error } = await supabase.from('vehicles').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id);
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PAYMENTS API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
router.get('/payments', asyncHandler(async (req, res) => {
|
||||||
|
const { load_id, type, page = 1, limit = 50 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = supabase.from('payments').select('*, load:loads(from_city, to_city, shipper:shippers(name))', { count: 'exact' })
|
||||||
|
.order('date', { ascending: false }).range(offset, offset + parseInt(limit) - 1);
|
||||||
|
if (load_id) query = query.eq('load_id', load_id);
|
||||||
|
if (type) query = query.eq('payment_type', type);
|
||||||
|
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/payments', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => {
|
||||||
|
const { data, error } = await supabase.from('payments').insert(req.body).select().single();
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.status(201).json(data);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete('/payments/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => {
|
||||||
|
const { error } = await supabase.from('payments').delete().eq('id', req.params.id);
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DASHBOARD STATS API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
router.get('/stats', asyncHandler(async (req, res) => {
|
||||||
|
const [
|
||||||
|
{ count: totalLoads },
|
||||||
|
{ data: loadsData },
|
||||||
|
{ count: totalShippers },
|
||||||
|
{ count: totalVehicles },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('loads').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('loads').select('freight_charged, commission, status, payments(amount, payment_type)'),
|
||||||
|
supabase.from('shippers').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('vehicles').select('*', { count: 'exact', head: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalFreight = loadsData?.reduce((s, l) => s + (l.freight_charged || 0), 0) || 0;
|
||||||
|
const totalCommission = loadsData?.reduce((s, l) => s + (l.commission || 0), 0) || 0;
|
||||||
|
const shipperPaid = loadsData?.reduce((s, l) => s + (l.payments?.filter(p => p.payment_type === 'credit').reduce((p, pay) => p + (pay.amount || 0), 0)), 0) || 0;
|
||||||
|
const shipperPending = totalFreight - shipperPaid;
|
||||||
|
|
||||||
|
const statusCounts = {};
|
||||||
|
loadsData?.forEach(l => { statusCounts[l.status] = (statusCounts[l.status] || 0) + 1; });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalLoads: totalLoads || 0,
|
||||||
|
totalShippers: totalShippers || 0,
|
||||||
|
totalVehicles: totalVehicles || 0,
|
||||||
|
totalFreight,
|
||||||
|
totalCommission,
|
||||||
|
shipperPaid,
|
||||||
|
shipperPending,
|
||||||
|
statusCounts,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
109
webapp/src/routes/portal-users.js
Normal file
109
webapp/src/routes/portal-users.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const supabase = require('../services/supabase');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const { asyncHandler } = require('../middleware/security');
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
router.use(requireRole('admin', 'manager'));
|
||||||
|
|
||||||
|
// GET /portal-users — list all portal users
|
||||||
|
router.get('/', asyncHandler(async (req, res) => {
|
||||||
|
const { data: users, error } = await supabase
|
||||||
|
.from('portal_users')
|
||||||
|
.select('*, shipper:shippers(name), driver:vehicles(number)')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) return res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
|
// Get shippers/drivers without portal accounts for the "create" dropdown
|
||||||
|
const { data: allShippers } = await supabase.from('shippers').select('id, name').order('name');
|
||||||
|
const { data: allVehicles } = await supabase.from('vehicles').select('id, number, driver_name').order('number');
|
||||||
|
|
||||||
|
// Filter to only those without portal accounts
|
||||||
|
const existingShipperIds = users?.filter(u => u.role === 'shipper').map(u => u.shipper_id) || [];
|
||||||
|
const existingDriverIds = users?.filter(u => u.role === 'driver').map(u => u.driver_id) || [];
|
||||||
|
|
||||||
|
res.render('pages/portal-users/list', {
|
||||||
|
users: users || [],
|
||||||
|
availableShippers: (allShippers || []).filter(s => !existingShipperIds.includes(s.id)),
|
||||||
|
availableDrivers: (allVehicles || []).filter(v => !existingDriverIds.includes(v.id)),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /portal-users — create portal user
|
||||||
|
router.post('/', asyncHandler(async (req, res) => {
|
||||||
|
const { username, password, role, shipper_id, driver_id } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password || !role) {
|
||||||
|
return res.status(400).json({ error: 'Username, password, and role are required' });
|
||||||
|
}
|
||||||
|
if (!['shipper', 'driver'].includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Role must be shipper or driver' });
|
||||||
|
}
|
||||||
|
if (role === 'shipper' && !shipper_id) {
|
||||||
|
return res.status(400).json({ error: 'Shipper must be selected' });
|
||||||
|
}
|
||||||
|
if (role === 'driver' && !driver_id) {
|
||||||
|
return res.status(400).json({ error: 'Driver/Vehicle must be selected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.from('portal_users').insert({
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
shipper_id: role === 'shipper' ? shipper_id : null,
|
||||||
|
driver_id: role === 'driver' ? driver_id : null,
|
||||||
|
is_active: true,
|
||||||
|
}).select().single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === '23505') {
|
||||||
|
return res.status(400).json({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/portal-users');
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PUT /portal-users/:id/toggle — enable/disable portal user
|
||||||
|
router.put('/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const { data: user } = await supabase.from('portal_users').select('is_active').eq('id', req.params.id).single();
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const { error } = await supabase.from('portal_users')
|
||||||
|
.update({ is_active: !user.is_active })
|
||||||
|
.eq('id', req.params.id);
|
||||||
|
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true, is_active: !user.is_active });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PUT /portal-users/:id/reset-password — reset password
|
||||||
|
router.put('/:id/reset-password', asyncHandler(async (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 12);
|
||||||
|
const { error } = await supabase.from('portal_users')
|
||||||
|
.update({ password_hash })
|
||||||
|
.eq('id', req.params.id);
|
||||||
|
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /portal-users/:id — delete portal user
|
||||||
|
router.delete('/:id', requireRole('admin'), asyncHandler(async (req, res) => {
|
||||||
|
const { error } = await supabase.from('portal_users').delete().eq('id', req.params.id);
|
||||||
|
if (error) return res.status(400).json({ error: error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -207,6 +207,8 @@ 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'));
|
app.use('/invoices', require('./routes/invoices'));
|
||||||
|
app.use('/portal-users', require('./routes/portal-users'));
|
||||||
|
app.use('/api', require('./routes/api'));
|
||||||
|
|
||||||
// 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() }));
|
||||||
|
|
|
||||||
143
webapp/src/views/pages/portal-users/list.ejs
Normal file
143
webapp/src/views/pages/portal-users/list.ejs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'portal-users' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">👥 Portal Users</h1>
|
||||||
|
<p class="page-subtitle">Manage shipper and driver portal access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create New Portal User -->
|
||||||
|
<% if (typeof availableShippers !== 'undefined') { %>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Create Portal Account</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/portal-users" class="row" style="gap:16px;align-items:flex-end;">
|
||||||
|
<div class="form-group" style="flex:2;">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select name="role" class="form-input" id="roleSelect" onchange="toggleRoleSelects()" required>
|
||||||
|
<option value="">Select role...</option>
|
||||||
|
<option value="shipper">Shipper</option>
|
||||||
|
<option value="driver">Driver</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2;">
|
||||||
|
<label class="form-label">Username / Phone</label>
|
||||||
|
<input type="text" name="username" class="form-input" required placeholder="Phone number or email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1;">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="text" name="password" class="form-input" required placeholder="Min 6 chars" minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2;">
|
||||||
|
<label class="form-label">Shipper</label>
|
||||||
|
<select name="shipper_id" class="form-input" id="shipperSelect" disabled>
|
||||||
|
<option value="">Select shipper...</option>
|
||||||
|
<% for (const s of availableShippers) { %>
|
||||||
|
<option value="<%= s.id %>"><%= s.name %></option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2;">
|
||||||
|
<label class="form-label">Driver / Vehicle</label>
|
||||||
|
<select name="driver_id" class="form-input" id="driverSelect" disabled>
|
||||||
|
<option value="">Select driver...</option>
|
||||||
|
<% for (const d of availableDrivers) { %>
|
||||||
|
<option value="<%= d.id %>"><%= d.number %> <%= d.driver_name ? '(' + d.driver_name + ')' : '' %></option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Existing Portal Users -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Existing Portal Accounts (<%= users.length %>)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<% if (users.length === 0) { %>
|
||||||
|
<p class="empty-state">No portal accounts created yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Linked To</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% for (const user of users) { %>
|
||||||
|
<tr>
|
||||||
|
<td><strong><%= user.username %></strong></td>
|
||||||
|
<td><span class="badge badge-<%= user.role === 'shipper' ? 'blue' : 'green' %>"><%= user.role %></span></td>
|
||||||
|
<td>
|
||||||
|
<% if (user.role === 'shipper' && user.shipper) { %>
|
||||||
|
<%= user.shipper.name %>
|
||||||
|
<% } else if (user.role === 'driver' && user.driver) { %>
|
||||||
|
<%= user.driver.number %>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-<%= user.is_active ? 'success' : 'danger' %>">
|
||||||
|
<%= user.is_active ? 'Active' : 'Disabled' %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><%= user.created_at ? new Date(user.created_at).toLocaleDateString('en-IN') : '—' %></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="toggleUser('<%= user.id %>', <%= user.is_active %>)">
|
||||||
|
<%= user.is_active ? 'Disable' : 'Enable' %>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="resetPassword('<%= user.id %>')">Reset PW</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleRoleSelects() {
|
||||||
|
const role = document.getElementById('roleSelect').value;
|
||||||
|
document.getElementById('shipperSelect').disabled = role !== 'shipper';
|
||||||
|
document.getElementById('driverSelect').disabled = role !== 'driver';
|
||||||
|
if (role !== 'shipper') document.getElementById('shipperSelect').value = '';
|
||||||
|
if (role !== 'driver') document.getElementById('driverSelect').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUser(id, currentStatus) {
|
||||||
|
if (!confirm(currentStatus ? 'Disable this portal account?' : 'Enable this portal account?')) return;
|
||||||
|
const res = await fetch(`/portal-users/${id}/toggle`, { method: 'PUT' });
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(id) {
|
||||||
|
const pw = prompt('Enter new password (min 6 chars):');
|
||||||
|
if (!pw || pw.length < 6) return alert('Password must be at least 6 characters');
|
||||||
|
const res = await fetch(`/portal-users/${id}/reset-password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: pw }),
|
||||||
|
});
|
||||||
|
if (res.ok) alert('Password reset successfully');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('../partials/footer') %>
|
||||||
|
|
@ -40,6 +40,11 @@
|
||||||
<a href="/shippers" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'shippers' ? 'active' : '' %>">🏢 Shippers</a>
|
<a href="/shippers" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'shippers' ? 'active' : '' %>">🏢 Shippers</a>
|
||||||
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : '' %>">🚚 Vehicles</a>
|
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : '' %>">🚚 Vehicles</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<span class="sidebar-title">Client Portal</span>
|
||||||
|
<a href="/portal-users" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'portal-users' ? 'active' : '' %>">👥 Portal Users</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue