conflict fixed
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||
@@ -40,6 +41,7 @@ interface WidgetMeta {
|
||||
cat: Category
|
||||
title: string
|
||||
size: Size
|
||||
permission: string
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<Category, { label: string; text: string; bg: string; solid: string }> = {
|
||||
@@ -50,23 +52,17 @@ const CATEGORY_META: Record<Category, { label: string; text: string; bg: string;
|
||||
}
|
||||
|
||||
const WIDGET_META: WidgetMeta[] = [
|
||||
{ id: 'pendingPO', cat: 'sales', title: 'Pending Shop POs', size: 'sm' },
|
||||
{ id: 'awaitingConfirm', cat: 'sales', title: 'Awaiting Confirmation', size: 'sm' },
|
||||
{ id: 'fulfillRate', cat: 'sales', title: 'Fulfillment Rate', size: 'md' },
|
||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md' },
|
||||
{ id: 'loadingSheetsByStatus', cat: 'ops', title: 'Loading Sheets by Status', size: 'md' },
|
||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm' },
|
||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg' },
|
||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg' },
|
||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' },
|
||||
{ id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' },
|
||||
{ id: 'salesTrendChart', cat: 'finance', title: 'Sales Trend (6 months)', size: 'chartWide' },
|
||||
{ id: 'paymentStatusChart', cat: 'finance', title: 'Payment Status', size: 'chart' },
|
||||
{ id: 'poStatusChart', cat: 'sales', title: 'Shop POs by Status', size: 'chart' },
|
||||
{ id: 'loadingSheetsChart', cat: 'ops', title: 'Loading Sheets Split', size: 'chart' },
|
||||
{ id: 'warehouseChart', cat: 'stock', title: 'Warehouse Stock Chart', size: 'chart' },
|
||||
{ id: 'movementTypeChart', cat: 'stock', title: 'Movements by Type', size: 'chart' },
|
||||
{ id: 'pendingPO', cat: 'sales', title: 'Pending Shop POs', size: 'sm', permission: 'orders.view' },
|
||||
{ id: 'awaitingConfirm', cat: 'sales', title: 'Awaiting Confirmation', size: 'sm', permission: 'orders.view' },
|
||||
{ id: 'fulfillRate', cat: 'sales', title: 'Fulfillment Rate', size: 'md', permission: 'orders.view' },
|
||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||
{ id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md', permission: 'orders.view' },
|
||||
]
|
||||
|
||||
const DEFAULT_ACTIVE = [
|
||||
@@ -223,103 +219,135 @@ interface DashboardData {
|
||||
monthlySales: MonthlySalesPoint[]
|
||||
}
|
||||
|
||||
const PO_STATUSES = ['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled'] as const
|
||||
const PAYMENT_STATUSES = ['Pending', 'PartiallyPaid', 'Paid'] as const
|
||||
const SALES_MONTHS = 6
|
||||
/** Recent-activity charts aggregate this many latest records client-side. */
|
||||
const CHART_SAMPLE_SIZE = 200
|
||||
|
||||
const splitWords = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
|
||||
function buildMonthlySales(rows: Array<{ createdAt: string; totalAmount: number }>): MonthlySalesPoint[] {
|
||||
const now = new Date()
|
||||
const months: MonthlySalesPoint[] = []
|
||||
for (let i = SALES_MONTHS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, invoiced: 0 })
|
||||
function emptyDashboardData(): DashboardData {
|
||||
return {
|
||||
pendingPOCount: 0, awaitingConfirmCount: 0, totalPOCount: 0, fulfilledPOCount: 0,
|
||||
openRides: [],
|
||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||
pendingTasksCount: 0, partialTasksCount: 0,
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
}
|
||||
for (const r of rows) {
|
||||
const bucket = months.find(m => m.month === r.createdAt.slice(0, 7))
|
||||
if (bucket) bucket.invoiced += r.totalAmount
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
async function loadDashboardData(): Promise<DashboardData> {
|
||||
const [
|
||||
pendingPO, awaitingConfirm, allPO, fulfilledPO,
|
||||
openRidesRes,
|
||||
plannedSheets, confirmedSheets,
|
||||
pendingTasks, partialTasks,
|
||||
lowStock,
|
||||
movements,
|
||||
balances,
|
||||
outstanding,
|
||||
draftInv, printedInv, finalizedInv,
|
||||
poStatusCounts, paymentStatusCounts,
|
||||
recentInvoices, recentMovements,
|
||||
] = await Promise.all([
|
||||
shopPos.list({ status: 'Pending', pageSize: 1 }),
|
||||
shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }),
|
||||
shopPos.list({ pageSize: 1 }),
|
||||
shopPos.list({ status: 'Fulfilled', pageSize: 1 }),
|
||||
rides.list({ status: 'Open', pageSize: 5 }),
|
||||
loadingSheets.list({ status: 'Planned', pageSize: 1 }),
|
||||
loadingSheets.list({ status: 'Confirmed', pageSize: 1 }),
|
||||
deliveryTasks.list({ status: 'Pending', pageSize: 1 }),
|
||||
deliveryTasks.list({ status: 'PartiallyDelivered', pageSize: 1 }),
|
||||
stock.lowStock(),
|
||||
stock.movements({ pageSize: 5 }),
|
||||
stock.balances({ locationType: 'Warehouse', pageSize: 300 }),
|
||||
invoices.outstandingSummary(),
|
||||
invoices.list({ status: 'Draft', pageSize: 1 }),
|
||||
invoices.list({ status: 'Printed', pageSize: 1 }),
|
||||
invoices.list({ status: 'Finalized', pageSize: 1 }),
|
||||
Promise.all(PO_STATUSES.map(status => shopPos.list({ status, pageSize: 1 }))),
|
||||
Promise.all(PAYMENT_STATUSES.map(paymentStatus => invoices.list({ paymentStatus, pageSize: 1 }))),
|
||||
invoices.list({ pageSize: CHART_SAMPLE_SIZE }),
|
||||
stock.movements({ pageSize: CHART_SAMPLE_SIZE }),
|
||||
])
|
||||
async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData> {
|
||||
const data = emptyDashboardData()
|
||||
const fetches: Promise<void>[] = []
|
||||
|
||||
const movementCounts = new Map<string, number>()
|
||||
for (const m of getPaginatedItems(recentMovements)) {
|
||||
movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1)
|
||||
if (activeIds.has('pendingPO') || activeIds.has('awaitingConfirm') || activeIds.has('fulfillRate')) {
|
||||
fetches.push(
|
||||
Promise.all([
|
||||
shopPos.list({ status: 'Pending', pageSize: 1 }),
|
||||
shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }),
|
||||
shopPos.list({ pageSize: 1 }),
|
||||
shopPos.list({ status: 'Fulfilled', pageSize: 1 }),
|
||||
]).then(([pending, awaiting, all, fulfilled]) => {
|
||||
data.pendingPOCount = pending.total
|
||||
data.awaitingConfirmCount = awaiting.total
|
||||
data.totalPOCount = all.total
|
||||
data.fulfilledPOCount = fulfilled.total
|
||||
}).catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
const balanceRows = getPaginatedItems(balances)
|
||||
const byWarehouse = new Map<string, WarehouseBalanceRow>()
|
||||
for (const b of balanceRows) {
|
||||
const existing = byWarehouse.get(b.locationId)
|
||||
if (existing) existing.quantity += b.quantity
|
||||
else byWarehouse.set(b.locationId, { locationId: b.locationId, name: b.locationName, quantity: b.quantity })
|
||||
if (activeIds.has('openRides')) {
|
||||
fetches.push(
|
||||
rides.list({ status: 'Open', pageSize: 5 })
|
||||
.then(res => {
|
||||
data.openRides = getPaginatedItems(res).map(r => ({
|
||||
id: r.id, vehicleNumberPlate: r.vehicleNumberPlate, driverFullName: r.driverFullName,
|
||||
}))
|
||||
})
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
const warehouseBalances = Array.from(byWarehouse.values())
|
||||
.sort((a, b) => b.quantity - a.quantity)
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
pendingPOCount: pendingPO.total,
|
||||
awaitingConfirmCount: awaitingConfirm.total,
|
||||
totalPOCount: allPO.total,
|
||||
fulfilledPOCount: fulfilledPO.total,
|
||||
openRides: getPaginatedItems(openRidesRes).map(r => ({ id: r.id, vehicleNumberPlate: r.vehicleNumberPlate, driverFullName: r.driverFullName })),
|
||||
plannedSheetsCount: plannedSheets.total,
|
||||
confirmedSheetsCount: confirmedSheets.total,
|
||||
pendingTasksCount: pendingTasks.total,
|
||||
partialTasksCount: partialTasks.total,
|
||||
lowStock: getPaginatedItems(lowStock).slice(0, 5),
|
||||
movements: getPaginatedItems(movements),
|
||||
warehouseBalances,
|
||||
outstandingAmount: outstanding.outstandingAmount,
|
||||
outstandingInvoiceCount: outstanding.invoiceCount,
|
||||
draftInvoiceCount: draftInv.total,
|
||||
printedInvoiceCount: printedInv.total,
|
||||
finalizedInvoiceCount: finalizedInv.total,
|
||||
poStatusChart: PO_STATUSES.map((s, i) => ({ name: splitWords(s), value: poStatusCounts[i].total })),
|
||||
paymentStatusChart: PAYMENT_STATUSES.map((s, i) => ({ name: splitWords(s), value: paymentStatusCounts[i].total })),
|
||||
movementTypeChart: Array.from(movementCounts, ([type, value]) => ({ name: splitWords(type), value })).sort((a, b) => b.value - a.value),
|
||||
monthlySales: buildMonthlySales(getPaginatedItems(recentInvoices)),
|
||||
if (activeIds.has('loadingSheetsByStatus')) {
|
||||
fetches.push(
|
||||
Promise.all([
|
||||
loadingSheets.list({ status: 'Planned', pageSize: 1 }),
|
||||
loadingSheets.list({ status: 'Confirmed', pageSize: 1 }),
|
||||
]).then(([planned, confirmed]) => {
|
||||
data.plannedSheetsCount = planned.total
|
||||
data.confirmedSheetsCount = confirmed.total
|
||||
}).catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('deliveryTasks')) {
|
||||
fetches.push(
|
||||
Promise.all([
|
||||
deliveryTasks.list({ status: 'Pending', pageSize: 1 }),
|
||||
deliveryTasks.list({ status: 'PartiallyDelivered', pageSize: 1 }),
|
||||
]).then(([pending, partial]) => {
|
||||
data.pendingTasksCount = pending.total
|
||||
data.partialTasksCount = partial.total
|
||||
}).catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('lowStock')) {
|
||||
fetches.push(
|
||||
stock.lowStock()
|
||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('stockMovements')) {
|
||||
fetches.push(
|
||||
stock.movements({ pageSize: 5 })
|
||||
.then(res => { data.movements = getPaginatedItems(res) })
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('warehouseBalance')) {
|
||||
fetches.push(
|
||||
stock.balances({ locationType: 'Warehouse', pageSize: 300 })
|
||||
.then(res => {
|
||||
const byWarehouse = new Map<string, WarehouseBalanceRow>()
|
||||
for (const b of getPaginatedItems(res)) {
|
||||
const existing = byWarehouse.get(b.locationId)
|
||||
if (existing) existing.quantity += b.quantity
|
||||
else byWarehouse.set(b.locationId, { locationId: b.locationId, name: b.locationName, quantity: b.quantity })
|
||||
}
|
||||
data.warehouseBalances = Array.from(byWarehouse.values())
|
||||
.sort((a, b) => b.quantity - a.quantity)
|
||||
.slice(0, 5)
|
||||
})
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('outstandingPay')) {
|
||||
fetches.push(
|
||||
invoices.outstandingSummary()
|
||||
.then(res => {
|
||||
data.outstandingAmount = res.outstandingAmount
|
||||
data.outstandingInvoiceCount = res.invoiceCount
|
||||
})
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('invoiceStatus')) {
|
||||
fetches.push(
|
||||
Promise.all([
|
||||
invoices.list({ status: 'Draft', pageSize: 1 }),
|
||||
invoices.list({ status: 'Printed', pageSize: 1 }),
|
||||
invoices.list({ status: 'Finalized', pageSize: 1 }),
|
||||
]).then(([draft, printed, finalized]) => {
|
||||
data.draftInvoiceCount = draft.total
|
||||
data.printedInvoiceCount = printed.total
|
||||
data.finalizedInvoiceCount = finalized.total
|
||||
}).catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(fetches)
|
||||
return data
|
||||
}
|
||||
|
||||
function CategoryPill({ cat }: { cat: Category }) {
|
||||
@@ -484,6 +512,7 @@ function mergeLayoutsChange(
|
||||
}
|
||||
|
||||
export function DashboardClient() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
|
||||
@@ -492,6 +521,16 @@ export function DashboardClient() {
|
||||
const [category, setCategory] = useState<'all' | Category>('all')
|
||||
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
||||
|
||||
const allowedIds = useMemo(
|
||||
() => new Set(WIDGET_META.filter(w => hasPermission(w.permission)).map(w => w.id)),
|
||||
[hasPermission],
|
||||
)
|
||||
|
||||
const permittedActiveIds = useMemo(
|
||||
() => state.activeIds.filter(id => allowedIds.has(id)),
|
||||
[state.activeIds, allowedIds],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setState(loadStoredLayoutState())
|
||||
}, [])
|
||||
@@ -508,13 +547,13 @@ export function DashboardClient() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setData(await loadDashboardData())
|
||||
setData(await loadDashboardData(new Set(permittedActiveIds)))
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load dashboard', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [permittedActiveIds])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -547,11 +586,11 @@ export function DashboardClient() {
|
||||
}
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() => state.activeIds
|
||||
() => permittedActiveIds
|
||||
.map(id => WIDGET_META.find(w => w.id === id))
|
||||
.filter((w): w is WidgetMeta => Boolean(w))
|
||||
.filter(w => category === 'all' || w.cat === category),
|
||||
[state.activeIds, category],
|
||||
[permittedActiveIds, category],
|
||||
)
|
||||
|
||||
const visibleLayouts = useMemo(() => {
|
||||
@@ -599,7 +638,7 @@ export function DashboardClient() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WIDGET_META.map(w => {
|
||||
{WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => {
|
||||
const checked = state.activeIds.includes(w.id)
|
||||
return (
|
||||
<label
|
||||
|
||||
Reference in New Issue
Block a user