Initial commit: FreightDesk v1.0

- Express + EJS server-rendered app
- Supabase PostgreSQL database
- Auth: username/password with bcrypt
- Dashboard with business stats
- Load CRUD with filters
- WhatsApp message parser
- Payment tracking
- Shipper & vehicle management
- Reports (monthly, top shippers, routes)
- Government-app aesthetic (tricolor theme)
- Dark mode support
- Docker + Coolify deployment ready
- Seed data from existing business ledger (88 loads, 41 shippers, 70 vehicles)
This commit is contained in:
FreightDesk 2026-06-07 18:57:24 +00:00
commit 1a4eaaa040
42 changed files with 6288 additions and 0 deletions

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
version: '3.8'
services:
freightdesk:
build:
context: ./webapp
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- APP_URL=http://localhost:3000
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_KEY=${SUPABASE_KEY}
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
- SESSION_SECRET=${SESSION_SECRET}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3

View file

@ -0,0 +1,175 @@
-- ============================================================
-- FreightDesk — Supabase Migration
-- Initial schema for freight forwarding commission agent
-- ============================================================
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================
-- SHIPPERS
-- ============================================================
CREATE TABLE shippers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
city TEXT DEFAULT 'Thiruvananthapuram',
state TEXT DEFAULT 'Kerala',
gst TEXT,
total_freight NUMERIC(12,2) DEFAULT 0,
total_commission NUMERIC(12,2) DEFAULT 0,
pending_amount NUMERIC(12,2) DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- VEHICLES
-- ============================================================
CREATE TABLE vehicles (
id TEXT PRIMARY KEY,
number TEXT NOT NULL UNIQUE,
type TEXT DEFAULT 'open',
capacity_ton NUMERIC(6,2),
city TEXT DEFAULT 'Thiruvananthapuram',
state TEXT DEFAULT 'Kerala',
owner_name TEXT,
owner_phone TEXT,
is_active BOOLEAN DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- LOADS (core table)
-- ============================================================
CREATE TABLE loads (
id TEXT PRIMARY KEY,
date DATE,
vehicle_id TEXT REFERENCES vehicles(id) ON DELETE SET NULL,
from_city TEXT,
via TEXT,
to_city TEXT,
shipper_id TEXT REFERENCES shippers(id) ON DELETE SET NULL,
load_type TEXT,
item TEXT,
freight_charged NUMERIC(12,2),
advance_received NUMERIC(12,2),
paid_to_driver NUMERIC(12,2),
commission NUMERIC(12,2),
driver_freight NUMERIC(12,2),
pending_from_shipper NUMERIC(12,2),
pending_to_driver NUMERIC(12,2),
status TEXT DEFAULT 'partial',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- PAYMENTS (individual payment transactions)
-- ============================================================
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
load_id TEXT REFERENCES loads(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('advance', 'balance', 'commission', 'driver_payment', 'other')),
direction TEXT NOT NULL CHECK (direction IN ('in', 'out')),
amount NUMERIC(12,2) NOT NULL,
method TEXT DEFAULT 'bank_transfer',
payment_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- PORTAL USERS (for shipper/driver portal access)
-- ============================================================
CREATE TABLE portal_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('shipper', 'driver', 'admin')),
entity_id TEXT, -- links to shippers.id or vehicles.id
is_active BOOLEAN DEFAULT true,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- COMMISSION INVOICES
-- ============================================================
CREATE TABLE commission_invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_no TEXT NOT NULL UNIQUE,
shipper_id TEXT REFERENCES shippers(id) ON DELETE SET NULL,
period_from DATE,
period_to DATE,
total_commission NUMERIC(12,2) NOT NULL DEFAULT 0,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'paid', 'overdue')),
due_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- APP SETTINGS
-- ============================================================
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- INDEXES
-- ============================================================
CREATE INDEX idx_loads_date ON loads(date);
CREATE INDEX idx_loads_status ON loads(status);
CREATE INDEX idx_loads_shipper ON loads(shipper_id);
CREATE INDEX idx_loads_vehicle ON loads(vehicle_id);
CREATE INDEX idx_loads_from_to ON loads(from_city, to_city);
CREATE INDEX idx_payments_load ON payments(load_id);
CREATE INDEX idx_payments_date ON payments(payment_date);
CREATE INDEX idx_vehicles_number ON vehicles(number);
CREATE INDEX idx_shippers_name ON shippers(name);
-- ============================================================
-- UPDATED_AT TRIGGER
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER shippers_updated_at BEFORE UPDATE ON shippers
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER vehicles_updated_at BEFORE UPDATE ON vehicles
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER loads_updated_at BEFORE UPDATE ON loads
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ============================================================
-- DEFAULT ADMIN USER (password: admin123)
-- bcrypt hash for 'admin123' with 10 rounds
-- ============================================================
INSERT INTO portal_users (username, password_hash, role, is_active)
VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'admin', true);
-- ============================================================
-- DEFAULT SETTINGS
-- ============================================================
INSERT INTO app_settings (key, value) VALUES
('app_name', 'FreightDesk'),
('app_name_hi', 'फ्रेटडेस्क'),
('currency', 'INR'),
('invoice_prefix', 'FD'),
('default_city', 'Thiruvananthapuram'),
('owner_name', ''),
('owner_phone', ''),
('owner_upi', '');

View file

@ -0,0 +1,19 @@
-- ============================================================
-- FreightDesk — Seed Data Migration
-- Imports existing business data from JSON ledger
-- ============================================================
-- Note: Run this after 001_initial_schema.sql
-- This inserts the seed data from seed_data.json
-- The actual seed data will be loaded via the seed.js script
-- which reads supabase/seed_data.json and inserts via Supabase client
-- This file is a placeholder for any SQL-level seed operations
-- For example, updating computed fields after data load:
-- Update shipper totals after loads are inserted
-- UPDATE shippers SET
-- total_freight = (SELECT COALESCE(SUM(freight_charged), 0) FROM loads WHERE shipper_id = shippers.id),
-- total_commission = (SELECT COALESCE(SUM(commission), 0) FROM loads WHERE shipper_id = shippers.id),
-- pending_amount = (SELECT COALESCE(SUM(pending_from_shipper), 0) FROM loads WHERE shipper_id = shippers.id);

2779
supabase/seed_data.json Normal file

File diff suppressed because it is too large Load diff

9
webapp/.env.example Normal file
View file

@ -0,0 +1,9 @@
NODE_ENV=development
PORT=3000
APP_URL=http://localhost:3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-anon-key
SUPABASE_SERVICE_KEY=your-service-role-key
SESSION_SECRET=change-this-to-a-random-string-in-production

24
webapp/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy source
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup && \
chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]

25
webapp/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "freightdesk",
"version": "1.0.0",
"description": "FreightDesk — Freight Forwarding Commission Agent Management",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"seed": "node seed.js"
},
"keywords": ["freight", "logistics", "commission", "agent", "india"],
"license": "ISC",
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-session": "^1.18.0",
"helmet": "^7.1.0"
}
}

