feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement. - Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data. - Developed `DashboardService` to fetch and compute necessary statistics from the database. - Added `IDashboardService` interface for service abstraction. - Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint. - Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend. - Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
This commit is contained in:
@@ -1,325 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertTriangle,
|
||||
BadgeDollarSign,
|
||||
Boxes,
|
||||
Clock,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
ScrollText,
|
||||
Send,
|
||||
} 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 { RecentOrdersTable } from "@/components/dashboard/recent-orders-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
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 PieChart from "@/components/ui/pie-chart"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
|
||||
const indigoButton =
|
||||
"bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40"
|
||||
const TREND_DAYS = 14
|
||||
|
||||
function isoDate(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// Shown only when the last TREND_DAYS days have no real ledger activity, so the
|
||||
// chart isn't a flat zero line before there's any real movement to plot.
|
||||
const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72]
|
||||
const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58]
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [date, setDate] = React.useState<Date>()
|
||||
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - (TREND_DAYS - 1))
|
||||
|
||||
Promise.all([
|
||||
dashboardApi.stats(),
|
||||
stockApi.ledger({ page: 1, pageSize: 5 }),
|
||||
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }),
|
||||
warehousesApi.list(),
|
||||
])
|
||||
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
|
||||
if (cancelled) return
|
||||
setStats(statsRes)
|
||||
setMovements(ledger.items)
|
||||
setTrend(trendRes.items)
|
||||
setWarehouses(warehousesRes.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
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 labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" }))
|
||||
|
||||
if (!trend || trend.length === 0) {
|
||||
return { labels, inData: SAMPLE_TREND_IN, outData: SAMPLE_TREND_OUT }
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const loaded = stats && movements && trend && warehouses
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Basic */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Product Detail</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<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>
|
||||
|
||||
{/* With ellipsis */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbEllipsis />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/purchase-orders">
|
||||
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</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">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
<Button variant="success">Success</Button>
|
||||
<Button variant="warning">Warning</Button>
|
||||
<Button variant="info">Info</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="xs" className={indigoButton}>
|
||||
Extra Small
|
||||
</Button>
|
||||
<Button size="sm" className={indigoButton}>
|
||||
Small
|
||||
</Button>
|
||||
<Button size="default" className={indigoButton}>
|
||||
Default
|
||||
</Button>
|
||||
<Button size="lg" className={indigoButton}>
|
||||
Large
|
||||
</Button>
|
||||
<Button size="lg" className={cn(indigoButton, "h-12 px-8 text-base")}>
|
||||
Extra Large
|
||||
</Button>
|
||||
<Button size="icon-sm" className={indigoButton} aria-label="Add (small)">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="icon" className={indigoButton} aria-label="Add">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="icon-lg" className={indigoButton} aria-label="Add (large)">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className={cn(indigoButton, "size-12")}
|
||||
aria-label="Add (extra large)"
|
||||
>
|
||||
<Plus className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Date Picker</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Single date</span>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder="Select a date"
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#6366f1",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Date range</span>
|
||||
<DateRangePicker
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
placeholder="Select date range"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(date || range?.from) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{date && <>Selected: <span className="font-medium text-foreground">{date.toLocaleDateString()}</span></>}
|
||||
{range?.from && (
|
||||
<>
|
||||
{date && " · "}
|
||||
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span>
|
||||
{range.to && <> – <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p>
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} />
|
||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircle2 className="size-7 text-success" />
|
||||
</div>
|
||||
<DialogTitle className="text-lg font-bold">Order confirmed</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your order has been placed successfully and is now being processed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-center px-2 pb-2 sm:px-4">
|
||||
<Button size="lg" className="w-full sm:w-auto sm:px-10">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => toast({ title: "Default toast message" })}>
|
||||
Default
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => toast.success("Success!", "Your changes have been saved.")}>
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => toast.error("Error", "Something went wrong. Please try again.")}>
|
||||
Error
|
||||
</Button>
|
||||
<Button variant="warning" onClick={() => toast.warning("Warning", "This action cannot be undone.")}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="info" onClick={() => toast.info("Info", "Your session will expire in 5 minutes.")}>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
toast({
|
||||
title: "With Action",
|
||||
description: "Do you want to undo this change?",
|
||||
actionLabel: "Undo",
|
||||
onAction: () => toast.success("Undone!", "Change has been reverted."),
|
||||
})
|
||||
}
|
||||
>
|
||||
With Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="info">Info</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="info"
|
||||
title="Update available"
|
||||
description="A new version is ready. Reload the page to apply the latest changes."
|
||||
confirmLabel="Reload"
|
||||
onConfirm={() => toast.info("Reloading...", "Applying the latest update.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="success">Success</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title="Order confirmed"
|
||||
description="Your order has been placed successfully and is now being processed."
|
||||
confirmLabel="Continue"
|
||||
cancelLabel="View order"
|
||||
onConfirm={() => toast.success("Done!", "Redirecting to dashboard.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="warning">Warning</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Unsaved changes"
|
||||
description="You have unsaved changes. Leaving this page will discard them."
|
||||
confirmLabel="Leave anyway"
|
||||
onConfirm={() => toast.warning("Changes discarded")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="destructive">Delete</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title="Delete record?"
|
||||
description="This action is permanent and cannot be undone. All associated data will be removed."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => toast.error("Deleted", "The record has been permanently removed.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</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 {TREND_DAYS} days)
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Total Revenue" value={45231.89} icon={DollarSign} />
|
||||
<StatCard label="Orders" value={1284} icon={ShoppingCart} />
|
||||
<StatCard label="Customers" value={892} icon={Users} />
|
||||
<StatCard label="Low Stock Items" value={16} icon={Package} />
|
||||
</div>
|
||||
|
||||
<RecentOrdersTable />
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3>
|
||||
<div className="h-56">
|
||||
{loaded ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<LineChart
|
||||
labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}
|
||||
datasets={[{ label: "Sales", data: [120, 200, 150, 220, 180, 260] }]}
|
||||
labels={movementTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</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>
|
||||
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
|
||||
<div className="h-56">
|
||||
<BarChart
|
||||
labels={["Q1", "Q2", "Q3", "Q4"]}
|
||||
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
|
||||
<div className="h-56">
|
||||
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
|
||||
</div>
|
||||
</div>
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<Table>
|
||||
<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 className="text-muted-foreground">
|
||||
{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 className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -156,14 +156,9 @@ export default function BrandsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -239,21 +234,21 @@ export default function BrandsPage() {
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -364,7 +359,7 @@ function SortableHeader({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
@@ -376,7 +371,7 @@ function SortableHeader({
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -155,14 +155,9 @@ export default function CategoriesPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -238,21 +233,21 @@ export default function CategoriesPage() {
|
||||
{!error && categories !== null && categories.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -371,7 +366,7 @@ function SortableHeader({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
@@ -383,7 +378,7 @@ function SortableHeader({
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user