[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
|
||||
router.get('/', requireAuth, asyncHandler(async (req, res) => {
|
||||
// Fetch summary stats
|
||||
const { data: loads } = await supabase.from('loads').select('*');
|
||||
// Fetch all loads with shipper info
|
||||
const { data: loads } = await supabase
|
||||
.from('loads')
|
||||
.select('*, shipper:shippers(name)');
|
||||
const allLoads = loads || [];
|
||||
|
||||
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 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
|
||||
.filter(l => l.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
|
||||
const statusCounts = {};
|
||||
|
|
@ -30,19 +36,20 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => {
|
|||
statusCounts[s] = (statusCounts[s] || 0) + 1;
|
||||
}
|
||||
|
||||
// Monthly data (last 6 months)
|
||||
const monthlyData = {};
|
||||
// Monthly data (last 6 months) for trend chart
|
||||
const monthlyMap = {};
|
||||
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++;
|
||||
if (!monthlyMap[key]) monthlyMap[key] = { month: key, freight: 0, commission: 0, count: 0 };
|
||||
monthlyMap[key].freight += l.freight_charged || 0;
|
||||
monthlyMap[key].commission += l.commission || 0;
|
||||
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
|
||||
.filter(l => ['pending collection', 'partially pending', 'fully pending from shipper', 'delivered / pending collection'].includes(l.status))
|
||||
.slice(0, 5);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,41 @@
|
|||
</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">
|
||||
<!-- Recent Loads -->
|
||||
<div class="card">
|
||||
|
|
@ -129,4 +164,119 @@
|
|||
</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') %>
|
||||
|
|
|
|||
Loading…
Reference in a new issue