feat: implement theme toggle and enhance UI components
This commit is contained in:
@@ -18,7 +18,7 @@ export default function DashboardLayout({
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="p-6 lg:p-8">
|
<div className="p-6 lg:p-8">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
<div className="rounded-xl bg-card border border-border shadow-sm">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,3 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
|
|
||||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
|
||||||
{
|
|
||||||
title: "Requisitions",
|
|
||||||
description: "Raise a purchase requisition and submit it into procurement.",
|
|
||||||
href: "/dashboard/procurement/requisitions",
|
|
||||||
icon: ClipboardList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "RFQs",
|
|
||||||
description: "Request quotations from vendors, record pricing, and compare side by side.",
|
|
||||||
href: "/dashboard/procurement/rfqs",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Purchase Orders",
|
|
||||||
description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.",
|
|
||||||
href: "/dashboard/procurement/purchase-orders",
|
|
||||||
icon: ShoppingCart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Purchase Returns",
|
|
||||||
description: "Return received goods to a vendor, referencing the original GRN line.",
|
|
||||||
href: "/dashboard/procurement/purchase-returns",
|
|
||||||
icon: PackageX,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ProcurementHubPage() {
|
export default function ProcurementHubPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -39,26 +7,6 @@ export default function ProcurementHubPage() {
|
|||||||
Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).
|
Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{areas.map((area) => (
|
|
||||||
<Link key={area.href} href={area.href}>
|
|
||||||
<Card className="h-full transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
||||||
<area.icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,17 +238,40 @@ function NewPurchaseOrderContent() {
|
|||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Vendor</Label>
|
<Label className="text-base">Vendor code</Label>
|
||||||
<Select<number | null> value={vendorId} onValueChange={setVendorId}>
|
<Select<number | null>
|
||||||
|
value={vendorId}
|
||||||
|
onValueChange={setVendorId}
|
||||||
|
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="Select vendor" />
|
<SelectValue placeholder="Select vendor code" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(vendors ?? []).map((v) => (
|
{(vendors ?? []).map((v) => (
|
||||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||||
{v.code} — {v.name}
|
{v.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Vendor name</Label>
|
||||||
|
<Select<number | null>
|
||||||
|
value={vendorId}
|
||||||
|
onValueChange={setVendorId}
|
||||||
|
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select vendor name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(vendors ?? []).map((v) => (
|
||||||
|
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||||
|
{v.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ export default function ItemDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await itemsApi.update(
|
const result = await itemsApi.update(
|
||||||
item.itemId,
|
item.itemId,
|
||||||
{ sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null },
|
{
|
||||||
|
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
||||||
|
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
|
||||||
|
taxClass: taxClass || null,
|
||||||
|
},
|
||||||
etag
|
etag
|
||||||
)
|
)
|
||||||
applyItem(result.data)
|
applyItem(result.data)
|
||||||
|
|||||||
@@ -385,13 +385,18 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<div>
|
||||||
{mode === "direct" && (
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
{mode === "po" && (
|
||||||
<Plus className="size-5" />
|
<p className="text-sm text-muted-foreground">
|
||||||
Add line
|
Lines default from the PO's open quantities — add a row for anything received that wasn't ordered.
|
||||||
</Button>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
Add line
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
|||||||
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||||
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Bin, Warehouse } from "@/types/master-data"
|
import { Bin, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
@@ -24,17 +24,36 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
|
||||||
|
* so an empty/punctuation-only name still yields a usable base. */
|
||||||
|
function warehouseCodeBase(name: string): string {
|
||||||
|
const firstWord = name.trim().split(/\s+/)[0] ?? ""
|
||||||
|
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||||
|
return cleaned.slice(0, 10) || "WH"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
|
||||||
|
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
|
||||||
|
function generateWarehouseCode(name: string, existingCodes: string[]): string {
|
||||||
|
const base = `WH-${warehouseCodeBase(name)}`
|
||||||
|
if (!existingCodes.includes(base)) return base
|
||||||
|
let suffix = 2
|
||||||
|
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
|
||||||
|
return `${base}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function WarehousesPage() {
|
export default function WarehousesPage() {
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
const [bins, setBins] = useState<Bin[] | null>(null)
|
const [bins, setBins] = useState<Bin[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : ""
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
warehousesApi
|
warehousesApi
|
||||||
.list()
|
.list()
|
||||||
@@ -54,23 +73,21 @@ export default function WarehousesPage() {
|
|||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!code.trim()) nextErrors.code = "Warehouse code is required"
|
|
||||||
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const warehouse = await warehousesApi.create({ code, name })
|
const warehouse = await warehousesApi.create({ code: generatedCode, name })
|
||||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setCode("")
|
|
||||||
setName("")
|
setName("")
|
||||||
setErrors({})
|
setErrors({})
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fe = fieldErrors(err)
|
// A 409 here means another creation raced ours for the same generated code — the
|
||||||
if (fe?.code) setErrors({ code: fe.code })
|
// proactive de-dupe above only knows about warehouses loaded when the dialog opened.
|
||||||
toast.error("Could not create warehouse", errorMessage(err))
|
toast.error("Could not create warehouse", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -104,19 +121,18 @@ export default function WarehousesPage() {
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New warehouse</DialogTitle>
|
<DialogTitle>New warehouse</DialogTitle>
|
||||||
<DialogDescription>Create a new warehouse. Bins are added from its detail page.</DialogDescription>
|
<DialogDescription>Create a new warehouse. Its code is generated from the name. Bins are added from its detail page.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.code}>
|
|
||||||
<FieldLabel htmlFor="wh-code">Code</FieldLabel>
|
|
||||||
<Input id="wh-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} />
|
|
||||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
|
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
|
||||||
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
|
||||||
|
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||||
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex justify-center gap-3 pt-2">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -27,9 +28,12 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const navItems: {
|
|||||||
title: string
|
title: string
|
||||||
code: string
|
code: string
|
||||||
href: string
|
href: string
|
||||||
|
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
||||||
|
* stays the section prefix used to decide whether this row is "active". */
|
||||||
|
landingHref?: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
chevron?: boolean
|
chevron?: boolean
|
||||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||||
@@ -57,18 +60,21 @@ const navItems: {
|
|||||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
|
||||||
{
|
{
|
||||||
title: "Procurement",
|
title: "Procurement",
|
||||||
code: "procurement",
|
code: "procurement",
|
||||||
href: "/dashboard/procurement",
|
href: "/dashboard/procurement",
|
||||||
|
// Clicking "Procurement" itself lands on Purchase Orders — the hub page underneath
|
||||||
|
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
|
||||||
|
landingHref: "/dashboard/procurement/purchase-orders",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
chevron: true,
|
chevron: true,
|
||||||
children: [
|
children: [
|
||||||
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
|
|
||||||
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
|
||||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||||
|
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
|
||||||
|
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
|
||||||
|
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
@@ -126,7 +132,7 @@ function SidebarContent({
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-300 ease-in-out",
|
"flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
||||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -138,13 +144,13 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-indigo-600">
|
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{!iconOnly && (
|
{!iconOnly && (
|
||||||
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
|
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -152,7 +158,7 @@ function SidebarContent({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={isMobile ? onClose : onCollapse}
|
onClick={isMobile ? onClose : onCollapse}
|
||||||
aria-label={isMobile ? "Close menu" : collapsed ? "Expand sidebar" : "Minimize sidebar"}
|
aria-label={isMobile ? "Close menu" : collapsed ? "Expand sidebar" : "Minimize sidebar"}
|
||||||
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-slate-400 hover:bg-slate-50 hover:text-slate-600"
|
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
{isMobile ? <X className="size-4" /> : <Menu className="size-4" />}
|
{isMobile ? <X className="size-4" /> : <Menu className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -172,28 +178,28 @@ function SidebarContent({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-2xl transition-colors",
|
"flex items-center rounded-2xl transition-colors",
|
||||||
isActive ? "bg-indigo-50" : "hover:bg-slate-50"
|
isActive ? "bg-primary/10" : "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.landingHref ?? item.href}
|
||||||
title={iconOnly ? item.title : undefined}
|
title={iconOnly ? item.title : undefined}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
|
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
|
||||||
iconOnly && "justify-center px-0",
|
iconOnly && "justify-center px-0",
|
||||||
isActive ? "text-indigo-600" : "text-slate-700"
|
isActive ? "text-primary" : "text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
|
className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||||
/>
|
/>
|
||||||
{!iconOnly && (
|
{!iconOnly && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1">{item.title}</span>
|
<span className="flex-1">{item.title}</span>
|
||||||
{item.chevron && !hasChildren && (
|
{item.chevron && !hasChildren && (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
|
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -207,8 +213,8 @@ function SidebarContent({
|
|||||||
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
|
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60",
|
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
|
||||||
isActive ? "text-indigo-500" : "text-slate-400"
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -245,12 +251,12 @@ function SidebarContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||||
childActive
|
childActive
|
||||||
? "bg-indigo-50 text-indigo-600"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<child.icon
|
<child.icon
|
||||||
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
|
className={cn("size-4 shrink-0", childActive ? "text-primary" : "text-muted-foreground")}
|
||||||
/>
|
/>
|
||||||
{child.title}
|
{child.title}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -268,8 +274,8 @@ function SidebarContent({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="flex items-center justify-center pt-6">
|
<div className="flex items-center justify-center pt-6">
|
||||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
|
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
|
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
|
||||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,13 +293,18 @@ export function AppSidebar() {
|
|||||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
// is visible if its own code is granted, or (for parents) if any child is.
|
// is visible if its own code is granted, or (for parents) if any child is.
|
||||||
|
//
|
||||||
|
// "procurement" is exempted from that check (frontend-only): no role is currently
|
||||||
|
// seeded with NAV:procurement or its children server-side, which would hide the whole
|
||||||
|
// section for everyone. Remove this bypass once roles are granted the permission
|
||||||
|
// properly (Settings → Roles → Sidebar permissions) or a backend seed grants it.
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
.filter((item) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
children: item.children?.filter((c) => navCodes.includes(c.code)),
|
children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
@@ -326,7 +337,7 @@ export function AppSidebar() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => setMobileOpen(true)}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-white shadow-sm ring-1 ring-black/5 text-slate-600 hover:bg-slate-50 lg:hidden"
|
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { authApi } from "@/lib/api/auth"
|
|||||||
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
||||||
import { AuthUser } from "@/types/auth"
|
import { AuthUser } from "@/types/auth"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
@@ -163,48 +164,50 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
|
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-4 pl-14 shadow-sm ring-1 ring-foreground/10 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{showBackButton && (
|
{showBackButton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
aria-label="Go back"
|
aria-label="Go back"
|
||||||
className="flex size-9 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-5" />
|
<ArrowLeft className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-base font-bold tracking-tight text-slate-900 sm:text-xl">{title}</h1>
|
<h1 className="text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bell className="size-5" />
|
<Bell className="size-5" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-indigo-600 p-0" />
|
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" />
|
||||||
)}
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="end"
|
align="end"
|
||||||
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
|
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-black/5 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3">
|
||||||
<p className="text-sm font-semibold text-slate-900">Notifications</p>
|
<p className="text-sm font-semibold text-foreground">Notifications</p>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={markAllAsRead}
|
onClick={markAllAsRead}
|
||||||
className="text-xs font-medium text-indigo-600 hover:underline"
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Mark all as read
|
Mark all as read
|
||||||
</button>
|
</button>
|
||||||
@@ -221,22 +224,22 @@ export function Header() {
|
|||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-3 border-b border-black/5 px-4 py-3 last:border-0",
|
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
|
||||||
notification.unread && "bg-indigo-50/50"
|
notification.unread && "bg-primary/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||||
notification.unread ? "bg-indigo-600" : "bg-transparent"
|
notification.unread ? "bg-primary" : "bg-transparent"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-slate-900">{notification.title}</p>
|
<p className="text-sm font-medium text-foreground">{notification.title}</p>
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
{notification.description}
|
{notification.description}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">{notification.time}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -246,19 +249,19 @@ export function Header() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
|
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
|
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||||
{initials(displayName(user))}
|
{initials(displayName(user))}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
|
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
||||||
{displayName(user)}
|
{displayName(user)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||||
<div className="px-2 py-2.5">
|
<div className="px-2 py-2.5">
|
||||||
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
|
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
|
||||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an empty slot until mounted: `resolvedTheme` is unknown on the server (and on
|
||||||
|
* the client's first paint, before next-themes reads localStorage), so rendering an icon
|
||||||
|
* before that would either be wrong or cause a hydration mismatch.
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ className }: { className?: string }) {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className={cn("size-10 shrink-0", className)} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user