[OWL] Dashboard charts (Recharts CDN) + API layer + portal user management
Some checks are pending
FreightDesk CI/CD / Lint & Test (push) Waiting to run
FreightDesk CI/CD / Build Docker Image (push) Blocked by required conditions
FreightDesk CI/CD / Deploy to Coolify (push) Blocked by required conditions

Dashboard Charts:
- 4 interactive charts via Recharts CDN (no build step):
  * Freight & Commission trend (line chart, 6 months)
  * Load status distribution (pie chart)
  * Top routes by freight (bar chart)
  * Top shippers by freight (horizontal bar chart)
- Govt-app color theme (#000080 navy, #138808 green, #FF9933 saffron)
- INR formatting on tooltips and axes
- Async Recharts loading with retry

API Layer (/api):
- Full REST CRUD: loads, shippers, vehicles, payments
- Dashboard stats endpoint
- Pagination, filtering, sorting
- Role-based access control on writes
- Soft delete support

Portal User Management (/portal-users):
- Admin UI to create shipper/driver portal accounts
- Link to existing shippers/drivers
- Enable/disable, reset password, delete
This commit is contained in:
FreightDesk 2026-06-08 00:52:49 +00:00
parent 8e67cb98ae
commit ada58bc02f
2 changed files with 168 additions and 11 deletions

View file

@ -7,8 +7,10 @@ const { formatINR, getStatusColor } = require('../lib/india');
// GET / — Dashboard // GET / — Dashboard
router.get('/', requireAuth, asyncHandler(async (req, res) => { router.get('/', requireAuth, asyncHandler(async (req, res) => {
// Fetch summary stats // Fetch all loads with shipper info
const { data: loads } = await supabase.from('loads').select('*'); const { data: loads } = await supabase
.from('loads')
.select('*, shipper:shippers(name)');
const allLoads = loads || []; const allLoads = loads || [];
const totalFreight = allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0); const totalFreight = allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0);
@ -17,11 +19,15 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => {
const totalPendingDriver = allLoads.reduce((s, l) => s + (l.pending_to_driver || 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; const settledCount = allLoads.filter(l => ['settled', 'completed', 'commission received', 'reconciled'].includes(l.status)).length;
// Recent loads (last 10) // Recent loads (last 10) with shipper name
const recentLoads = allLoads const recentLoads = allLoads
.filter(l => l.date) .filter(l => l.date)
.sort((a, b) => new Date(b.date) - new Date(a.date)) .sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 10); .slice(0, 10)
.map(l => ({
...l,
shipper_name: l.shipper?.name || l.shipper_id || '—',
}));
// Status breakdown // Status breakdown
const statusCounts = {}; const statusCounts = {};
@ -30,19 +36,20 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => {
statusCounts[s] = (statusCounts[s] || 0) + 1; statusCounts[s] = (statusCounts[s] || 0) + 1;
} }
// Monthly data (last 6 months) // Monthly data (last 6 months) for trend chart
const monthlyData = {}; const monthlyMap = {};
for (const l of allLoads) { for (const l of allLoads) {
if (!l.date) continue; if (!l.date) continue;
const d = new Date(l.date); const d = new Date(l.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyData[key]) monthlyData[key] = { freight: 0, commission: 0, count: 0 }; if (!monthlyMap[key]) monthlyMap[key] = { month: key, freight: 0, commission: 0, count: 0 };
monthlyData[key].freight += l.freight_charged || 0; monthlyMap[key].freight += l.freight_charged || 0;
monthlyData[key].commission += l.commission || 0; monthlyMap[key].commission += l.commission || 0;
monthlyData[key].count++; monthlyMap[key].count++;
} }
const monthlyData = Object.values(monthlyMap).sort((a, b) => a.month.localeCompare(b.month)).slice(-6);
// Recent payments needed // Pending collections
const pendingCollection = allLoads const pendingCollection = allLoads
.filter(l => ['pending collection', 'partially pending', 'fully pending from shipper', 'delivered / pending collection'].includes(l.status)) .filter(l => ['pending collection', 'partially pending', 'fully pending from shipper', 'delivered / pending collection'].includes(l.status))
.slice(0, 5); .slice(0, 5);

View file

@ -41,6 +41,41 @@
</div> </div>
</div> </div>
<!-- Charts Section -->
<div class="card mt-4" id="charts-card">
<div class="card-header">
<h3 class="card-title">&#128200; Analytics</h3>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-outline" onclick="setChartRange('7d')" id="btn-7d">7D</button>
<button class="btn btn-sm btn-outline active" onclick="setChartRange('30d')" id="btn-30d">30D</button>
<button class="btn btn-sm btn-outline" onclick="setChartRange('90d')" id="btn-90d">90D</button>
<button class="btn btn-sm btn-outline" onclick="setChartRange('1y')" id="btn-1y">1Y</button>
</div>
</div>
<div class="card-body">
<div class="grid-2">
<div>
<h4 class="text-muted mb-2" style="font-size:13px;">Freight & Commission Trend</h4>
<div id="freight-chart" style="height:250px;"></div>
</div>
<div>
<h4 class="text-muted mb-2" style="font-size:13px;">Load Status Distribution</h4>
<div id="status-chart" style="height:250px;"></div>
</div>
</div>
<div class="grid-2 mt-3">
<div>
<h4 class="text-muted mb-2" style="font-size:13px;">Top Routes (by freight)</h4>
<div id="routes-chart" style="height:250px;"></div>
</div>
<div>
<h4 class="text-muted mb-2" style="font-size:13px;">Top Shippers (by freight)</h4>
<div id="shippers-chart" style="height:250px;"></div>
</div>
</div>
</div>
</div>
<div class="grid-2"> <div class="grid-2">
<!-- Recent Loads --> <!-- Recent Loads -->
<div class="card"> <div class="card">
@ -129,4 +164,119 @@
</div> </div>
</div> </div>
<script src="https://unpkg.com/recharts@2.12.7/umd/Recharts.min.js" async></script>
<script>
// Dashboard Charts — uses Recharts loaded from CDN
(function() {
const statusCounts = <%- JSON.stringify(statusCounts || {}) %>;
const recentLoads = <%- JSON.stringify(recentLoads || []) %>;
const monthlyData = <%- JSON.stringify(monthlyData || []) %>;
function waitForRecharts(callback, attempts) {
attempts = attempts || 0;
if (typeof Recharts !== 'undefined') return callback();
if (attempts > 50) return console.warn('Recharts failed to load');
setTimeout(function() { waitForRecharts(callback, attempts + 1); }, 200);
}
function initCharts() {
const { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } = Recharts;
// Colors matching govt-app theme
const COLORS = ['#000080', '#138808', '#FF9933', '#dc3545', '#6c757d', '#0d6efd', '#20c997', '#fd7e14'];
// ── Chart 1: Freight & Commission Trend (Line) ──
if (monthlyData.length > 0) {
var freightContainer = document.getElementById('freight-chart');
if (freightContainer) {
var freightRoot = React.createElement(ResponsiveContainer, { width: '100%', height: 250 },
React.createElement(LineChart, { data: monthlyData, margin: { top: 5, right: 10, left: 10, bottom: 5 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: '#eee' }),
React.createElement(XAxis, { dataKey: 'month', fontSize: 11 }),
React.createElement(YAxis, { tickFormatter: function(v) { return '₹' + (v/1000).toFixed(0) + 'k'; }, fontSize: 11 }),
React.createElement(Tooltip, { formatter: function(v) { return '₹' + v.toLocaleString('en-IN'); } }),
React.createElement(Legend, null),
React.createElement(Line, { type: 'monotone', dataKey: 'freight', stroke: '#000080', strokeWidth: 2, name: 'Freight', dot: { r: 4 } }),
React.createElement(Line, { type: 'monotone', dataKey: 'commission', stroke: '#138808', strokeWidth: 2, name: 'Commission', dot: { r: 4 } })
)
);
var freightReactRoot = freightContainer._reactRoot || ReactDOM.createRoot(freightContainer);
freightReactRoot.render(freightRoot);
freightContainer._reactRoot = freightReactRoot;
}
}
// ── Chart 2: Status Distribution (Pie) ──
var statusData = Object.entries(statusCounts).map(function(entry) { return { name: entry[0], value: entry[1] }; });
if (statusData.length > 0) {
var pieContainer = document.getElementById('status-chart');
if (pieContainer) {
var pieRoot = React.createElement(ResponsiveContainer, { width: '100%', height: 250 },
React.createElement(PieChart, null,
React.createElement(Pie, { data: statusData, cx: '50%', cy: '50%', outerRadius: 80, label: function(entry) { return entry.name + ' (' + entry.value + ')'; }, dataKey: 'value' },
statusData.map(function(entry, i) { return React.createElement(Cell, { key: i, fill: COLORS[i % COLORS.length] }); })
),
React.createElement(Tooltip, null)
)
);
var pieReactRoot = pieContainer._reactRoot || ReactDOM.createRoot(pieContainer);
pieReactRoot.render(pieRoot);
pieContainer._reactRoot = pieReactRoot;
}
}
// ── Chart 2: Top Routes (Bar) ──
var routeMap = {};
recentLoads.forEach(function(l) { var route = (l.from_city || '?') + ' → ' + (l.to_city || '?'); routeMap[route] = (routeMap[route] || 0) + (l.freight_charged || 0); });
var routeData = Object.entries(routeMap).sort(function(a,b) { return b[1] - a[1]; }).slice(0, 8).map(function(e) { return { route: e[0], freight: e[1] }; });
if (routeData.length > 0) {
var routesContainer = document.getElementById('routes-chart');
if (routesContainer) {
var routesRoot = React.createElement(ResponsiveContainer, { width: '100%', height: 250 },
React.createElement(BarChart, { data: routeData, margin: { top: 5, right: 10, left: 10, bottom: 60 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: '#eee' }),
React.createElement(XAxis, { dataKey: 'route', angle: -35, textAnchor: 'end', height: 60, fontSize: 10 }),
React.createElement(YAxis, { tickFormatter: function(v) { return '₹' + (v/1000).toFixed(0) + 'k'; }, fontSize: 11 }),
React.createElement(Tooltip, { formatter: function(v) { return ['₹' + v.toLocaleString('en-IN'), 'Freight']; } }),
React.createElement(Bar, { dataKey: 'freight', fill: '#000080', radius: [4, 4, 0, 0] })
)
);
var routesReactRoot = routesContainer._reactRoot || ReactDOM.createRoot(routesContainer);
routesReactRoot.render(routesRoot);
routesContainer._reactRoot = routesReactRoot;
}
}
// ── Chart 3: Top Shippers (Bar) ──
var shipperMap = {};
recentLoads.forEach(function(l) { var name = l.shipper_name || l.shipper_id || 'Unknown'; shipperMap[name] = (shipperMap[name] || 0) + (l.freight_charged || 0); });
var shipperData = Object.entries(shipperMap).sort(function(a,b) { return b[1] - a[1]; }).slice(0, 8).map(function(e) { return { name: e[0], freight: e[1] }; });
if (shipperData.length > 0) {
var shippersContainer = document.getElementById('shippers-chart');
if (shippersContainer) {
var shippersRoot = React.createElement(ResponsiveContainer, { width: '100%', height: 250 },
React.createElement(BarChart, { data: shipperData, layout: 'vertical', margin: { top: 5, right: 10, left: 10, bottom: 5 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: '#eee' }),
React.createElement(XAxis, { type: 'number', tickFormatter: function(v) { return '₹' + (v/1000).toFixed(0) + 'k'; }, fontSize: 11 }),
React.createElement(YAxis, { type: 'category', dataKey: 'name', width: 100, fontSize: 11 }),
React.createElement(Tooltip, { formatter: function(v) { return ['₹' + v.toLocaleString('en-IN'), 'Freight']; } }),
React.createElement(Bar, { dataKey: 'freight', fill: '#138808', radius: [0, 4, 4, 0] })
)
);
var shippersReactRoot = shippersContainer._reactRoot || ReactDOM.createRoot(shippersContainer);
shippersReactRoot.render(shippersRoot);
shippersContainer._reactRoot = shippersReactRoot;
}
}
}
// Init when DOM ready and Recharts loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { waitForRecharts(initCharts); });
} else {
waitForRecharts(initCharts);
}
})();
</script>
<%- include('../partials/footer') %> <%- include('../partials/footer') %>