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:
commit
1a4eaaa040
42 changed files with 6288 additions and 0 deletions
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal 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
|
||||||
175
supabase/migrations/001_initial_schema.sql
Normal file
175
supabase/migrations/001_initial_schema.sql
Normal 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', '');
|
||||||
19
supabase/migrations/002_seed_data.sql
Normal file
19
supabase/migrations/002_seed_data.sql
Normal 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
2779
supabase/seed_data.json
Normal file
File diff suppressed because it is too large
Load diff
9
webapp/.env.example
Normal file
9
webapp/.env.example
Normal 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
24
webapp/Dockerfile
Normal 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
25
webapp/package.json
Normal 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
122
webapp/seed.js
Normal 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();
|
||||||
52
webapp/src/config/constants.js
Normal file
52
webapp/src/config/constants.js
Normal 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
13
webapp/src/config/env.js
Normal 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
92
webapp/src/lib/india.js
Normal 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,
|
||||||
|
};
|
||||||
30
webapp/src/middleware/auth.js
Normal file
30
webapp/src/middleware/auth.js
Normal 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 };
|
||||||
62
webapp/src/middleware/security.js
Normal file
62
webapp/src/middleware/security.js
Normal 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, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/\//g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
767
webapp/src/public/css/style.css
Normal file
767
webapp/src/public/css/style.css
Normal 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; }
|
||||||
59
webapp/src/public/js/app.js
Normal file
59
webapp/src/public/js/app.js
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
67
webapp/src/routes/dashboard.js
Normal file
67
webapp/src/routes/dashboard.js
Normal 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
187
webapp/src/routes/loads.js
Normal 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;
|
||||||
37
webapp/src/routes/payments.js
Normal file
37
webapp/src/routes/payments.js
Normal 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;
|
||||||
50
webapp/src/routes/reports.js
Normal file
50
webapp/src/routes/reports.js
Normal 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;
|
||||||
54
webapp/src/routes/shippers.js
Normal file
54
webapp/src/routes/shippers.js
Normal 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;
|
||||||
49
webapp/src/routes/vehicles.js
Normal file
49
webapp/src/routes/vehicles.js
Normal 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
251
webapp/src/server.js
Normal 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;
|
||||||
190
webapp/src/services/parser.js
Normal file
190
webapp/src/services/parser.js
Normal 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 };
|
||||||
14
webapp/src/services/supabase.js
Normal file
14
webapp/src/services/supabase.js
Normal 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;
|
||||||
68
webapp/src/views/layouts/main.ejs
Normal file
68
webapp/src/views/layouts/main.ejs
Normal 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">☀</button>
|
||||||
|
<span class="user-name">👤 <%= 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' : '' %>">📊 Dashboard</a>
|
||||||
|
<a href="/loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'loads' ? 'active' : "" %>">🚚 Loads</a>
|
||||||
|
<a href="/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : "" %>">💰 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' : "" %>">🏢 Shippers</a>
|
||||||
|
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : "" %>">🚚 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' : "" %>">📈 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 & Highways</strong>, Government of India initiative.</p>
|
||||||
|
<p class="footer-muted">© <%= 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>
|
||||||
9
webapp/src/views/layouts/page.ejs
Normal file
9
webapp/src/views/layouts/page.ejs
Normal 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') %>
|
||||||
18
webapp/src/views/pages/403.ejs
Normal file
18
webapp/src/views/pages/403.ejs
Normal 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>पृनरावेशन निषेद़ / 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>
|
||||||
32
webapp/src/views/pages/404.ejs
Normal file
32
webapp/src/views/pages/404.ejs
Normal 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">🌐</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">👤 <%= user ? user.username : '' %></span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-code">404</div>
|
||||||
|
<h1>रिक्त पेज / 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>
|
||||||
28
webapp/src/views/pages/500.ejs
Normal file
28
webapp/src/views/pages/500.ejs
Normal 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">🌐</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>सर्वर त्रुटि / 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>
|
||||||
132
webapp/src/views/pages/dashboard.ejs
Normal file
132
webapp/src/views/pages/dashboard.ejs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<%- include('../partials/header', { activeMenu: 'dashboard' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">📊 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">💰</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">✅</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">⏰</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">🚚</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 || '?' %> → <%= 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">⏰ 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 || '?' %> → <%= 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') %>
|
||||||
111
webapp/src/views/pages/loads/detail.ejs
Normal file
111
webapp/src/views/pages/loads/detail.ejs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'loads' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 Load Detail</h1>
|
||||||
|
<p class="page-subtitle"><%= load.id %></p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a href="/loads/<%= encodeURIComponent(load.id) %>/edit" class="btn btn-primary">Edit</a>
|
||||||
|
<a href="/loads" class="btn btn-outline">← 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 || '?' %> → <%= load.via ? load.via + ' → ' : '' %><%= 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' ? '⬆ In' : '⬇ 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') %>
|
||||||
234
webapp/src/views/pages/loads/form.ejs
Normal file
234
webapp/src/views/pages/loads/form.ejs
Normal 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">← 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 (₹)</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 (₹)</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 (₹)</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 (₹)</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 (₹)</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 (₹)</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">📱 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: [] }) %>
|
||||||
81
webapp/src/views/pages/loads/list.ejs
Normal file
81
webapp/src/views/pages/loads/list.ejs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'loads' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 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"> </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 || '?' %> → <%= 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') %>
|
||||||
48
webapp/src/views/pages/login.ejs
Normal file
48
webapp/src/views/pages/login.ejs
Normal 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>
|
||||||
33
webapp/src/views/pages/payments/list.ejs
Normal file
33
webapp/src/views/pages/payments/list.ejs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'payments' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">💰 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') %>
|
||||||
75
webapp/src/views/pages/reports/index.ejs
Normal file
75
webapp/src/views/pages/reports/index.ejs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'reports' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">📈 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') %>
|
||||||
49
webapp/src/views/pages/shippers/detail.ejs
Normal file
49
webapp/src/views/pages/shippers/detail.ejs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'shippers' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🏢 <%= shipper.name %></h1>
|
||||||
|
<p class="page-subtitle"><%= shipper.city || '' %><%= shipper.state ? ', ' + shipper.state : '' %></p>
|
||||||
|
</div>
|
||||||
|
<a href="/shippers" class="btn btn-outline">← 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 || '?' %> → <%= 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') %>
|
||||||
65
webapp/src/views/pages/shippers/list.ejs
Normal file
65
webapp/src/views/pages/shippers/list.ejs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'shippers' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🏢 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') %>
|
||||||
33
webapp/src/views/pages/vehicles/detail.ejs
Normal file
33
webapp/src/views/pages/vehicles/detail.ejs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'vehicles' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 <%= vehicle.number %></h1>
|
||||||
|
<p class="page-subtitle"><%= vehicle.type || '—' %> | <%= vehicle.city || '—' %></p>
|
||||||
|
</div>
|
||||||
|
<a href="/vehicles" class="btn btn-outline">← 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 || '?' %> → <%= 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') %>
|
||||||
56
webapp/src/views/pages/vehicles/list.ejs
Normal file
56
webapp/src/views/pages/vehicles/list.ejs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<%- include('../partials/header', { activeMenu: 'vehicles' }) %>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">🚚 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') %>
|
||||||
17
webapp/src/views/partials/footer.ejs
Normal file
17
webapp/src/views/partials/footer.ejs
Normal 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 & Highways</strong>, Government of India initiative.</p>
|
||||||
|
<p class="footer-muted">© <%= 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>
|
||||||
49
webapp/src/views/partials/header.ejs
Normal file
49
webapp/src/views/partials/header.ejs
Normal 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">🌐</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">☀</button>
|
||||||
|
<span class="user-name">👤 <%= 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' : '' %>">📊 Dashboard</a>
|
||||||
|
<a href="/loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'loads' ? 'active' : '' %>">🚚 Loads</a>
|
||||||
|
<a href="/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : '' %>">💰 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' : '' %>">🏢 Shippers</a>
|
||||||
|
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : '' %>">🚚 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' : '' %>">📈 Reports</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
Loading…
Reference in a new issue