122
webapp/seed.js Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* FreightDesk Seed Script
* Reads supabase/seed_data.json and inserts into Supabase
* Also runs the schema migrations
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing SUPABASE_URL or SUPABASE_KEY in .env');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function runMigrations() {
const migrationsDir = path.join(__dirname, '..', 'supabase', 'migrations');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const file of files) {
if (file.includes('seed')) continue; // Skip seed placeholder
console.log(`Running migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
// Split by semicolons and execute each statement
const statements = sql.split(';').filter(s => s.trim() && !s.trim().startsWith('--'));
for (const stmt of statements) {
if (stmt.trim()) {
const { error } = await supabase.rpc('exec_sql', { sql: stmt.trim() });
if (error && !error.message.includes('already exists')) {
console.warn(` Warning: ${error.message}`);
}
}
}
console.log(` Done.`);
}
}
async function seedData() {
const seedFile = path.join(__dirname, '..', 'supabase', 'seed_data.json');
if (!fs.existsSync(seedFile)) {
console.log('No seed_data.json found, skipping seed.');
return;
}
const seed = JSON.parse(fs.readFileSync(seedFile, 'utf-8'));
// Insert shippers
if (seed.shippers && seed.shippers.length > 0) {
console.log(`Inserting ${seed.shippers.length} shippers...`);
const { error } = await supabase.from('shippers').upsert(seed.shippers, { onConflict: 'id' });
if (error) console.error('Shippers error:', error.message);
else console.log(' Done.');
}
// Insert vehicles
if (seed.vehicles && seed.vehicles.length > 0) {
console.log(`Inserting ${seed.vehicles.length} vehicles...`);
const { error } = await supabase.from('vehicles').upsert(seed.vehicles, { onConflict: 'id' });
if (error) console.error('Vehicles error:', error.message);
else console.log(' Done.');
}
// Insert loads in batches
if (seed.loads && seed.loads.length > 0) {
console.log(`Inserting ${seed.loads.length} loads...`);
const batchSize = 20;
for (let i = 0; i < seed.loads.length; i += batchSize) {
const batch = seed.loads.slice(i, i + batchSize);
const { error } = await supabase.from('loads').upsert(batch, { onConflict: 'id' });
if (error) console.error(`Loads batch error (${i}-${i + batchSize}):`, error.message);
else console.log(` Batch ${Math.floor(i / batchSize) + 1} done.`);
}
}
// Update shipper totals
console.log('Updating shipper totals...');
for (const shipper of seed.shippers) {
const shipperLoads = seed.loads.filter(l => l.shipper_id === shipper.id);
const totals = shipperLoads.reduce((acc, l) => ({
freight: acc.freight + (l.freight_charged || 0),
commission: acc.commission + (l.commission || 0),
pending: acc.pending + (l.pending_from_shipper || 0),
}), { freight: 0, commission: 0, pending: 0 });
await supabase.from('shippers').update({
total_freight: totals.freight,
total_commission: totals.commission,
pending_amount: totals.pending,
}).eq('id', shipper.id);
}
console.log(' Done.');
}
async function main() {
console.log('=== FreightDesk Seeder ===\n');
try {
console.log('Step 1: Running migrations...');
// Note: For actual migration execution, use Supabase CLI or Dashboard SQL editor
// This is a simplified approach - run migrations manually in Supabase SQL Editor
console.log(' Please run the SQL migrations in Supabase SQL Editor first:');
console.log(' - supabase/migrations/001_initial_schema.sql\n');
console.log('Step 2: Seeding data...');
await seedData();
console.log('\n=== Seed complete! ===');
} catch (err) {
console.error('Fatal error:', err);
process.exit(1);
}
}
main();

View file

@ -0,0 +1,52 @@
module.exports = {
LOAD_STATUSES: [
'pending lead',
'assigned vehicle',
'assigned',
'loaded / in transit',
'delivered / pending collection',
'pending collection',
'partially pending',
'fully pending from shipper',
'settled',
'commission received',
'commission adjusted',
'commission due',
'reconciled',
'completed',
'handled directly by shipper',
'partial',
'available vehicle',
'cancelled',
],
VEHICLE_TYPES: ['open', 'closed', 'container', 'flatbed', 'tanker', 'refrigerated', 'mini', '10ft', '17ft', '20ft', '24ft'],
LOAD_TYPES: ['Full load', 'Part load', 'Mixed', 'Household', 'Container'],
PAYMENT_METHODS: ['bank_transfer', 'upi', 'cash', 'cheque', 'online'],
PAYMENT_TYPES: [
{ value: 'advance', label: 'Advance' },
{ value: 'balance', label: 'Balance' },
{ value: 'commission', label: 'Commission' },
{ value: 'driver_payment', label: 'Driver Payment' },
{ value: 'other', label: 'Other' },
],
CITIES: [
'Thiruvananthapuram', 'Kollam', 'Kochi', 'Cochin', 'Thrissur', 'Palakkad',
'Kozhikode', 'Kannur', 'Mangalore', 'Bangalore', 'Chennai', 'Coimbatore',
'Madurai', 'Salem', 'Erode', 'Tirupur', 'Vellore', 'Tirupati',
'Hyderabad', 'Vijayawada', 'Visakhapatnam', 'Mumbai', 'Pune', 'Nagpur',
'Delhi', 'Noida', 'Gurugram', 'Ahmedabad', 'Surat', 'Jaipur', 'Lucknow',
'Kolkata', 'Bhopal', 'Indore', 'Patna', 'Ranchi', 'Guwahati',
'Guruvayoor', 'Kalpetta', 'Theni', 'Tiruchirappalli', 'Tanjavur',
'Thiruvalla', 'Pathanamthitta', 'Adoor', 'Kottarakara', 'Changanassery',
'Perumathura', 'Marapalam', 'Udupi', 'Mysore', 'Hubli', 'Nashik',
'Rajahmundry', 'Gundoor', 'Gaganpahad', 'Tolichoki', 'Himayathnagar',
'Sheikpet', 'Ukkadam', 'Ganapati', 'Shabarimala', 'Triplicane',
],
STATES: {
KL: 'Kerala', TN: 'Tamil Nadu', KA: 'Karnataka', AP: 'Andhra Pradesh',
TS: 'Telangana', MH: 'Maharashtra', GJ: 'Gujarat', RJ: 'Rajasthan',
UP: 'Uttar Pradesh', DL: 'Delhi', HR: 'Haryana', PB: 'Punjab',
WB: 'West Bengal', MP: 'Madhya Pradesh', CG: 'Chhattisgarh',
JH: 'Jharkhand', BR: 'Bihar', OR: 'Odisha', GA: 'Goa',
},
};

13
webapp/src/config/env.js Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
appUrl: process.env.APP_URL || 'http://localhost:3000',
session: {
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
},
supabase: {
url: process.env.SUPABASE_URL,
key: process.env.SUPABASE_KEY,
serviceKey: process.env.SUPABASE_SERVICE_KEY,
},
};

92
webapp/src/lib/india.js Normal file
View file

@ -0,0 +1,92 @@
// India-specific utilities for FreightDesk
const STATES = {
KL:'Kerala',TN:'Tamil Nadu',KA:'Karnataka',AP:'Andhra Pradesh',TS:'Telangana',
MH:'Maharashtra',GJ:'Gujarat',RJ:'Rajasthan',UP:'Uttar Pradesh',DL:'Delhi',
HR:'Haryana',PB:'Punjab',WB:'West Bengal',MP:'Madhya Pradesh',GA:'Goa',
};
function formatINR(n) {
if (n === null || n === undefined || isNaN(n)) return '—';
return '₹' + parseFloat(n).toLocaleString('en-IN');
}
function formatINRShort(n) {
if (n === null || n === undefined || isNaN(n)) return '—';
const num = parseFloat(n);
if (num >= 100000) return '₹' + (num / 100000).toFixed(1) + 'L';
if (num >= 1000) return '₹' + (num / 1000).toFixed(1) + 'K';
return '₹' + num.toLocaleString('en-IN');
}
function formatDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatDateShort(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' });
}
function validateVehicleNumber(number) {
if (!number) return { valid: false };
const cleaned = number.replace(/[\s\-\.]/g, '').toUpperCase();
if (!/^[A-Z]{2}\d{1,2}[A-Z]{1,3}\d{4}$/.test(cleaned)) return { valid: false };
const sc = cleaned.substring(0, 2);
return {
valid: true,
state_code: sc,
state_name: STATES[sc] || 'Unknown',
formatted: `${cleaned.substring(0,2)} ${cleaned.substring(2,4)} ${cleaned.substring(4,cleaned.length-4)} ${cleaned.slice(-4)}`,
};
}
function getStatusColor(status) {
const colors = {
'settled': 'green',
'completed': 'green',
'commission received': 'green',
'reconciled': 'green',
'handled directly by shipper': 'green',
'loaded / in transit': 'blue',
'assigned': 'blue',
'assigned vehicle': 'blue',
'pending collection': 'orange',
'partially pending': 'orange',
'fully pending from shipper': 'orange',
'delivered / pending collection': 'orange',
'commission due': 'orange',
'pending lead': 'gray',
'partial': 'gray',
'available vehicle': 'gray',
'commission adjusted': 'purple',
'cancelled': 'red',
};
return colors[status] || 'gray';
}
function calcCommission(freight, paidToDriver) {
if (!freight || !paidToDriver) return null;
return freight - paidToDriver;
}
function calcPendingFromShipper(freight, advance) {
if (!freight) return null;
return freight - (advance || 0);
}
function calcPendingToDriver(driverFreight, paid) {
if (!driverFreight) return null;
return driverFreight - (paid || 0);
}
module.exports = {
STATES, formatINR, formatINRShort, formatDate, formatDateShort,
validateVehicleNumber, getStatusColor,
calcCommission, calcPendingFromShipper, calcPendingToDriver,
};

View file

@ -0,0 +1,30 @@
function requireAuth(req, res, next) {
if (req.session && req.session.user) {
res.locals.user = req.session.user;
return next();
}
if (req.accepts('html')) {
res.redirect('/login');
} else {
res.status(401).json({ error: 'Authentication required' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (!req.session || !req.session.user) {
if (req.accepts('html')) return res.redirect('/login');
return res.status(401).json({ error: 'Authentication required' });
}
if (roles.includes(req.session.user.role) || req.session.user.role === 'admin') {
return next();
}
if (req.accepts('html')) {
res.status(403).render('pages/403');
} else {
res.status(403).json({ error: 'Forbidden' });
}
};
}
module.exports = { requireAuth, requireRole };

View file

@ -0,0 +1,62 @@
const crypto = require('crypto');
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
function setupCSRF(req, res, next) {
if (!req.session._csrf) {
req.session._csrf = generateCSRFToken();
}
res.locals._csrf = req.session._csrf;
next();
}
function validateCSRF(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const token = req.body?._csrf || req.headers['x-csrf-token'] || req.query._csrf;
if (!token || token !== req.session._csrf) {
return res.status(403).send('Invalid CSRF token. Please go back and try again.');
}
next();
}
function sanitizeInput(str) {
if (typeof str !== 'string') return str;
return str
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
function sanitizeBody(req, res, next) {
if (req.body && typeof req.body === 'object') {
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'string') {
req.body[key] = sanitizeInput(req.body[key]).trim();
}
}
}
next();
}
function requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const status = res.statusCode;
const icon = status >= 500 ? '❌' : status >= 400 ? '⚠️' : status >= 300 ? '↪️' : '✓';
console.log(`${icon} ${req.method} ${req.url} ${status} ${duration}ms`);
});
next();
}
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
module.exports = { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler, generateCSRFToken };

View file

@ -0,0 +1,767 @@
/* ============================================================
FreightDesk Government App Aesthetic
Tricolor: Saffron #FF9933 | White #FFFFFF | Green #138808
Navy: #000080 (Ashoka Chakra blue)
============================================================ */
:root {
/* Colors */
--saffron: #FF9933;
--saffron-dark: #E68A2E;
--green: #138808;
--green-dark: #0F6B06;
--navy: #000080;
--navy-light: #1a1a9a;
--white: #FFFFFF;
--bg: #f5f5f0;
--bg-card: #FFFFFF;
--bg-sidebar: #000080;
--text: #1a1a2e;
--text-muted: #666;
--text-light: #999;
--border: #e0ddd5;
--danger: #dc3545;
--warning: #f0ad4e;
--success: #138808;
--info: #17a2b8;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.12);
--radius: 8px;
--radius-lg: 12px;
--font-hi: 'Noto Sans Devanagari', sans-serif;
--font-en: 'Inter', -apple-system, sans-serif;
}
[data-theme="dark"] {
--bg: #1a1a2e;
--bg-card: #252540;
--bg-sidebar: #0a0a2e;
--text: #e8e8f0;
--text-muted: #a0a0b0;
--text-light: #707080;
--border: #3a3a55;
--shadow: 0 2px 8px rgba(0,0,0,0.3);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-en);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* ============================================================
TOPBAR
============================================================ */
.topbar {
background: var(--navy);
color: var(--white);
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.topbar::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, var(--saffron) 33%, var(--white) 33%, var(--white) 66%, var(--green) 66%);
}
.topbar-brand {
display: flex;
align-items: center;
gap: 12px;
}
.emblem {
font-size: 28px;
line-height: 1;
}
.brand-text {
display: flex;
flex-direction: column;
}
.brand-hi {
font-family: var(--font-hi);
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
.brand-en {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
opacity: 0.9;
}
.brand-tagline {
font-size: 9px;
opacity: 0.7;
letter-spacing: 0.3px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-size: 14px;
opacity: 0.9;
}
/* ============================================================
LAYOUT
============================================================ */
.layout {
display: flex;
min-height: calc(100vh - 64px);
}
.sidebar {
width: 240px;
background: var(--bg-sidebar);
color: var(--white);
padding: 20px 0;
flex-shrink: 0;
min-height: calc(100vh - 64px);
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-title {
display: block;
padding: 8px 20px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.5;
font-weight: 600;
}
.sidebar-link {
display: block;
padding: 10px 20px;
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.sidebar-link:hover {
background: rgba(255,255,255,0.1);
color: var(--white);
}
.sidebar-link.active {
background: rgba(255,255,255,0.15);
color: var(--white);
border-left-color: var(--saffron);
font-weight: 600;
}
.content {
flex: 1;
padding: 24px;
max-width: 100%;
}
/* ============================================================
CARDS
============================================================ */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
border: 1px solid var(--border);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.card-body {
padding: 20px;
}
/* ============================================================
STATS
============================================================ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-left: 4px solid var(--navy);
}
.stat-card.stat-primary { border-left-color: var(--navy); }
.stat-card.stat-success { border-left-color: var(--green); }
.stat-card.stat-warning { border-left-color: var(--saffron); }
.stat-card.stat-info { border-left-color: var(--info); }
.stat-icon {
font-size: 32px;
line-height: 1;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================================
TABLES
============================================================ */
.table-responsive {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 2px solid var(--border);
font-weight: 600;
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.table tr:hover td {
background: rgba(0,0,128,0.02);
}
/* ============================================================
BADGES
============================================================ */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
}
.badge-green { background: rgba(19,136,8,0.1); color: var(--green); }
.badge-blue { background: rgba(0,0,128,0.1); color: var(--navy); }
.badge-orange { background: rgba(255,153,51,0.15); color: #c47000; }
.badge-gray { background: rgba(128,128,128,0.1); color: #666; }
.badge-red { background: rgba(220,53,69,0.1); color: var(--danger); }
.badge-purple { background: rgba(128,0,128,0.1); color: #800080; }
.badge-success { background: rgba(19,136,8,0.1); color: var(--green); }
.badge-warning { background: rgba(240,173,78,0.15); color: #c47000; }
.badge-danger { background: rgba(220,53,69,0.1); color: var(--danger); }
.badge-info { background: rgba(23,162,184,0.1); color: var(--info); }
/* ============================================================
BUTTONS
============================================================ */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius);
font-size: 14px;
font-weight: 500;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s;
font-family: var(--font-en);
}
.btn-primary {
background: var(--navy);
color: var(--white);
}
.btn-primary:hover { background: var(--navy-light); }
.btn-secondary {
background: var(--saffron);
color: var(--white);
}
.btn-secondary:hover { background: var(--saffron-dark); }
.btn-outline {
background: transparent;
color: var(--navy);
border: 1px solid var(--navy);
}
.btn-outline:hover { background: rgba(0,0,128,0.05); }
.btn-danger {
background: var(--danger);
color: var(--white);
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
}
.btn-block {
width: 100%;
justify-content: center;
}
.btn-icon {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
/* ============================================================
FORMS
============================================================ */
.form-group {
margin-bottom: 16px;
flex: 1;
}
.form-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.form-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font-en);
background: var(--bg-card);
color: var(--text);
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--navy);
box-shadow: 0 0 0 3px rgba(0,0,128,0.1);
}
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--navy);
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
/* ============================================================
FILTER BAR
============================================================ */
.filter-bar {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.filter-bar .form-group {
margin-bottom: 0;
}
/* ============================================================
PAGE HEADER
============================================================ */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 24px;
font-weight: 700;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-top: 2px;
}
.page-actions {
display: flex;
gap: 8px;
}
/* ============================================================
GRID
============================================================ */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.grid-2 { grid-template-columns: 1fr; }
.sidebar { display: none; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 600px) {
.stats-grid { grid-template-columns: 1fr; }
.form-row { flex-direction: column; }
.filter-bar { flex-direction: column; }
}
/* ============================================================
DETAIL LIST
============================================================ */
.detail-list {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 16px;
}
.detail-list dt {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
}
.detail-list dd {
font-size: 14px;
}
/* ============================================================
STATUS GRID
============================================================ */
.status-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-label {
font-size: 13px;
color: var(--text-muted);
}
/* ============================================================
ALERTS
============================================================ */
.alert {
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 14px;
}
.alert-error {
background: rgba(220,53,69,0.1);
color: var(--danger);
border: 1px solid rgba(220,53,69,0.2);
}
.alert-success {
background: rgba(19,136,8,0.1);
color: var(--green);
border: 1px solid rgba(19,136,8,0.2);
}
/* ============================================================
EMPTY STATE
============================================================ */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
}
/* ============================================================
LOGIN PAGE
============================================================ */
.auth-page {
background: linear-gradient(135deg, var(--navy) 0%, #1a1a4a 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 420px;
overflow: hidden;
}
.login-header {
background: var(--navy);
color: var(--white);
padding: 32px 24px 24px;
text-align: center;
position: relative;
}
.login-header::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, var(--saffron) 33%, var(--white) 33%, var(--white) 66%, var(--green) 66%);
}
.login-emblem {
font-size: 48px;
margin-bottom: 12px;
}
.login-title-hi {
font-family: var(--font-hi);
font-size: 24px;
font-weight: 700;
}
.login-title-en {
font-size: 14px;
font-weight: 600;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 1px;
}
.login-tagline {
font-family: var(--font-hi);
font-size: 13px;
opacity: 0.8;
margin-top: 8px;
}
.login-tagline-en {
font-size: 11px;
opacity: 0.6;
margin-top: 4px;
}
.login-form {
padding: 24px;
}
.login-footer {
padding: 16px 24px;
text-align: center;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
/* ============================================================
ERROR PAGES
============================================================ */
.error-page {
text-align: center;
padding: 80px 20px;
}
.error-code {
font-size: 120px;
font-weight: 700;
color: var(--navy);
line-height: 1;
opacity: 0.3;
}
.error-page h1 {
font-size: 24px;
margin: 16px 0 8px;
}
.error-page p {
color: var(--text-muted);
}
/* ============================================================
FOOTER
============================================================ */
.govt-footer {
text-align: center;
padding: 24px;
border-top: 1px solid var(--border);
margin-top: 40px;
font-size: 12px;
color: var(--text-muted);
}
.footer-tricolor {
display: flex;
height: 4px;
border-radius: 2px;
overflow: hidden;
margin: 0 auto 16px;
max-width: 200px;
}
.footer-tricolor span:nth-child(1) { background: var(--saffron); flex: 1; }
.footer-tricolor span:nth-child(2) { background: var(--white); flex: 1; border-left: 1px solid #ddd; border-right: 1px solid #ddd; }
.footer-tricolor span:nth-child(3) { background: var(--green); flex: 1; }
.footer-muted {
opacity: 0.6;
margin-top: 4px;
}
/* ============================================================
PARSE RESULT
============================================================ */
.parse-result {
background: rgba(0,0,128,0.03);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.parse-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.parse-field {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--bg-card);
border-radius: 4px;
font-size: 13px;
}
.parse-key {
color: var(--text-muted);
font-weight: 500;
}
.parse-val {
font-weight: 600;
}
/* ============================================================
UTILITIES
============================================================ */
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.text-bold { font-weight: 700; }
.text-success { color: var(--green); }
.text-danger { color: var(--danger); }
.text-warning { color: #c47000; }
.text-muted { color: var(--text-muted); }
.text-center { text-align: center; }

View file

@ -0,0 +1,59 @@
// FreightDesk — Client-side JavaScript
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('fd-theme', next);
}
// Restore theme
(function() {
const saved = localStorage.getItem('fd-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
// Auto-hide alerts after 5 seconds
document.addEventListener('DOMContentLoaded', function() {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(function(alert) {
setTimeout(function() {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.5s';
setTimeout(function() { alert.remove(); }, 500);
}, 5000);
});
});
// Confirm delete actions
document.querySelectorAll('form[onsubmit]').forEach(function(form) {
form.addEventListener('submit', function(e) {
const msg = form.getAttribute('onsubmit');
if (msg && msg.includes('confirm')) {
const question = msg.match(/confirm\('(.+?)'\)/);
if (question && !confirm(question[1])) {
e.preventDefault();
}
}
});
});
// WhatsApp parser (inline function for form page)
// parseWhatsApp() and applyParsed() are defined inline in the form view
// Format number as INR
function formatINR(num) {
if (num === null || num === undefined || isNaN(num)) return '—';
return '₹' + parseFloat(num).toLocaleString('en-IN');
}
// Debounce helper
function debounce(fn, ms) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}

View file

@ -0,0 +1,67 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
const { formatINR, getStatusColor } = require('../lib/india');
// GET / — Dashboard
router.get('/', requireAuth, asyncHandler(async (req, res) => {
// Fetch summary stats
const { data: loads } = await supabase.from('loads').select('*');
const allLoads = loads || [];
const totalFreight = allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0);
const totalCommission = allLoads.reduce((s, l) => s + (l.commission || 0), 0);
const totalPendingShipper = allLoads.reduce((s, l) => s + (l.pending_from_shipper || 0), 0);
const totalPendingDriver = allLoads.reduce((s, l) => s + (l.pending_to_driver || 0), 0);
const settledCount = allLoads.filter(l => ['settled', 'completed', 'commission received', 'reconciled'].includes(l.status)).length;
// Recent loads (last 10)
const recentLoads = allLoads
.filter(l => l.date)
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 10);
// Status breakdown
const statusCounts = {};
for (const l of allLoads) {
const s = l.status || 'unknown';
statusCounts[s] = (statusCounts[s] || 0) + 1;
}
// Monthly data (last 6 months)
const monthlyData = {};
for (const l of allLoads) {
if (!l.date) continue;
const d = new Date(l.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyData[key]) monthlyData[key] = { freight: 0, commission: 0, count: 0 };
monthlyData[key].freight += l.freight_charged || 0;
monthlyData[key].commission += l.commission || 0;
monthlyData[key].count++;
}
// Recent payments needed
const pendingCollection = allLoads
.filter(l => ['pending collection', 'partially pending', 'fully pending from shipper', 'delivered / pending collection'].includes(l.status))
.slice(0, 5);
res.render('pages/dashboard', {
stats: {
totalFreight,
totalCommission,
totalPendingShipper,
totalPendingDriver,
totalLoads: allLoads.length,
settledCount,
},
recentLoads,
statusCounts,
monthlyData,
pendingCollection,
getStatusColor,
});
}));
module.exports = router;

187
webapp/src/routes/loads.js Normal file
View file

@ -0,0 +1,187 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
const { formatINR, formatDate, getStatusColor, calcCommission, calcPendingFromShipper, calcPendingToDriver } = require('../lib/india');
const { CITIES, LOAD_STATUSES, LOAD_TYPES } = require('../config/constants');
// GET /loads — List all loads
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { status, shipper, search, sort } = req.query;
let query = supabase.from('loads').select('*, shipper:shippers(name), vehicle:vehicles(number)');
if (status) query = query.eq('status', status);
if (shipper) query = query.eq('shipper_id', shipper);
if (search) {
query = query.or(`from_city.ilike.%${search}%,to_city.ilike.%${search}%,notes.ilike.%${search}%`);
}
const sortField = sort === 'freight' ? 'freight_charged' : 'date';
query = query.order(sortField, { ascending: false, nullsFirst: false }).limit(100);
const { data: loads } = await query;
// Get all shippers for filter dropdown
const { data: shippers } = await supabase.from('shippers').select('id, name').order('name');
res.render('pages/loads/list', {
loads: loads || [],
shippers: shippers || [],
filters: { status, shipper, search, sort },
LOAD_STATUSES,
getStatusColor,
});
}));
// GET /loads/new — New load form
router.get('/new', requireAuth, asyncHandler(async (req, res) => {
const { data: shippers } = await supabase.from('shippers').select('*').order('name');
const { data: vehicles } = await supabase.from('vehicles').select('*').eq('is_active', true).order('number');
res.render('pages/loads/form', {
load: {},
shippers: shippers || [],
vehicles: vehicles || [],
LOAD_STATUSES,
LOAD_TYPES,
CITIES,
isEdit: false,
});
}));
// POST /loads — Create load
router.post('/', requireAuth, asyncHandler(async (req, res) => {
const body = { ...req.body };
// Auto-calculate
const freight = parseFloat(body.freight_charged) || 0;
const advance = parseFloat(body.advance_received) || 0;
const paid = parseFloat(body.paid_to_driver) || 0;
const driverFreight = parseFloat(body.driver_freight) || 0;
if (!body.commission && freight && paid) {
body.commission = freight - paid;
}
if (!body.pending_from_shipper && freight) {
body.pending_from_shipper = freight - advance;
}
if (!body.pending_to_driver && driverFreight) {
body.pending_to_driver = driverFreight - paid;
}
// Clean empty strings to null
for (const key of Object.keys(body)) {
if (body[key] === '') body[key] = null;
}
const { data, error } = await supabase.from('insert_load').insert(body).select().single();
if (error) {
console.error('Insert error:', error);
const { data: shippers } = await supabase.from('shippers').select('*').order('name');
const { data: vehicles } = await supabase.from('vehicles').select('*').eq('is_active', true).order('number');
return res.render('pages/loads/form', {
load: body, shippers: shippers || [], vehicles: vehicles || [],
LOAD_STATUSES, LOAD_TYPES, CITIES, isEdit: false, error: 'Failed to save load',
});
}
// Update shipper totals
if (body.shipper_id) {
await updateShipperTotals(body.shipper_id);
}
res.redirect('/loads/' + encodeURIComponent(data.id));
}));
// GET /loads/:id — Load detail
router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
const { data: load } = await supabase.from('loads')
.select('*, shipper:shippers(*), vehicle:vehicles(*)')
.eq('id', req.params.id)
.single();
if (!load) return res.status(404).render('pages/404');
const { data: payments } = await supabase.from('payments')
.select('*').eq('load_id', load.id).order('payment_date', { ascending: false });
res.render('pages/loads/detail', {
load,
payments: payments || [],
getStatusColor,
});
}));
// GET /loads/:id/edit — Edit form
router.get('/:id/edit', requireAuth, asyncHandler(async (req, res) => {
const { data: load } = await supabase.from('loads').select('*').eq('id', req.params.id).single();
if (!load) return res.status(404).render('pages/404');
const { data: shippers } = await supabase.from('shippers').select('*').order('name');
const { data: vehicles } = await supabase.from('vehicles').select('*').order('number');
res.render('pages/loads/form', {
load,
shippers: shippers || [],
vehicles: vehicles || [],
LOAD_STATUSES,
LOAD_TYPES,
CITIES,
isEdit: true,
});
}));
// POST /loads/:id — Update load
router.post('/:id', requireAuth, asyncHandler(async (req, res) => {
const body = { ...req.body };
const freight = parseFloat(body.freight_charged) || 0;
const advance = parseFloat(body.advance_received) || 0;
const paid = parseFloat(body.paid_to_driver) || 0;
const driverFreight = parseFloat(body.driver_freight) || 0;
if (body.commission === '') body.commission = (freight && paid) ? freight - paid : null;
if (body.pending_from_shipper === '') body.pending_from_shipper = freight ? freight - advance : null;
if (body.pending_to_driver === '') body.pending_to_driver = driverFreight ? driverFreight - paid : null;
for (const key of Object.keys(body)) {
if (body[key] === '') body[key] = null;
}
delete body._csrf;
const { error } = await supabase.from('loads').update(body).eq('id', req.params.id);
if (error) console.error('Update error:', error);
if (body.shipper_id) await updateShipperTotals(body.shipper_id);
res.redirect('/loads/' + encodeURIComponent(req.params.id));
}));
// POST /loads/:id/delete — Delete load
router.post('/:id/delete', requireAuth, asyncHandler(async (req, res) => {
await supabase.from('loads').delete().eq('id', req.params.id);
res.redirect('/loads');
}));
// Helper: Update shipper totals
async function updateShipperTotals(shipperId) {
const { data: shipperLoads } = await supabase.from('loads')
.select('freight_charged, commission, pending_from_shipper')
.eq('shipper_id', shipperId);
if (!shipperLoads) return;
const totals = shipperLoads.reduce((acc, l) => ({
freight: acc.freight + (l.freight_charged || 0),
commission: acc.commission + (l.commission || 0),
pending: acc.pending + (l.pending_from_shipper || 0),
}), { freight: 0, commission: 0, pending: 0 });
await supabase.from('shippers').update({
total_freight: totals.freight,
total_commission: totals.commission,
pending_amount: totals.pending,
}).eq('id', shipperId);
}
module.exports = router;

View file

@ -0,0 +1,37 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
const { PAYMENT_METHODS } = require('../config/constants');
// GET /payments — Payment ledger
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { data: payments } = await supabase
.from('payments')
.select('*, load:loads(from_city, to_city, shipper:shippers(name))')
.order('payment_date', { ascending: false, nullsFirst: false })
.limit(50);
res.render('pages/payments/list', {
payments: payments || [],
PAYMENT_METHODS,
});
}));
// POST /payments — Record a payment
router.post('/', requireAuth, asyncHandler(async (req, res) => {
const { load_id, type, direction, amount, method, payment_date, notes } = req.body;
await supabase.from('payments').insert({
load_id, type, direction,
amount: parseFloat(amount) || 0,
method: method || 'bank_transfer',
payment_date: payment_date || null,
notes: notes || null,
});
res.redirect(req.get('Referer') || '/payments');
}));
module.exports = router;

View file

@ -0,0 +1,50 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
// GET /reports — Reports dashboard
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { data: loads } = await supabase.from('loads').select('*');
const allLoads = loads || [];
// Monthly breakdown
const monthly = {};
for (const l of allLoads) {
if (!l.date) continue;
const d = new Date(l.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthly[key]) monthly[key] = { label: d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }), freight: 0, commission: 0, count: 0, pending: 0 };
monthly[key].freight += l.freight_charged || 0;
monthly[key].commission += l.commission || 0;
monthly[key].count++;
monthly[key].pending += l.pending_from_shipper || 0;
}
const monthlySorted = Object.entries(monthly).sort(([a], [b]) => a.localeCompare(b));
// Top shippers
const { data: shippers } = await supabase
.from('shippers').select('*').order('total_freight', { ascending: false }).limit(10);
// Route analysis
const routes = {};
for (const l of allLoads) {
if (!l.from_city || !l.to_city) continue;
const key = `${l.from_city}${l.to_city}`;
if (!routes[key]) routes[key] = { route: key, count: 0, total_freight: 0, total_commission: 0 };
routes[key].count++;
routes[key].total_freight += l.freight_charged || 0;
routes[key].total_commission += l.commission || 0;
}
const topRoutes = Object.values(routes).sort((a, b) => b.total_commission - a.total_commission).slice(10);
res.render('pages/reports/index', {
monthly: monthlySorted,
shippers: shippers || [],
routes: topRoutes,
totalLoads: allLoads.length,
});
}));
module.exports = router;

View file

@ -0,0 +1,54 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
// GET /shippers — List all shippers
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { data: shippers } = await supabase
.from('shippers')
.select('*')
.order('total_freight', { ascending: false });
res.render('pages/shippers/list', { shippers: shippers || [] });
}));
// GET /shippers/:id — Shipper detail with loads
router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
const { data: shipper } = await supabase
.from('shippers').select('*').eq('id', req.params.id).single();
if (!shipper) return res.status(404).render('pages/404');
const { data: loads } = await supabase
.from('loads')
.select('*, vehicle:vehicles(number)')
.eq('shipper_id', req.params.id)
.order('date', { ascending: false, nullsFirst: false });
res.render('pages/shippers/detail', {
shipper,
loads: loads || [],
});
}));
// POST /shippers — Create shipper
router.post('/', requireAuth, asyncHandler(async (req, res) => {
const { name, phone, email, city, state, notes } = req.body;
const id = name.toLowerCase().replace(/&/g, 'and').replace(/'/g, '').replace(/\s+/g, '_');
await supabase.from('shippers').upsert({ id, name, phone: phone || null, email: email || null, city: city || 'Thiruvananthapuram', state: state || 'Kerala', notes: notes || null });
res.redirect('/shippers/' + encodeURIComponent(id));
}));
// POST /shippers/:id — Update shipper
router.post('/:id', requireAuth, asyncHandler(async (req, res) => {
const { name, phone, email, city, state, notes } = req.body;
await supabase.from('shippers').update({
name, phone: phone || null, email: email || null,
city: city || 'Thiruvananthapuram', state: state || 'Kerala', notes: notes || null,
}).eq('id', req.params.id);
res.redirect('/shippers/' + encodeURIComponent(req.params.id));
}));
module.exports = router;

View file

@ -0,0 +1,49 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security');
// GET /vehicles — List all vehicles
router.get('/', requireAuth, asyncHandler(async (req, res) => {
const { data: vehicles } = await supabase
.from('vehicles')
.select('*')
.order('number');
res.render('pages/vehicles/list', { vehicles: vehicles || [] });
}));
// GET /vehicles/:id — Vehicle detail with loads
router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
const { data: vehicle } = await supabase
.from('vehicles').select('*').eq('id', req.params.id).single();
if (!vehicle) return res.status(404).render('pages/404');
const { data: loads } = await supabase
.from('loads')
.select('*, shipper:shippers(name)')
.eq('vehicle_id', req.params.id)
.order('date', { ascending: false, nullsFirst: false });
res.render('pages/vehicles/detail', {
vehicle,
loads: loads || [],
});
}));
// POST /vehicles — Create/update vehicle
router.post('/', requireAuth, asyncHandler(async (req, res) => {
const { number, type, city, state, owner_name, owner_phone, is_active } = req.body;
const id = number.replace(/\s/g, '').toLowerCase();
await supabase.from('vehicles').upsert({
id, number: number.toUpperCase().replace(/\s/g, ''),
type: type || 'open', city: city || 'Thiruvananthapuram', state: state || 'Kerala',
owner_name: owner_name || null, owner_phone: owner_phone || null,
is_active: is_active === 'true' || is_active === 'on',
});
res.redirect('/vehicles/' + encodeURIComponent(id));
}));
module.exports = router;

251
webapp/src/server.js Normal file
View file

@ -0,0 +1,251 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const helmet = require('helmet');
const compression = require('compression');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const bcrypt = require('bcryptjs');
const config = require('./config/env');
const supabase = require('./services/supabase');
const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security');
const { requireAuth } = require('./middleware/auth');
const { formatINR, getStatusColor } = require('./lib/india');
const app = express();
// Trust proxy
app.set('trust proxy', 1);
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://unpkg.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://react.dev"],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
}));
app.use(compression());
app.use(requestLogger);
// Rate limiting
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests, please try again later.',
}));
// Body parsing
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(cookieParser());
// Static files
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
etag: true,
}));
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Session
app.use(session({
secret: config.session.secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.nodeEnv === 'production',
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
name: 'fd.sid',
}));
// CSRF
app.use(setupCSRF);
app.use(sanitizeBody);
// Make helpers available to all views
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
res.locals.appName = 'FreightDesk';
res.locals.appNameHi = 'फ्रेटडेस्क';
res.locals.formatINR = formatINR;
res.locals.getStatusColor = getStatusColor;
res.locals.year = new Date().getFullYear();
res.locals._csrf = req.session._csrf;
next();
});
// CSRF validation for POST/PUT/DELETE
app.use(validateCSRF);
// ============================================================
// AUTH ROUTES (public)
// ============================================================
app.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('pages/login', { error: null });
});
app.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('pages/login', { error: 'Username and password required' });
}
const { data: user } = await supabase
.from('portal_users')
.select('*')
.eq('username', username)
.eq('is_active', true)
.single();
if (!user) {
return res.render('pages/login', { error: 'Invalid username or password' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.render('pages/login', { error: 'Invalid username or password' });
}
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
entity_id: user.entity_id,
};
await supabase.from('portal_users').update({ last_login: new Date().toISOString() }).eq('id', user.id);
// Redirect based on role
if (user.role === 'admin') return res.redirect('/');
return res.redirect('/');
}));
app.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/login');
});
app.get('/setup', asyncHandler(async (req, res) => {
// Check if admin exists
const { count } = await supabase
.from('portal_users')
.select('*', { count: 'exact', head: true })
.eq('username', 'admin');
if (count > 0) {
return res.redirect('/login');
}
// Create default admin
const hash = await bcrypt.hash('admin123', 10);
await supabase.from('portal_users').insert({
username: 'admin',
password_hash: hash,
role: 'admin',
is_active: true,
});
res.send('<h1>Admin created!</h1><p>Username: <strong>admin</strong></p><p>Password: <strong>admin123</strong></p><p><a href="/login">Go to login</a></p>');
}));
// ============================================================
// API ROUTES (for React dashboard + WhatsApp parser)
// ============================================================
// WhatsApp parser API
app.post('/api/parse-whatsapp', requireAuth, asyncHandler(async (req, res) => {
const { parseWhatsAppMessage } = require('./services/parser');
const { message } = req.body;
if (!message) return res.json({ error: 'No message provided' });
const result = parseWhatsAppMessage(message);
res.json(result);
}));
// Dashboard stats API
app.get('/api/stats', requireAuth, asyncHandler(async (req, res) => {
const { data: loads } = await supabase.from('loads').select('*');
const allLoads = loads || [];
const monthly = {};
for (const l of allLoads) {
if (!l.date) continue;
const d = new Date(l.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthly[key]) monthly[key] = { freight: 0, commission: 0, count: 0 };
monthly[key].freight += l.freight_charged || 0;
monthly[key].commission += l.commission || 0;
monthly[key].count++;
}
res.json({
totalFreight: allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0),
totalCommission: allLoads.reduce((s, l) => s + (l.commission || 0), 0),
totalPending: allLoads.reduce((s, l) => s + (l.pending_from_shipper || 0), 0),
settledCount: allLoads.filter(l => ['settled', 'completed', 'commission received', 'reconciled'].includes(l.status)).length,
totalLoads: allLoads.length,
monthly: Object.entries(monthly).sort(([a], [b]) => a.localeCompare(b)),
});
}));
// ============================================================
// PAGE ROUTES (protected)
// ============================================================
app.use('/', require('./routes/dashboard'));
app.use('/loads', require('./routes/loads'));
app.use('/shippers', require('./routes/shippers'));
app.use('/vehicles', require('./routes/vehicles'));
app.use('/payments', require('./routes/payments'));
app.use('/reports', require('./routes/reports'));
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
// 404
app.use((req, res) => {
res.status(404);
res.render('pages/404');
});
// Error handler
app.use((err, req, res, next) => {
console.error(`[ERROR] ${req.method} ${req.url}:`, err.message);
if (config.nodeEnv === 'development') console.error(err.stack);
res.status(err.status || 500);
res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
});
const server = app.listen(config.port, '::', () => {
console.log(`\n🚛 FreightDesk running at http://localhost:${config.port}`);
console.log(` Environment: ${config.nodeEnv}`);
console.log(` Press Ctrl+C to stop\n`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
});
module.exports = app;

View file

@ -0,0 +1,190 @@
// WhatsApp message parser for FreightDesk
// Parses natural language freight messages into structured data
const { CITIES } = require('../config/constants');
// Known shipper names (from existing data)
const KNOWN_SHIPPERS = [
'Kahn Transport', 'Agarwal Packers and Movers', 'Agarwal', 'Sahara Packers',
'Ambika Packers', 'Century Polymers', 'DRS', 'Superstar', 'Superstar Packers',
'Chips', 'Chipps', 'Indian CBE', 'Indian CBE Shipper', 'KTC', 'ATC', 'TCI',
'Filatex', 'Dryfish John', 'Sun Packers', 'Thangavel', 'Nafees Alappuzha',
'Shivaprasad', 'Jinu Coin', 'Balmer Thuni', 'Pasupathy', 'Sulphi Baddest',
'KRS', 'Hirosh', 'Hirosh Roadways', 'Aero Rubber', 'Silverstar',
'DRS Agarwal', 'Gem', 'E20 Packers and Movers', 'CRT Transport',
'Mohamed Anas', 'Nair', 'Badadosth',
];
// Status keywords mapping
const STATUS_KEYWORDS = {
'pending lead': ['pending lead', 'lead', 'enquiry', 'enquiry'],
'assigned vehicle': ['assigned vehicle', 'vehicle assigned'],
'assigned': ['assigned', 'allotted'],
'loaded / in transit': ['loaded', 'in transit', 'on the way', 'dispatched', 'started'],
'delivered / pending collection': ['delivered', 'delivery done'],
'pending collection': ['pending collection', 'collection pending', 'to collect'],
'partially pending': ['partially pending', 'partial pending'],
'fully pending from shipper': ['fully pending', 'no payment'],
'settled': ['settled', 'complete', 'completed', 'closed'],
'commission received': ['commission received', 'comm received'],
'commission adjusted': ['commission adjusted', 'comm adjusted'],
'commission due': ['commission due', 'comm due'],
'reconciled': ['reconciled'],
'completed': ['completed', 'done'],
'handled directly by shipper': ['directly by shipper', 'handled directly'],
'available vehicle': ['available', 'vehicle available'],
'partial': ['partial'],
};
function parseWhatsAppMessage(text) {
const result = {
shipper: null,
vehicle: null,
from_city: null,
to_city: null,
via: null,
status: null,
freight_charged: null,
advance_received: null,
paid_to_driver: null,
commission: null,
driver_freight: null,
pending_from_shipper: null,
pending_to_driver: null,
notes: text,
confidence: 'low',
parsed_fields: [],
};
const lower = text.toLowerCase();
// 1. Parse shipper
for (const shipper of KNOWN_SHIPPERS) {
if (lower.includes(shipper.toLowerCase())) {
result.shipper = shipper;
result.parsed_fields.push('shipper');
break;
}
}
// 2. Parse vehicle number (Indian format: XX00XX0000)
const vehicleMatch = text.match(/\b([A-Z]{2}\s*\d{1,2}\s*[A-Z]{1,3}\s*\d{4})\b/i);
if (vehicleMatch) {
result.vehicle = vehicleMatch[1].replace(/\s/g, '').toUpperCase();
result.parsed_fields.push('vehicle');
}
// 3. Parse cities (from → to pattern)
const cityPattern = CITIES.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
const routeMatch = text.match(new RegExp(`(${cityPattern})\\s*(?:to|→|-|via)\\s*(${cityPattern})`, 'i'));
if (routeMatch) {
result.from_city = routeMatch[1];
result.to_city = routeMatch[2];
result.parsed_fields.push('from_city', 'to_city');
} else {
// Try to find any known city
for (const city of CITIES) {
if (lower.includes(city.toLowerCase())) {
if (!result.to_city) {
result.to_city = city;
result.parsed_fields.push('to_city');
} else if (!result.from_city) {
result.from_city = city;
result.parsed_fields.push('from_city');
}
}
}
}
// 4. Parse via
const viaMatch = text.match(/via\s+([A-Za-z\s,]+?)(?:\s*(?:to|→|-|loaded|freight|₹|\d{4,}))/i);
if (viaMatch) {
result.via = viaMatch[1].trim();
result.parsed_fields.push('via');
}
// 5. Parse status
for (const [status, keywords] of Object.entries(STATUS_KEYWORDS)) {
for (const kw of keywords) {
if (lower.includes(kw)) {
result.status = status;
result.parsed_fields.push('status');
break;
}
}
if (result.status) break;
}
// 6. Parse amounts
// Freight: look for "freight", "charged", "total" followed by number
const freightMatch = text.match(/(?:freight|charged|total|amount|bill)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
if (freightMatch) {
result.freight_charged = parseInt(freightMatch[1].replace(/,/g, ''));
result.parsed_fields.push('freight_charged');
} else {
// Try standalone large numbers (4-6 digits) that could be freight
const amountMatches = text.match(/₹?\s*(\d{4,6})\b/g);
if (amountMatches) {
const amounts = amountMatches.map(m => parseInt(m.replace(/[₹,\s]/g, '')));
if (amounts.length > 0) {
result.freight_charged = Math.max(...amounts);
result.parsed_fields.push('freight_charged');
}
}
}
// Advance received
const advanceMatch = text.match(/(?:advance|received|paid by shipper)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
if (advanceMatch) {
result.advance_received = parseInt(advanceMatch[1].replace(/,/g, ''));
result.parsed_fields.push('advance_received');
}
// Paid to driver
const driverPaidMatch = text.match(/(?:paid to driver|driver advance|driver paid|to driver)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
if (driverPaidMatch) {
result.paid_to_driver = parseInt(driverPaidMatch[1].replace(/,/g, ''));
result.parsed_fields.push('paid_to_driver');
}
// Commission
const commissionMatch = text.match(/(?:commission|comm)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
if (commissionMatch) {
result.commission = parseInt(commissionMatch[1].replace(/,/g, ''));
result.parsed_fields.push('commission');
}
// Driver freight
const driverFreightMatch = text.match(/(?:driver freight|driver rate|driver amount)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
if (driverFreightMatch) {
result.driver_freight = parseInt(driverFreightMatch[1].replace(/,/g, ''));
result.parsed_fields.push('driver_freight');
}
// Auto-calculate commission if not parsed
if (!result.commission && result.freight_charged && result.paid_to_driver) {
result.commission = result.freight_charged - result.paid_to_driver;
result.parsed_fields.push('commission (auto)');
}
// Auto-calculate pending from shipper
if (!result.pending_from_shipper && result.freight_charged) {
result.pending_from_shipper = result.freight_charged - (result.advance_received || 0);
if (result.pending_from_shipper > 0) result.parsed_fields.push('pending_from_shipper (auto)');
}
// Auto-calculate pending to driver
if (!result.pending_to_driver && result.driver_freight) {
result.pending_to_driver = result.driver_freight - (result.paid_to_driver || 0);
if (result.pending_to_driver > 0) result.parsed_fields.push('pending_to_driver (auto)');
}
// Confidence based on how many fields were parsed
const fieldCount = result.parsed_fields.length;
if (fieldCount >= 6) result.confidence = 'high';
else if (fieldCount >= 3) result.confidence = 'medium';
return result;
}
module.exports = { parseWhatsAppMessage, KNOWN_SHIPPERS };

View file

@ -0,0 +1,14 @@
const { createClient } = require('@supabase/supabase-js');
const config = require('../config/env');
const supabaseUrl = config.supabase.url;
const supabaseKey = config.supabase.key;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing SUPABASE_URL or SUPABASE_KEY. Check .env file.');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
module.exports = supabase;

View file

@ -0,0 +1,68 @@
<!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><%= typeof title !== 'undefined' ? title + ' — ' : '' %><%= appName %> · <%= appNameHi %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<% if (typeof extraCss !== 'undefined') { <% for (const css of extraCss) { %> <link rel="stylesheet" href="<%= css %>"> <% } %> <% } %>
</head>
<body>
<% if (typeof user !== 'undefined' && user) { %>
<nav class="topbar">
<div class="topbar-brand">
<div class="emblem">🇮🇳</div>
<div class="brand-text">
<span class="brand-hi"><%= appNameHi %></span>
<span class="brand-en"><%= appName %></span>
<span class="brand-tagline">भारत सरकार प्रायोजित · Govt. of India Initiative</span>
</div>
</div>
<div class="topbar-actions">
<button onclick="toggleTheme()" class="btn-icon" title="Toggle theme">&#9728;</button>
<span class="user-name">&#128100; <%= user.username %></span>
<a href="/logout" class="btn btn-sm btn-outline">Logout</a>
</div>
</nav>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-section">
<span class="sidebar-title">Main</span>
<a href="/" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'dashboard' ? 'active' : '' %>">&#128202; Dashboard</a>
<a href="/loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'loads' ? 'active' : "" %>">&#128666; Loads</a>
<a href="/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : "" %>">&#128176; Payments</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Contacts</span>
<a href="/shippers" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'shippers' ? 'active' : "" %>">&#127970; Shippers</a>
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : "" %>">&#128666; Vehicles</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Reports</span>
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : "" %>">&#128200; Reports</a>
</div>
</aside>
<main class="content">
<%- content %>
</main>
</div>
<footer class="govt-footer">
<div class="footer-tricolor"><span></span><span></span><span></span></div>
<p>This is an official platform under the <strong>Ministry of Road Transport &amp; Highways</strong>, Government of India initiative.</p>
<p class="footer-muted">&copy; <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p>
</footer>
<% } else { %>
<%- content %>
<% } %>
<script src="/js/app.js"></script>
<% if (typeof extraJs !== 'undefined') { <% for (const js of extraJs) { %><script src="<%= js %>"></script><% } %> <% } %>
</body>
</html>

View file

@ -0,0 +1,9 @@
<%
// Simple layout helper — pages set these variables before including
var _body = '';
var _title = '';
var _activeMenu = '';
var _extraJs = [];
%>
<%- include('../layouts/main') %>

View file

@ -0,0 +1,18 @@
<!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>403 - <%= appName %></title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="error-page">
<div class="error-code">403</div>
<h1>&#2346;&#2371;&#2344;&#2352;&#2366;&#2357;&#2375;&#2358;&#2344; &#2344;&#2367;&#2359;&#2375;&#2342;&#2364; / Forbidden</h1>
<p>You don't have permission to access this page.</p>
<a href="/" class="btn btn-primary mt-4">Go to Dashboard</a>
</div>
</body>
</html>

View file

@ -0,0 +1,32 @@
<% var _title = '404 Not Found'; %>
<!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>404 - <%= appName %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-brand">
<div class="emblem">&#127760;</div>
<div class="brand-text">
<span class="brand-hi"><%= appNameHi %></span>
<span class="brand-en"><%= appName %></span>
</div>
</div>
<div class="topbar-actions">
<span class="user-name">&#128100; <%= user ? user.username : '' %></span>
</div>
</nav>
<div class="error-page">
<div class="error-code">404</div>
<h1>&#2352;&#2367;&#2325;&#2381;&#2340; &#2346;&#2375;&#2332; / Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn btn-primary mt-4">Go to Dashboard</a>
</div>
</body>
</html>

View file

@ -0,0 +1,28 @@
<!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>500 - <%= appName %></title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-brand">
<div class="emblem">&#127760;</div>
<div class="brand-text">
<span class="brand-hi"><%= appNameHi %></span>
<span class="brand-en"><%= appName %></span>
</div>
</div>
</nav>
<div class="error-page">
<div class="error-code">500</div>
<h1>&#2360;&#2352;&#2381;&#2357;&#2352; &#2340;&#2381;&#2352;&#2369;&#2335;&#2367; / Server Error</h1>
<p>Something went wrong. Please try again later.</p>
<% if (typeof error !== 'undefined' && error) { %><p class="text-muted"><%= error %></p><% } %>
<a href="/" class="btn btn-primary mt-4">Go to Dashboard</a>
</div>
</body>
</html>

View file

@ -0,0 +1,132 @@
<!-- Dashboard Page -->
<%- include('../partials/header', { activeMenu: 'dashboard' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128202; Dashboard</h1>
<p class="page-subtitle">Welcome back, <%= user.username %>! Here's your business overview.</p>
</div>
<a href="/loads/new" class="btn btn-primary">+ New Load</a>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card stat-primary">
<div class="stat-icon">&#128176;</div>
<div class="stat-info">
<span class="stat-value"><%= formatINR(stats.totalFreight) %></span>
<span class="stat-label">Total Freight</span>
</div>
</div>
<div class="stat-card stat-success">
<div class="stat-icon">&#9989;</div>
<div class="stat-info">
<span class="stat-value"><%= formatINR(stats.totalCommission) %></span>
<span class="stat-label">Commission Earned</span>
</div>
</div>
<div class="stat-card stat-warning">
<div class="stat-icon">&#9200;</div>
<div class="stat-info">
<span class="stat-value"><%= formatINR(stats.totalPendingShipper) %></span>
<span class="stat-label">Pending Collection</span>
</div>
</div>
<div class="stat-card stat-info">
<div class="stat-icon">&#128666;</div>
<div class="stat-info">
<span class="stat-value"><%= stats.totalLoads %></span>
<span class="stat-label">Total Loads (<%= stats.settledCount %> settled)</span>
</div>
</div>
</div>
<div class="grid-2">
<!-- Recent Loads -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Recent Loads</h3>
<a href="/loads" class="btn btn-sm btn-outline">View All</a>
</div>
<div class="card-body">
<% if (recentLoads.length === 0) { %>
<p class="empty-state">No loads yet. <a href="/loads/new">Add your first load</a></p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Route</th>
<th>Freight</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<% for (const load of recentLoads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.from_city || '?' %> &#8594; <%= load.to_city || '?' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<!-- Pending Collections -->
<div class="card">
<div class="card-header">
<h3 class="card-title">&#9200; Pending Collections</h3>
</div>
<div class="card-body">
<% if (pendingCollection.length === 0) { %>
<p class="empty-state">No pending collections. Great job!</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Shipper</th>
<th>Route</th>
<th>Pending</th>
</tr>
</thead>
<tbody>
<% for (const load of pendingCollection) { %>
<tr>
<td><%= load.shipper_id || '—' %></td>
<td><%= load.from_city || '?' %> &#8594; <%= load.to_city || '?' %></td>
<td class="text-danger"><%= formatINR(load.pending_from_shipper) %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
</div>
<!-- Status Breakdown -->
<div class="card mt-4">
<div class="card-header">
<h3 class="card-title">Status Breakdown</h3>
</div>
<div class="card-body">
<div class="status-grid">
<% for (const [status, count] of Object.entries(statusCounts)) { %>
<div class="status-item">
<span class="badge badge-<%= getStatusColor(status) %>"><%= count %></span>
<span class="status-label"><%= status %></span>
</div>
<% } %>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,111 @@
<%- include('../partials/header', { activeMenu: 'loads' }) %>
<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="/loads/<%= encodeURIComponent(load.id) %>/edit" class="btn btn-primary">Edit</a>
<a href="/loads" class="btn btn-outline">&larr; Back</a>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">Load Info</h3></div>
<div class="card-body">
<dl class="detail-list">
<dt>Date</dt><dd><%= load.date || '—' %></dd>
<dt>Shipper</dt><dd><%= load.shipper ? load.shipper.name : (load.shipper_id || '—') %></dd>
<dt>Route</dt><dd><%= load.from_city || '?' %> &#8594; <%= load.via ? load.via + ' &#8594; ' : '' %><%= load.to_city || '?' %></dd>
<dt>Vehicle</dt><dd><%= load.vehicle ? load.vehicle.number : '—' %></dd>
<dt>Load Type</dt><dd><%= load.load_type || '—' %></dd>
<dt>Item</dt><dd><%= load.item || '—' %></dd>
<dt>Status</dt><dd><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Financials</h3></div>
<div class="card-body">
<dl class="detail-list">
<dt>Freight Charged</dt><dd class="text-bold"><%= formatINR(load.freight_charged) %></dd>
<dt>Advance Received</dt><dd><%= formatINR(load.advance_received) %></dd>
<dt>Paid to Driver</dt><dd><%= formatINR(load.paid_to_driver) %></dd>
<dt>Driver Freight</dt><dd><%= formatINR(load.driver_freight) %></dd>
<dt>Commission</dt><dd class="text-success text-bold"><%= formatINR(load.commission) %></dd>
<dt>Pending from Shipper</dt><dd class="text-danger"><%= formatINR(load.pending_from_shipper) %></dd>
<dt>Pending to Driver</dt><dd class="text-warning"><%= formatINR(load.pending_to_driver) %></dd>
</dl>
</div>
</div>
</div>
<% if (load.notes) { %>
<div class="card mt-4">
<div class="card-header"><h3 class="card-title">Notes</h3></div>
<div class="card-body"><p style="white-space: pre-wrap;"><%= load.notes %></p></div>
</div>
<% } %>
<!-- Payments -->
<div class="card mt-4">
<div class="card-header">
<h3 class="card-title">Payment History</h3>
</div>
<div class="card-body">
<% if (payments.length === 0) { %>
<p class="empty-state">No payments recorded yet.</p>
<% } else { %>
<table class="table">
<thead><tr><th>Date</th><th>Type</th><th>Direction</th><th>Amount</th><th>Method</th><th>Notes</th></tr></thead>
<tbody>
<% for (const p of payments) { %>
<tr>
<td><%= p.payment_date || '—' %></td>
<td><%= p.type %></td>
<td><%= p.direction === 'in' ? '&#11014; In' : '&#11015; Out' %></td>
<td><%= formatINR(p.amount) %></td>
<td><%= p.method %></td>
<td><%= p.notes || '' %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<!-- Add Payment Form -->
<h4 class="section-title">Record Payment</h4>
<form method="POST" action="/payments" class="form-row">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<input type="hidden" name="load_id" value="<%= load.id %>">
<div class="form-group">
<select name="type" class="form-input">
<option value="advance">Advance</option>
<option value="balance">Balance</option>
<option value="commission">Commission</option>
<option value="driver_payment">Driver Payment</option>
</select>
</div>
<div class="form-group">
<select name="direction" class="form-input">
<option value="in">In (Received)</option>
<option value="out">Out (Paid)</option>
</select>
</div>
<div class="form-group">
<input type="number" name="amount" class="form-input" placeholder="Amount" required>
</div>
<div class="form-group">
<input type="date" name="payment_date" class="form-input">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,234 @@
<%- include('../partials/header', { activeMenu: 'loads' }) %>
<div class="page-header">
<div>
<h1 class="page-title"><%= isEdit ? 'Edit Load' : '+ New Load' %></h1>
<p class="page-subtitle"><%= isEdit ? 'Update load details' : 'Add a new freight load' %></p>
</div>
<a href="/loads" class="btn btn-outline">&larr; Back to Loads</a>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<div class="grid-2">
<!-- Form -->
<div class="card">
<div class="card-header"><h3 class="card-title">Load Details</h3></div>
<div class="card-body">
<form method="POST" action="<%= isEdit ? '/loads/' + encodeURIComponent(load.id) : '/loads' %>" id="loadForm">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-row">
<div class="form-group">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-input" value="<%= load.date || '' %>">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-input">
<% for (const s of LOAD_STATUSES) { %>
<option value="<%= s %>" <%= load.status === s ? 'selected' : '' %>><%= s %></option>
<% } %>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Shipper</label>
<select name="shipper_id" class="form-input">
<option value="">— Select —</option>
<% for (const s of shippers) { %>
<option value="<%= s.id %>" <%= load.shipper_id === s.id ? 'selected' : '' %>><%= s.name %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">Vehicle</label>
<select name="vehicle_id" class="form-input">
<option value="">— Select —</option>
<% for (const v of vehicles) { %>
<option value="<%= v.id %>" <%= load.vehicle_id === v.id ? 'selected' : '' %>><%= v.number %></option>
<% } %>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">From City</label>
<input type="text" name="from_city" class="form-input" list="cities" value="<%= load.from_city || '' %>" placeholder="Origin">
</div>
<div class="form-group">
<label class="form-label">Via</label>
<input type="text" name="via" class="form-input" value="<%= load.via || '' %>" placeholder="Via (optional)">
</div>
<div class="form-group">
<label class="form-label">To City</label>
<input type="text" name="to_city" class="form-input" list="cities" value="<%= load.to_city || '' %>" placeholder="Destination">
</div>
</div>
<datalist id="cities">
<% for (const c of CITIES) { %><option value="<%= c %>"><% } %>
</datalist>
<div class="form-row">
<div class="form-group">
<label class="form-label">Load Type</label>
<select name="load_type" class="form-input">
<option value="">— Select —</option>
<% for (const t of LOAD_TYPES) { %>
<option value="<%= t %>" <%= load.load_type === t ? 'selected' : '' %>><%= t %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">Item</label>
<input type="text" name="item" class="form-input" value="<%= load.item || '' %>" placeholder="Goods description">
</div>
</div>
<h4 class="section-title">Financials</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Freight Charged (&#8377;)</label>
<input type="number" name="freight_charged" class="form-input" value="<%= load.freight_charged || '' %>" placeholder="0" id="freight_charged">
</div>
<div class="form-group">
<label class="form-label">Advance Received (&#8377;)</label>
<input type="number" name="advance_received" class="form-input" value="<%= load.advance_received || '' %>" placeholder="0" id="advance_received">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Paid to Driver (&#8377;)</label>
<input type="number" name="paid_to_driver" class="form-input" value="<%= load.paid_to_driver || '' %>" placeholder="0" id="paid_to_driver">
</div>
<div class="form-group">
<label class="form-label">Driver Freight (&#8377;)</label>
<input type="number" name="driver_freight" class="form-input" value="<%= load.driver_freight || '' %>" placeholder="0" id="driver_freight">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Commission (&#8377;)</label>
<input type="number" name="commission" class="form-input" value="<%= load.commission || '' %>" placeholder="Auto-calculated" id="commission">
</div>
<div class="form-group">
<label class="form-label">Pending from Shipper (&#8377;)</label>
<input type="number" name="pending_from_shipper" class="form-input" value="<%= load.pending_from_shipper || '' %>" placeholder="Auto-calculated" id="pending_from_shipper">
</div>
</div>
<div class="form-group">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-input" rows="3" placeholder="Additional notes..."><%= load.notes || '' %></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><%= isEdit ? 'Update Load' : 'Save Load' %></button>
<a href="/loads" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
</div>
<!-- WhatsApp Parser -->
<div class="card">
<div class="card-header"><h3 class="card-title">&#128241; WhatsApp Parser</h3></div>
<div class="card-body">
<p class="text-muted">Paste a WhatsApp message to auto-fill the form.</p>
<div class="form-group">
<textarea id="whatsappInput" class="form-input" rows="4" placeholder='e.g. "Agarwal Bangalore TN39DV8142 loaded 19000 freight driver advance 15900"'></textarea>
</div>
<button type="button" class="btn btn-secondary" onclick="parseWhatsApp()">Parse Message</button>
<div id="parseResult" class="mt-3" style="display:none;">
<div class="parse-result">
<h4>Parsed Fields <span id="parseConfidence" class="badge"></span></h4>
<div id="parseFields"></div>
<button type="button" class="btn btn-primary mt-2" onclick="applyParsed()">Apply to Form</button>
</div>
</div>
</div>
</div>
</div>
<% if (isEdit) { %>
<div class="mt-4">
<form method="POST" action="/loads/<%= encodeURIComponent(load.id) %>/delete" onsubmit="return confirm('Delete this load?')">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<button type="submit" class="btn btn-danger">Delete Load</button>
</form>
</div>
<% } %>
<script>
let parsedData = null;
async function parseWhatsApp() {
const msg = document.getElementById('whatsappInput').value;
if (!msg.trim()) return;
const res = await fetch('/api/parse-whatsapp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg })
});
parsedData = await res.json();
const resultDiv = document.getElementById('parseResult');
const fieldsDiv = document.getElementById('parseFields');
const confidenceSpan = document.getElementById('parseConfidence');
resultDiv.style.display = 'block';
confidenceSpan.textContent = parsedData.confidence + ' confidence';
confidenceSpan.className = 'badge badge-' + (parsedData.confidence === 'high' ? 'success' : parsedData.confidence === 'medium' ? 'warning' : 'gray');
const fieldLabels = {
shipper: 'Shipper', vehicle: 'Vehicle', from_city: 'From', to_city: 'To',
via: 'Via', status: 'Status', freight_charged: 'Freight', advance_received: 'Advance',
paid_to_driver: 'Paid to Driver', commission: 'Commission', driver_freight: 'Driver Freight',
pending_from_shipper: 'Pending (Shipper)', pending_to_driver: 'Pending (Driver)'
};
let html = '<div class="parse-fields">';
for (const [key, value] of Object.entries(parsedData)) {
if (key === 'confidence' || key === 'parsed_fields' || key === 'notes' || value === null || value === undefined) continue;
html += '<div class="parse-field"><span class="parse-key">' + (fieldLabels[key] || key) + ':</span> <span class="parse-val">' + value + '</span></div>';
}
html += '</div>';
fieldsDiv.innerHTML = html;
}
function applyParsed() {
if (!parsedData) return;
const map = {
shipper_id: parsedData.shipper,
vehicle_id: parsedData.vehicle,
from_city: parsedData.from_city,
to_city: parsedData.to_city,
via: parsedData.via,
status: parsedData.status,
freight_charged: parsedData.freight_charged,
advance_received: parsedData.advance_received,
paid_to_driver: parsedData.paid_to_driver,
commission: parsedData.commission,
driver_freight: parsedData.driver_freight,
pending_from_shipper: parsedData.pending_from_shipper,
pending_to_driver: parsedData.pending_to_driver,
};
for (const [field, value] of Object.entries(map)) {
if (value === null || value === undefined) continue;
const el = document.querySelector('[name="' + field + '"]');
if (el) el.value = value;
}
}
</script>
<%- include('../partials/footer', { extraJs: [] }) %>

View file

@ -0,0 +1,81 @@
<%- include('../partials/header', { activeMenu: 'loads' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Loads</h1>
<p class="page-subtitle">Manage all your freight loads</p>
</div>
<div class="page-actions">
<a href="/loads/new" class="btn btn-primary">+ New Load</a>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/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 LOAD_STATUSES) { %>
<option value="<%= s %>" <%= filters.status === s ? 'selected' : '' %>><%= s %></option>
<% } %>
</select>
</div>
<div class="form-group">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="City, notes..." value="<%= filters.search || '' %>">
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary">Filter</button>
</div>
</form>
</div>
</div>
<!-- Loads Table -->
<div class="card">
<div class="card-body">
<% if (loads.length === 0) { %>
<p class="empty-state">No loads found. <a href="/loads/new">Add your first load</a></p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Shipper</th>
<th>Route</th>
<th>Vehicle</th>
<th>Freight</th>
<th>Commission</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% for (const load of loads) { %>
<tr>
<td><%= load.date || '—' %></td>
<td><%= load.shipper ? load.shipper.name : (load.shipper_id || '—') %></td>
<td><%= load.from_city || '?' %> &#8594; <%= load.to_city || '?' %></td>
<td><%= load.vehicle ? load.vehicle.number : '—' %></td>
<td><%= formatINR(load.freight_charged) %></td>
<td><%= formatINR(load.commission) %></td>
<td><span class="badge badge-<%= getStatusColor(load.status) %>"><%= load.status %></span></td>
<td>
<a href="/loads/<%= encodeURIComponent(load.id) %>" class="btn btn-sm btn-outline">View</a>
<a href="/loads/<%= encodeURIComponent(load.id) %>/edit" class="btn btn-sm btn-outline">Edit</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,48 @@
<!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><%= typeof title !== 'undefined' ? title + ' — ' : '' %><%= appName %> · <%= appNameHi %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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"><%= appName %></h2>
<p class="login-tagline">भारत सरकार प्रायोजित माल परिवहन मंच</p>
<p class="login-tagline-en">Govt. of India Sponsored Freight Platform</p>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form method="POST" action="/login" class="login-form">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-input" required autofocus placeholder="Enter username">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" required placeholder="Enter password">
</div>
<button type="submit" class="btn btn-primary btn-block">🔐 Login</button>
</form>
<div class="login-footer">
<div class="footer-tricolor"><span></span><span></span><span></span></div>
<p>Secured by Government of India</p>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,33 @@
<%- include('../partials/header', { activeMenu: 'payments' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128176; Payments</h1>
<p class="page-subtitle">Track all payment transactions</p>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Date</th><th>Type</th><th>Direction</th><th>Amount</th><th>Method</th><th>Notes</th><th>Load</th></tr></thead>
<tbody>
<% for (const p of payments) { %>
<tr>
<td><%= p.payment_date || '—' %></td>
<td><%= p.type %></td>
<td><span class="badge badge-<%= p.direction === 'in' ? 'success' : 'danger' %>"><%= p.direction === 'in' ? 'In' : 'Out' %></span></td>
<td><%= formatINR(p.amount) %></td>
<td><%= p.method %></td>
<td><%= p.notes || '' %></td>
<td><%= p.load ? (p.load.from_city + ' → ' + p.load.to_city) : '—' %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,75 @@
<%- include('../partials/header', { activeMenu: 'reports' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128200; Reports</h1>
<p class="page-subtitle">Business analytics and insights</p>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Monthly Summary</h3></div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Month</th><th>Loads</th><th>Freight</th><th>Commission</th><th>Pending</th></tr></thead>
<tbody>
<% for (const [key, m] of monthly) { %>
<tr>
<td><strong><%= m.label %></strong></td>
<td><%= m.count %></td>
<td><%= formatINR(m.freight) %></td>
<td class="text-success"><%= formatINR(m.commission) %></td>
<td class="text-danger"><%= formatINR(m.pending) %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<div class="grid-2 mt-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Top Shippers</h3></div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Shipper</th><th>Loads</th><th>Freight</th><th>Commission</th></tr></thead>
<tbody>
<% for (const s of shippers) { %>
<tr>
<td><a href="/shippers/<%= encodeURIComponent(s.id) %>"><%= s.name %></a></td>
<td><%= s.load_count || 0 %></td>
<td><%= formatINR(s.total_freight) %></td>
<td class="text-success"><%= formatINR(s.total_commission) %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Top Routes</h3></div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Route</th><th>Loads</th><th>Commission</th></tr></thead>
<tbody>
<% for (const r of routes) { %>
<tr>
<td><%= r.route %></td>
<td><%= r.count %></td>
<td class="text-success"><%= formatINR(r.total_commission) %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,49 @@
<%- include('../partials/header', { activeMenu: 'shippers' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#127970; <%= shipper.name %></h1>
<p class="page-subtitle"><%= shipper.city || '' %><%= shipper.state ? ', ' + shipper.state : '' %></p>
</div>
<a href="/shippers" class="btn btn-outline">&larr; Back</a>
</div>
<div class="stats-grid">
<div class="stat-card stat-primary">
<div class="stat-info"><span class="stat-value"><%= formatINR(shipper.total_freight) %></span><span class="stat-label">Total Freight</span></div>
</div>
<div class="stat-card stat-success">
<div class="stat-info"><span class="stat-value"><%= formatINR(shipper.total_commission) %></span><span class="stat-label">Commission</span></div>
</div>
<div class="stat-card stat-warning">
<div class="stat-info"><span class="stat-value"><%= formatINR(shipper.pending_amount) %></span><span class="stat-label">Pending</span></div>
</div>
<div class="stat-card stat-info">
<div class="stat-info"><span class="stat-value"><%= loads.length %></span><span class="stat-label">Loads</span></div>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h3 class="card-title">All Loads</h3></div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Date</th><th>Route</th><th>Vehicle</th><th>Freight</th><th>Commission</th><th>Status</th></tr></thead>
<tbody>
<% for (const l of loads) { %>
<tr>
<td><%= l.date || '—' %></td>
<td><%= l.from_city || '?' %> &#8594; <%= l.to_city || '?' %></td>
<td><%= l.vehicle ? l.vehicle.number : '—' %></td>
<td><%= formatINR(l.freight_charged) %></td>
<td><%= formatINR(l.commission) %></td>
<td><span class="badge badge-<%= getStatusColor(l.status) %>"><%= l.status %></span></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,65 @@
<%- include('../partials/header', { activeMenu: 'shippers' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#127970; Shippers</h1>
<p class="page-subtitle">Manage your shipper contacts</p>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>City</th>
<th>Loads</th>
<th>Total Freight</th>
<th>Commission</th>
<th>Pending</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% for (const s of shippers) { %>
<tr>
<td><strong><%= s.name %></strong></td>
<td><%= s.city || '—' %></td>
<td><%= s.load_count || 0 %></td>
<td><%= formatINR(s.total_freight) %></td>
<td class="text-success"><%= formatINR(s.total_commission) %></td>
<td class="text-danger"><%= formatINR(s.pending_amount) %></td>
<td><a href="/shippers/<%= encodeURIComponent(s.id) %>" class="btn btn-sm btn-outline">View</a></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add Shipper -->
<div class="card mt-4">
<div class="card-header"><h3 class="card-title">Add New Shipper</h3></div>
<div class="card-body">
<form method="POST" action="/shippers" class="form-row">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-group">
<input type="text" name="name" class="form-input" placeholder="Name" required>
</div>
<div class="form-group">
<input type="text" name="phone" class="form-input" placeholder="Phone">
</div>
<div class="form-group">
<input type="text" name="city" class="form-input" placeholder="City">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,33 @@
<%- include('../partials/header', { activeMenu: 'vehicles' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; <%= vehicle.number %></h1>
<p class="page-subtitle"><%= vehicle.type || '—' %> &#124; <%= vehicle.city || '—' %></p>
</div>
<a href="/vehicles" class="btn btn-outline">&larr; Back</a>
</div>
<div class="card mt-4">
<div class="card-header"><h3 class="card-title">Load History</h3></div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Date</th><th>Shipper</th><th>Route</th><th>Freight</th><th>Status</th></tr></thead>
<tbody>
<% for (const l of loads) { %>
<tr>
<td><%= l.date || '—' %></td>
<td><%= l.shipper ? l.shipper.name : '—' %></td>
<td><%= l.from_city || '?' %> &#8594; <%= l.to_city || '?' %></td>
<td><%= formatINR(l.freight_charged) %></td>
<td><span class="badge badge-<%= getStatusColor(l.status) %>"><%= l.status %></span></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,56 @@
<%- include('../partials/header', { activeMenu: 'vehicles' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Vehicles</h1>
<p class="page-subtitle">Manage your vehicle fleet</p>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead><tr><th>Number</th><th>Type</th><th>City</th><th>Active</th><th>Actions</th></tr></thead>
<tbody>
<% for (const v of vehicles) { %>
<tr>
<td><strong><%= v.number %></strong></td>
<td><%= v.type || '—' %></td>
<td><%= v.city || '—' %></td>
<td><span class="badge badge-<%= v.is_active ? 'success' : 'gray' %>"><%= v.is_active ? 'Active' : 'Inactive' %></span></td>
<td><a href="/vehicles/<%= encodeURIComponent(v.id) %>" class="btn btn-sm btn-outline">View</a></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h3 class="card-title">Add Vehicle</h3></div>
<div class="card-body">
<form method="POST" action="/vehicles" class="form-row">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-group">
<input type="text" name="number" class="form-input" placeholder="Vehicle Number (e.g. MH12AB1234)" required>
</div>
<div class="form-group">
<select name="type" class="form-input">
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="container">Container</option>
</select>
</div>
<div class="form-group">
<input type="text" name="city" class="form-input" placeholder="City">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
<%- include('../partials/footer') %>

View file

@ -0,0 +1,17 @@
</main>
</div>
<footer class="govt-footer">
<div class="footer-tricolor"><span></span><span></span><span></span></div>
<p>This is an official platform under the <strong>Ministry of Road Transport &amp; Highways</strong>, Government of India initiative.</p>
<p class="footer-muted">&copy; <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p>
</footer>
<script src="/js/app.js"></script>
<% if (typeof extraJs !== 'undefined') { %>
<% for (const js of extraJs) { %>
<script src="<%= js %>"></script>
<% } %>
<% } %>
</body>
</html>

View file

@ -0,0 +1,49 @@
<!-- Header Partial -->
<!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><%= typeof title !== 'undefined' ? title + ' — ' : '' %><%= appName %> · <%= appNameHi %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>
<nav class="topbar">
<div class="topbar-brand">
<div class="emblem">&#127760;</div>
<div class="brand-text">
<span class="brand-hi"><%= appNameHi %></span>
<span class="brand-en"><%= appName %></span>
<span class="brand-tagline">&#2349;&#2366;&#2352;&#2340; &#2360;&#2352;&#2325;&#2366;&#2352; &#2346;&#2381;&#2352;&#2366;&#2351;&#2379;&#2332;&#2367;&#2340; &middot; Govt. of India Initiative</span>
</div>
</div>
<div class="topbar-actions">
<button onclick="toggleTheme()" class="btn-icon" title="Toggle theme">&#9728;</button>
<span class="user-name">&#128100; <%= user.username %></span>
<a href="/logout" class="btn btn-sm btn-outline">Logout</a>
</div>
</nav>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-section">
<span class="sidebar-title">Main</span>
<a href="/" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'dashboard' ? 'active' : '' %>">&#128202; Dashboard</a>
<a href="/loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'loads' ? 'active' : '' %>">&#128666; Loads</a>
<a href="/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : '' %>">&#128176; Payments</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Contacts</span>
<a href="/shippers" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'shippers' ? 'active' : '' %>">&#127970; Shippers</a>
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : '' %>">&#128666; Vehicles</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Reports</span>
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">&#128200; Reports</a>
</div>
</aside>
<main class="content">