feat(dashboard): add date filter and stat groups components
- Implemented DashboardDateFilter component for selecting date ranges. - Created DashboardStatGroups component to display various dashboard statistics. - Added dashboard chart data processing logic to support new chart specifications. - Introduced reusable Chart components for rendering different types of charts.
This commit is contained in:
@@ -2,74 +2,53 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
BadgeDollarSign,
|
||||
Boxes,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
ScrollText,
|
||||
Send,
|
||||
} from "lucide-react"
|
||||
import { ArrowDownLeft, ArrowUpRight, ScrollText } from "lucide-react"
|
||||
|
||||
import { dashboardApi } from "@/lib/api/dashboard"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { DashboardDateFilter, DashboardPeriodValue, periodBounds } from "@/components/dashboard/DashboardDateFilter"
|
||||
import { DashboardCharts } from "@/components/dashboard/DashboardCharts"
|
||||
import { DashboardStatGroups } from "@/components/dashboard/DashboardStatGroups"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
|
||||
const TREND_DAYS = 14
|
||||
const MOBILE_TREND_DAYS = 5
|
||||
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
|
||||
const CHART_MIN_SLOTS = 5
|
||||
|
||||
function isoDate(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const { activeBranchId, branchingEnabled, can } = useAuth()
|
||||
// The recent-movements table belongs to the Stock area of the dashboard (Settings -> Roles -> Dashboard).
|
||||
const canStock = can("dashboard.stock")
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [charts, setCharts] = useState<DashboardChartsData | null>(null)
|
||||
const [chartsFailed, setChartsFailed] = useState(false)
|
||||
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
|
||||
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [period, setPeriod] = useState<DashboardPeriodValue>({ period: "today" })
|
||||
const bounds = useMemo(() => periodBounds(period), [period])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - (TREND_DAYS - 1))
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
Promise.all([
|
||||
dashboardApi.stats({ branchId }),
|
||||
stockApi.ledger({ page: 1, pageSize: 5, branchId }),
|
||||
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }),
|
||||
warehousesApi.list(),
|
||||
canStock ? stockApi.ledger({ page: 1, pageSize: 5, branchId }) : Promise.resolve(null),
|
||||
// Only used to label warehouses; a role without the Warehouses screen just sees ids instead of an error.
|
||||
warehousesApi.list().catch(() => null),
|
||||
])
|
||||
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
|
||||
.then(([ledger, warehousesRes]) => {
|
||||
if (cancelled) return
|
||||
setStats(statsRes)
|
||||
setMovements(ledger.items)
|
||||
setTrend(trendRes.items)
|
||||
setWarehouses(warehousesRes.items)
|
||||
setMovements(ledger?.items ?? [])
|
||||
setWarehouses(warehousesRes?.items ?? [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
@@ -78,61 +57,62 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
}, [activeBranchId, branchingEnabled, canStock])
|
||||
|
||||
// Stats follow the date filter: the activity counts use its from–to period, the rest are snapshots.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
dashboardApi
|
||||
.stats({ branchId, from: bounds.from, to: bounds.to })
|
||||
.then((res) => {
|
||||
if (!cancelled) setStats(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
|
||||
|
||||
// Graph datasets follow the same date filter. A failure only blanks the graphs, not the whole dashboard.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
dashboardApi
|
||||
.charts({ branchId, from: bounds.from, to: bounds.to })
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setCharts(res)
|
||||
setChartsFailed(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setChartsFailed(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
|
||||
|
||||
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
|
||||
|
||||
// Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date.
|
||||
const movementTrend = useMemo(() => {
|
||||
const days: string[] = []
|
||||
const cursor = new Date()
|
||||
cursor.setDate(cursor.getDate() - (TREND_DAYS - 1))
|
||||
for (let i = 0; i < TREND_DAYS; i++) {
|
||||
days.push(isoDate(cursor))
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
const warehouseCodes = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w.code])), [warehouses])
|
||||
|
||||
const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" }))
|
||||
|
||||
const inByDay = new Map(days.map((d) => [d, 0]))
|
||||
const outByDay = new Map(days.map((d) => [d, 0]))
|
||||
for (const entry of trend ?? []) {
|
||||
const day = entry.createdAt.slice(0, 10)
|
||||
const bucket = entry.direction === "In" ? inByDay : outByDay
|
||||
if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase)
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
inData: days.map((d) => inByDay.get(d) ?? 0),
|
||||
outData: days.map((d) => outByDay.get(d) ?? 0),
|
||||
}
|
||||
}, [trend])
|
||||
|
||||
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
|
||||
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
|
||||
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
|
||||
const isMobile = useIsMobile()
|
||||
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
|
||||
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
|
||||
const shownTrend = useMemo(
|
||||
() => ({
|
||||
labels: movementTrend.labels.slice(-trendDays),
|
||||
inData: movementTrend.inData.slice(-trendDays),
|
||||
outData: movementTrend.outData.slice(-trendDays),
|
||||
}),
|
||||
[movementTrend, trendDays]
|
||||
)
|
||||
const valuationRows = stats?.stockValuationByWarehouse ?? []
|
||||
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
|
||||
|
||||
const loaded = stats && movements && trend && warehouses
|
||||
const loaded = stats && movements && warehouses
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="shrink-0">
|
||||
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||
</div>
|
||||
<DashboardDateFilter value={period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -141,232 +121,143 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
|
||||
compact stacked layout, so they would not match the stat cards on every other screen. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
<DashboardStatGroups from={bounds.from} to={bounds.to} stats={stats} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<DashboardCharts stats={stats} charts={charts} warehouseCodes={warehouseCodes} error={chartsFailed} />
|
||||
|
||||
{canStock && (
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
Recent Stock Movements
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<BarChart
|
||||
labels={[
|
||||
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`),
|
||||
...Array<string>(valuationPad).fill(""),
|
||||
]}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)],
|
||||
backgroundColor: "#E11D48",
|
||||
},
|
||||
]}
|
||||
barPercentage={0.6}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {trendDays} days)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
|
||||
<div className="h-64 sm:h-72">
|
||||
<LineChart
|
||||
labels={shownTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
Recent Stock Movements
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{movements.map((entry) => (
|
||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
movements.length > 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{movements.map((entry) => (
|
||||
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
||||
)}
|
||||
>
|
||||
{entry.direction === "In" ? (
|
||||
<ArrowDownLeft className="size-4 text-success" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction === "In" ? (
|
||||
<ArrowDownLeft className="size-4 text-success" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-destructive" />
|
||||
)}
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty</p>
|
||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Source</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
|
||||
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Qty</p>
|
||||
<p className="font-medium text-foreground">{entry.qtyBase}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Source</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-160">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-160">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||
<TableCell>
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.qtyBase}</TableCell>
|
||||
<TableCell>
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
--chart-3: oklch(0.582 0.183 142.495);
|
||||
--chart-4: oklch(0.787 0.141 72.031);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
/* Dashboard charts: one slate ink, tinted lighter for extra series, so every graph matches. */
|
||||
--chart-ink: #5b6b6e;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.98 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
@@ -120,6 +122,7 @@
|
||||
--chart-3: oklch(0.65 0.15 142.495);
|
||||
--chart-4: oklch(0.85 0.12 72.031);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--chart-ink: #a3b3b6;
|
||||
--sidebar: oklch(0.125 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||
|
||||
@@ -58,10 +58,8 @@ export interface StatCardProps {
|
||||
valueClassName?: string
|
||||
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
|
||||
iconClassName?: string
|
||||
/** Makes the card a toggle button that filters the list below it (click again to clear). */
|
||||
onClick?: () => void
|
||||
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
|
||||
active?: boolean
|
||||
/** Tighter padding and a smaller icon / text, for dense rows such as the dashboard. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,8 +78,7 @@ export function StatCard({
|
||||
className,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
onClick,
|
||||
active = false,
|
||||
compact = false,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
@@ -98,31 +95,36 @@ export function StatCard({
|
||||
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
|
||||
: {})}
|
||||
className={cn(
|
||||
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
className,
|
||||
// After className so the "selected" look wins over a card's own tint.
|
||||
active && "bg-primary/5 ring-2 ring-primary"
|
||||
"@container min-w-0 rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
compact ? "px-3 py-2.5" : "px-4 py-5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
|
||||
value keeps the full width instead of being cut off. */}
|
||||
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
// Compact cards keep the icon beside the text at every width; the regular card stacks it above when narrow.
|
||||
compact ? "flex-row items-center gap-2.5" : "flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5"
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
|
||||
"flex shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary",
|
||||
compact ? "size-9" : "size-9 @[12.5rem]:size-11",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
|
||||
<Icon className={compact ? "size-4" : "size-4.5 @[12.5rem]:size-5"} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-muted-foreground", compact ? "text-xs leading-tight" : "truncate text-sm")}>{label}</p>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
|
||||
<p className={cn("leading-tight font-semibold tracking-tight wrap-break-word text-foreground", compact ? "text-base" : "text-base @[12.5rem]:text-lg", valueClassName)}>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
|
||||
|
||||
export const dashboardApi = {
|
||||
stats(params: { branchId?: number } = {}): Promise<DashboardStats> {
|
||||
/** `from` / `to` (YYYY-MM-DD) set the period for the activity counts; omitted means today. */
|
||||
stats(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardStats> {
|
||||
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/** Datasets for the dashboard graphs; the time-based ones follow `from` / `to` (YYYY-MM-DD, default today). */
|
||||
charts(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardChartsData> {
|
||||
return apiRequest<DashboardChartsData>(`/dashboard/charts${buildQuery(params)}`)
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+331
-1
@@ -16,6 +16,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.5.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -1909,6 +1911,42 @@
|
||||
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
|
||||
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.2.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.3.tgz",
|
||||
"integrity": "sha512-FbR9W6jf7+tvOCuDmOLJy3yStLCvoRDODRARzfeaWmn75+1fW4hFbMv5X5cWgEkxgWcC/eC5Nva00ZV1iv+0EA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1934,6 +1972,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -2398,6 +2442,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
@@ -2413,6 +2463,12 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@@ -2422,12 +2478,48 @@
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
@@ -2518,6 +2610,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -3916,6 +4014,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cn": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cn/-/cn-0.5.0.tgz",
|
||||
"integrity": "sha512-JGEixZEpfFXm1P5uXwvEobOFLlHwIWi5v4tjd1ZwfiYmcEilkuD2l+054rvJcYCR1B+uVThHeUbgmNz1FwuxuA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cn": "bin/cn.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/code-block-writer": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||
@@ -4186,6 +4296,18 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -4226,6 +4348,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
@@ -4238,6 +4369,31 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
@@ -4247,6 +4403,42 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@@ -4394,6 +4586,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -4861,6 +5059,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5317,6 +5527,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -6093,6 +6309,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6140,6 +6366,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -8484,7 +8719,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-loading-skeleton": {
|
||||
@@ -8496,6 +8730,29 @@
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.12",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz",
|
||||
@@ -8512,6 +8769,57 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10090,6 +10398,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.5.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -20,6 +20,36 @@ export interface DashboardStats {
|
||||
pendingLeaveRequests: number
|
||||
overdueSalesInvoices: number
|
||||
monthlyPurchasesVsSales: MonthlyPurchasesVsSales[]
|
||||
// Stock & warehouse
|
||||
batchesExpiringSoon: number
|
||||
transfersInTransit: number
|
||||
draftStockAdjustments: number
|
||||
wastagePendingApproval: number
|
||||
// Approvals
|
||||
pendingApprovals: number
|
||||
priceAdjustmentsPending: number
|
||||
// Sales (salesReturns and bundleSales are counted inside the requested from–to period)
|
||||
draftSalesInvoices: number
|
||||
draftSalesSlips: number
|
||||
salesReturns: number
|
||||
bundleSales: number
|
||||
// Purchasing
|
||||
purchaseReturnsPending: number
|
||||
unpaidPurchaseInvoices: number
|
||||
// Money (cashInHand is the closing figure at the end of the period; bankDeposits is summed inside it)
|
||||
cashInHand: number
|
||||
bankDeposits: number
|
||||
// Service & warranty
|
||||
openSupportTickets: number
|
||||
warrantiesExpiringSoon: number
|
||||
warrantyClaimsOpen: number
|
||||
// Production
|
||||
productionRunsInProgress: number
|
||||
// HR (employeesPresent is counted inside the period)
|
||||
payrollRunsPending: number
|
||||
attendanceBatchesPending: number
|
||||
employeesPresent: number
|
||||
totalActiveEmployees: number
|
||||
}
|
||||
|
||||
// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM".
|
||||
@@ -29,3 +59,53 @@ export interface MonthlyPurchasesVsSales {
|
||||
purchases: number
|
||||
sales: number
|
||||
}
|
||||
|
||||
// GET /dashboard/charts — every dataset behind the dashboard graphs (Backend/ERPCore/Dtos/Dashboard/DashboardChartsDtos.cs).
|
||||
// Time-based sets follow the requested from–to period; ageing, batches expiring, low stock and warranties are snapshots.
|
||||
export interface ChartPoint {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
/** Two values under one label: stock in / out by warehouse, tickets opened / resolved by day. */
|
||||
export interface ChartPair {
|
||||
label: string
|
||||
first: number
|
||||
second: number
|
||||
}
|
||||
|
||||
export interface DailySalesPoint {
|
||||
label: string
|
||||
sales: number
|
||||
grossProfit: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
export interface DocumentStatusPoint {
|
||||
doc: string
|
||||
draft: number
|
||||
posted: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
export interface DashboardChartsData {
|
||||
dailySales: DailySalesPoint[]
|
||||
paymentSplit: ChartPoint[]
|
||||
topItems: ChartPoint[]
|
||||
salesByCategory: ChartPoint[]
|
||||
salesByCustomer: ChartPoint[]
|
||||
documentStatus: DocumentStatusPoint[]
|
||||
cashInHandByDay: ChartPoint[]
|
||||
receivablesAgeing: ChartPoint[]
|
||||
payablesAgeing: ChartPoint[]
|
||||
expenseBreakdown: ChartPoint[]
|
||||
topVendors: ChartPoint[]
|
||||
stockInVsOut: ChartPair[]
|
||||
batchesExpiring: ChartPoint[]
|
||||
attendanceMix: ChartPoint[]
|
||||
payrollCostByMonth: ChartPoint[]
|
||||
leaveStatus: ChartPoint[]
|
||||
ticketsOverTime: ChartPair[]
|
||||
productionStatus: ChartPoint[]
|
||||
warrantyExpiryByMonth: ChartPoint[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user