|
|
|
@@ -0,0 +1,665 @@
|
|
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { HugeiconsIcon } from '@hugeicons/react'
|
|
|
|
|
import { Add01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
|
|
|
|
|
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
|
|
|
|
|
import type { Layout, LayoutItem } from 'react-grid-layout'
|
|
|
|
|
import 'react-grid-layout/css/styles.css'
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
|
import { toast } from '@/components/ui/toast'
|
|
|
|
|
import {
|
|
|
|
|
shopPos,
|
|
|
|
|
rides,
|
|
|
|
|
loadingSheets,
|
|
|
|
|
deliveryTasks,
|
|
|
|
|
stock,
|
|
|
|
|
invoices,
|
|
|
|
|
getApiError,
|
|
|
|
|
getPaginatedItems,
|
|
|
|
|
} from '@/lib/api/index'
|
|
|
|
|
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
|
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'distribution-dashboard-widgets-v3'
|
|
|
|
|
const ROW_HEIGHT = 36
|
|
|
|
|
const GRID_MARGIN: [number, number] = [16, 16]
|
|
|
|
|
|
|
|
|
|
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
|
|
|
|
|
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
|
|
|
|
|
|
|
|
|
|
type Category = 'sales' | 'ops' | 'stock' | 'finance'
|
|
|
|
|
type Size = 'sm' | 'md' | 'lg'
|
|
|
|
|
|
|
|
|
|
interface WidgetMeta {
|
|
|
|
|
id: string
|
|
|
|
|
cat: Category
|
|
|
|
|
title: string
|
|
|
|
|
size: Size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CATEGORY_META: Record<Category, { label: string; text: string; bg: string; solid: string }> = {
|
|
|
|
|
sales: { label: 'Sales & Fulfillment', text: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-500/10', solid: 'bg-blue-600' },
|
|
|
|
|
ops: { label: 'Operations', text: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-500/10', solid: 'bg-violet-600' },
|
|
|
|
|
stock: { label: 'Stock', text: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-500/10', solid: 'bg-amber-600' },
|
|
|
|
|
finance: { label: 'Finance', text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', solid: 'bg-emerald-600' },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ACTIVE = [
|
|
|
|
|
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
|
|
|
|
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
/** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints
|
|
|
|
|
* give small/medium widgets a full-width row instead of shrinking them into unreadably thin columns. */
|
|
|
|
|
const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
|
|
|
|
|
lg: {
|
|
|
|
|
sm: { w: 3, h: 4, minW: 2, minH: 3 },
|
|
|
|
|
md: { w: 6, h: 5, minW: 3, minH: 3 },
|
|
|
|
|
lg: { w: 12, h: 7, minW: 4, minH: 4 },
|
|
|
|
|
},
|
|
|
|
|
md: {
|
|
|
|
|
sm: { w: 5, h: 4, minW: 3, minH: 3 },
|
|
|
|
|
md: { w: 10, h: 5, minW: 4, minH: 3 },
|
|
|
|
|
lg: { w: 10, h: 7, minW: 4, minH: 4 },
|
|
|
|
|
},
|
|
|
|
|
sm: {
|
|
|
|
|
sm: { w: 3, h: 4, minW: 2, minH: 3 },
|
|
|
|
|
md: { w: 6, h: 5, minW: 3, minH: 3 },
|
|
|
|
|
lg: { w: 6, h: 7, minW: 3, minH: 4 },
|
|
|
|
|
},
|
|
|
|
|
xs: {
|
|
|
|
|
sm: { w: 4, h: 4, minW: 2, minH: 3 },
|
|
|
|
|
md: { w: 4, h: 5, minW: 2, minH: 3 },
|
|
|
|
|
lg: { w: 4, h: 7, minW: 2, minH: 4 },
|
|
|
|
|
},
|
|
|
|
|
xxs: {
|
|
|
|
|
sm: { w: 2, h: 4, minW: 1, minH: 3 },
|
|
|
|
|
md: { w: 2, h: 5, minW: 1, minH: 3 },
|
|
|
|
|
lg: { w: 2, h: 7, minW: 1, minH: 4 },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Lays widgets out left-to-right for one breakpoint's column count, wrapping to a new row when a widget won't fit. */
|
|
|
|
|
function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
|
|
|
|
|
const cols = DEFAULT_COLS[bp]
|
|
|
|
|
const metas = WIDGET_META.filter(w => ids.includes(w.id))
|
|
|
|
|
let x = 0
|
|
|
|
|
let y = 0
|
|
|
|
|
let rowH = 0
|
|
|
|
|
const items: LayoutItem[] = []
|
|
|
|
|
for (const m of metas) {
|
|
|
|
|
const cfg = SIZE_CONFIG[bp][m.size]
|
|
|
|
|
if (x + cfg.w > cols) {
|
|
|
|
|
x = 0
|
|
|
|
|
y += rowH
|
|
|
|
|
rowH = 0
|
|
|
|
|
}
|
|
|
|
|
items.push({ i: m.id, x, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH })
|
|
|
|
|
x += cfg.w
|
|
|
|
|
rowH = Math.max(rowH, cfg.h)
|
|
|
|
|
}
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateFlowLayouts(ids: string[]): Record<Breakpoint, LayoutItem[]> {
|
|
|
|
|
return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record<Breakpoint, LayoutItem[]>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Places a newly-added widget below everything else on a given breakpoint, so it never overlaps what's already there. */
|
|
|
|
|
function appendWidget(layout: LayoutItem[], meta: WidgetMeta, bp: Breakpoint): LayoutItem[] {
|
|
|
|
|
const cfg = SIZE_CONFIG[bp][meta.size]
|
|
|
|
|
const y = layout.reduce((max, item) => Math.max(max, item.y + item.h), 0)
|
|
|
|
|
return [...layout, { i: meta.id, x: 0, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH }]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DashboardLayoutState {
|
|
|
|
|
activeIds: string[]
|
|
|
|
|
layouts: Record<Breakpoint, LayoutItem[]>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultLayoutState(): DashboardLayoutState {
|
|
|
|
|
return { activeIds: [...DEFAULT_ACTIVE], layouts: generateFlowLayouts(DEFAULT_ACTIVE) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadStoredLayoutState(): DashboardLayoutState {
|
|
|
|
|
try {
|
|
|
|
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
|
|
|
if (!raw) return defaultLayoutState()
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(raw)
|
|
|
|
|
const validIds = new Set(WIDGET_META.map(w => w.id))
|
|
|
|
|
const activeIds: string[] = Array.isArray(parsed?.activeIds)
|
|
|
|
|
? parsed.activeIds.filter((id: unknown): id is string => typeof id === 'string' && validIds.has(id))
|
|
|
|
|
: []
|
|
|
|
|
if (activeIds.length === 0) return defaultLayoutState()
|
|
|
|
|
|
|
|
|
|
const layouts = {} as Record<Breakpoint, LayoutItem[]>
|
|
|
|
|
for (const bp of BREAKPOINT_ORDER) {
|
|
|
|
|
const storedLayout: LayoutItem[] = Array.isArray(parsed?.layouts?.[bp])
|
|
|
|
|
? parsed.layouts[bp].filter((l: unknown): l is LayoutItem =>
|
|
|
|
|
!!l && typeof l === 'object' && typeof (l as LayoutItem).i === 'string' && validIds.has((l as LayoutItem).i) && activeIds.includes((l as LayoutItem).i))
|
|
|
|
|
: []
|
|
|
|
|
const missingIds = activeIds.filter(id => !storedLayout.some(l => l.i === id))
|
|
|
|
|
layouts[bp] = missingIds.length > 0 ? [...storedLayout, ...generateFlowLayout(missingIds, bp)] : storedLayout
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { activeIds, layouts }
|
|
|
|
|
} catch {
|
|
|
|
|
return defaultLayoutState()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
|
|
|
|
|
|
|
|
|
interface OpenRide {
|
|
|
|
|
id: string
|
|
|
|
|
vehicleNumberPlate: string
|
|
|
|
|
driverFullName: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WarehouseBalanceRow {
|
|
|
|
|
locationId: string
|
|
|
|
|
name: string
|
|
|
|
|
quantity: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DashboardData {
|
|
|
|
|
pendingPOCount: number
|
|
|
|
|
awaitingConfirmCount: number
|
|
|
|
|
totalPOCount: number
|
|
|
|
|
fulfilledPOCount: number
|
|
|
|
|
openRides: OpenRide[]
|
|
|
|
|
plannedSheetsCount: number
|
|
|
|
|
confirmedSheetsCount: number
|
|
|
|
|
pendingTasksCount: number
|
|
|
|
|
partialTasksCount: number
|
|
|
|
|
lowStock: LowStockItemResponse[]
|
|
|
|
|
movements: StockMovementResponse[]
|
|
|
|
|
warehouseBalances: WarehouseBalanceRow[]
|
|
|
|
|
outstandingAmount: number
|
|
|
|
|
outstandingInvoiceCount: number
|
|
|
|
|
draftInvoiceCount: number
|
|
|
|
|
printedInvoiceCount: number
|
|
|
|
|
finalizedInvoiceCount: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDashboardData(): Promise<DashboardData> {
|
|
|
|
|
const [
|
|
|
|
|
pendingPO, awaitingConfirm, allPO, fulfilledPO,
|
|
|
|
|
openRidesRes,
|
|
|
|
|
plannedSheets, confirmedSheets,
|
|
|
|
|
pendingTasks, partialTasks,
|
|
|
|
|
lowStock,
|
|
|
|
|
movements,
|
|
|
|
|
balances,
|
|
|
|
|
outstanding,
|
|
|
|
|
draftInv, printedInv, finalizedInv,
|
|
|
|
|
] = 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 }),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CategoryPill({ cat }: { cat: Category }) {
|
|
|
|
|
const meta = CATEGORY_META[cat]
|
|
|
|
|
return (
|
|
|
|
|
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
|
|
|
|
|
{meta.label}
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Bar({ pct, colorClass }: { pct: number; colorClass: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
|
|
|
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|
|
|
|
switch (id) {
|
|
|
|
|
case 'pendingPO':
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-3xl font-bold text-foreground">{data.pendingPOCount}</div>
|
|
|
|
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'awaitingConfirm':
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-3xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
|
|
|
|
|
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'fulfillRate': {
|
|
|
|
|
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-2.5 text-2xl font-bold text-foreground">{pct}%</div>
|
|
|
|
|
<Bar pct={pct} colorClass="bg-blue-600" />
|
|
|
|
|
<div className="mt-2.5 text-xs text-muted-foreground">{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
case 'openRides':
|
|
|
|
|
return data.openRides.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No open rides right now.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{data.openRides.map(r => (
|
|
|
|
|
<div key={r.id} className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="rounded-full bg-violet-600 px-2.5 py-1 text-[11px] font-semibold text-white">Open</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'loadingSheetsByStatus': {
|
|
|
|
|
const total = data.plannedSheetsCount + data.confirmedSheetsCount
|
|
|
|
|
const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0
|
|
|
|
|
const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
|
|
|
<span className="text-muted-foreground">Planned</span>
|
|
|
|
|
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={plannedPct} colorClass="bg-amber-600" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
|
|
|
<span className="text-muted-foreground">Confirmed</span>
|
|
|
|
|
<span className="font-semibold text-foreground">{data.confirmedSheetsCount}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={confirmedPct} colorClass="bg-violet-600" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
case 'deliveryTasks':
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-3xl font-bold text-foreground">{data.pendingTasksCount}</div>
|
|
|
|
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'lowStock':
|
|
|
|
|
return data.lowStock.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
|
|
|
|
</div>
|
|
|
|
|
{data.lowStock.map(row => (
|
|
|
|
|
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
|
|
|
|
<span className="text-foreground">{row.itemName}</span>
|
|
|
|
|
<span className="text-muted-foreground">{row.locationName}</span>
|
|
|
|
|
<span className="font-semibold text-destructive">{row.quantity}</span>
|
|
|
|
|
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'stockMovements':
|
|
|
|
|
return data.movements.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="grid grid-cols-[2fr_1fr_0.8fr_2fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
<span>Item</span><span>Type</span><span>Qty</span><span>Route</span>
|
|
|
|
|
</div>
|
|
|
|
|
{data.movements.map(m => (
|
|
|
|
|
<div key={m.id} className="grid grid-cols-[2fr_1fr_0.8fr_2fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
|
|
|
|
<span className="text-foreground">{m.itemName}</span>
|
|
|
|
|
<span className="font-medium text-foreground">{m.type}</span>
|
|
|
|
|
<span className="text-muted-foreground">{m.quantity}</span>
|
|
|
|
|
<span className="truncate text-muted-foreground">{m.fromLocationName ?? '—'} → {m.toLocationName ?? '—'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'warehouseBalance': {
|
|
|
|
|
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity))
|
|
|
|
|
return data.warehouseBalances.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No warehouse stock recorded.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
{data.warehouseBalances.map(w => (
|
|
|
|
|
<div key={w.locationId}>
|
|
|
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
|
|
|
<span className="text-muted-foreground">{w.name}</span>
|
|
|
|
|
<span className="font-semibold text-foreground">{w.quantity}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={(w.quantity / max) * 100} colorClass="bg-amber-600" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
case 'outstandingPay':
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-3xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
|
|
|
|
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
case 'invoiceStatus': {
|
|
|
|
|
const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
|
|
|
|
|
const segs = [
|
|
|
|
|
{ label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' },
|
|
|
|
|
{ label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' },
|
|
|
|
|
{ label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' },
|
|
|
|
|
]
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
{segs.map(s => (
|
|
|
|
|
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3.5 flex flex-col gap-1.5">
|
|
|
|
|
{segs.map(s => (
|
|
|
|
|
<div key={s.label} className="flex items-center justify-between text-xs">
|
|
|
|
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
|
|
|
|
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
|
|
|
|
|
{s.label}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-semibold text-foreground">{s.count}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeLayoutsChange(
|
|
|
|
|
full: Record<Breakpoint, LayoutItem[]>,
|
|
|
|
|
changed: Partial<Record<Breakpoint, Layout>>,
|
|
|
|
|
): Record<Breakpoint, LayoutItem[]> {
|
|
|
|
|
const next = { ...full }
|
|
|
|
|
for (const bp of BREAKPOINT_ORDER) {
|
|
|
|
|
const changedArr = changed[bp]
|
|
|
|
|
if (!changedArr) continue
|
|
|
|
|
next[bp] = full[bp].map(item => {
|
|
|
|
|
const updated = changedArr.find(c => c.i === item.i)
|
|
|
|
|
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DashboardClient() {
|
|
|
|
|
const [data, setData] = useState<DashboardData | null>(null)
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
|
|
|
|
|
const [editMode, setEditMode] = useState(false)
|
|
|
|
|
const [showPicker, setShowPicker] = useState(false)
|
|
|
|
|
const [category, setCategory] = useState<'all' | Category>('all')
|
|
|
|
|
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setState(loadStoredLayoutState())
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const persist = useCallback((next: DashboardLayoutState) => {
|
|
|
|
|
setState(next)
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
|
|
|
} catch {
|
|
|
|
|
// localStorage unavailable (private browsing, etc.) — layout just won't persist
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const fetchData = useCallback(async () => {
|
|
|
|
|
setLoading(true)
|
|
|
|
|
try {
|
|
|
|
|
setData(await loadDashboardData())
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.add({ title: 'Failed to load dashboard', description: getApiError(err), type: 'error' })
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchData()
|
|
|
|
|
}, [fetchData])
|
|
|
|
|
|
|
|
|
|
function toggleWidget(id: string, checked: boolean) {
|
|
|
|
|
if (checked) {
|
|
|
|
|
const meta = WIDGET_META.find(w => w.id === id)
|
|
|
|
|
if (!meta || state.activeIds.includes(id)) return
|
|
|
|
|
const layouts = { ...state.layouts }
|
|
|
|
|
for (const bp of BREAKPOINT_ORDER) layouts[bp] = appendWidget(layouts[bp], meta, bp)
|
|
|
|
|
persist({ activeIds: [...state.activeIds, id], layouts })
|
|
|
|
|
} else {
|
|
|
|
|
const layouts = { ...state.layouts }
|
|
|
|
|
for (const bp of BREAKPOINT_ORDER) layouts[bp] = layouts[bp].filter(l => l.i !== id)
|
|
|
|
|
persist({ activeIds: state.activeIds.filter(x => x !== id), layouts })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLayoutChange(_layout: Layout, changedLayouts: Partial<Record<Breakpoint, Layout>>) {
|
|
|
|
|
setState(prev => {
|
|
|
|
|
const next = { ...prev, layouts: mergeLayoutsChange(prev.layouts, changedLayouts) }
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
|
|
|
} catch {
|
|
|
|
|
// localStorage unavailable — position just won't persist
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visibleWidgets = useMemo(
|
|
|
|
|
() => state.activeIds
|
|
|
|
|
.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],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const visibleLayouts = useMemo(() => {
|
|
|
|
|
const visibleIds = new Set(visibleWidgets.map(w => w.id))
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
BREAKPOINT_ORDER.map(bp => [bp, state.layouts[bp].filter(l => visibleIds.has(l.i))]),
|
|
|
|
|
) as Record<Breakpoint, LayoutItem[]>
|
|
|
|
|
}, [state.layouts, visibleWidgets])
|
|
|
|
|
|
|
|
|
|
const filters: Array<{ key: 'all' | Category; label: string }> = [
|
|
|
|
|
{ key: 'all', label: 'All widgets' },
|
|
|
|
|
{ key: 'sales', label: CATEGORY_META.sales.label },
|
|
|
|
|
{ key: 'ops', label: CATEGORY_META.ops.label },
|
|
|
|
|
{ key: 'stock', label: CATEGORY_META.stock.label },
|
|
|
|
|
{ key: 'finance', label: CATEGORY_META.finance.label },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
{editMode ? 'Drag to move, drag a corner to resize, or tick which widgets show.' : 'Add, remove, drag and resize widgets to make this view yours.'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="relative flex items-center gap-2">
|
|
|
|
|
{editMode && (
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => setShowPicker(v => !v)}>
|
|
|
|
|
<HugeiconsIcon icon={Add01Icon} size={14} />
|
|
|
|
|
Widgets
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button variant={editMode ? 'default' : 'outline'} size="sm" onClick={() => { setEditMode(v => !v); setShowPicker(false) }}>
|
|
|
|
|
{editMode ? 'Done' : 'Customize Dashboard'}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{showPicker && (
|
|
|
|
|
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-2xl border bg-popover p-3 shadow-lg">
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">Widgets</span>
|
|
|
|
|
<button onClick={() => setShowPicker(false)} aria-label="Close" className="rounded-full bg-muted p-1 text-muted-foreground">
|
|
|
|
|
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
{WIDGET_META.map(w => {
|
|
|
|
|
const checked = state.activeIds.includes(w.id)
|
|
|
|
|
return (
|
|
|
|
|
<label
|
|
|
|
|
key={w.id}
|
|
|
|
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<Checkbox checked={checked} onCheckedChange={(v) => toggleWidget(w.id, v === true)} />
|
|
|
|
|
<span className="text-sm text-foreground">{w.title}</span>
|
|
|
|
|
</label>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{filters.map(f => (
|
|
|
|
|
<button
|
|
|
|
|
key={f.key}
|
|
|
|
|
onClick={() => setCategory(f.key)}
|
|
|
|
|
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
|
|
|
|
category === f.key
|
|
|
|
|
? 'border-foreground bg-foreground text-background'
|
|
|
|
|
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{f.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading || !data ? (
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
|
|
|
<Skeleton key={i} className="h-40 rounded-2xl" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : visibleWidgets.length === 0 ? (
|
|
|
|
|
<div className="rounded-2xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
No widgets on this dashboard yet. Click “Widgets” to add some.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div ref={containerRef} className="w-full">
|
|
|
|
|
{mounted && (
|
|
|
|
|
<ResponsiveGridLayout
|
|
|
|
|
width={width}
|
|
|
|
|
layouts={visibleLayouts}
|
|
|
|
|
breakpoints={DEFAULT_BREAKPOINTS}
|
|
|
|
|
cols={DEFAULT_COLS}
|
|
|
|
|
rowHeight={ROW_HEIGHT}
|
|
|
|
|
margin={GRID_MARGIN}
|
|
|
|
|
containerPadding={[0, 0]}
|
|
|
|
|
maxRows={Infinity}
|
|
|
|
|
dragConfig={{ enabled: editMode }}
|
|
|
|
|
resizeConfig={{ enabled: editMode, handles: ['se'] }}
|
|
|
|
|
autoSize
|
|
|
|
|
onLayoutChange={handleLayoutChange}
|
|
|
|
|
>
|
|
|
|
|
{visibleWidgets.map(w => (
|
|
|
|
|
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
|
|
|
|
|
{editMode && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => toggleWidget(w.id, false)}
|
|
|
|
|
aria-label="Remove widget"
|
|
|
|
|
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<div className="mb-3.5 shrink-0">
|
|
|
|
|
<div className="text-sm font-semibold text-foreground">{w.title}</div>
|
|
|
|
|
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
|
|
|
|
|
</div>
|
|
|
|
|
<WidgetBody id={w.id} data={data} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</ResponsiveGridLayout>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|