[OWL] Dashboard charts (Recharts CDN) + API layer + portal user management
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:
parent
8e67cb98ae
commit
ada58bc02f
2 changed files with 168 additions and 11 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">📈 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') %>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue