feat: implement theme toggle and enhance UI components

This commit is contained in:
2026-07-23 12:53:26 +05:30
parent 5cf9588728
commit 7366ca93c0
11 changed files with 173 additions and 120 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ export default function DashboardLayout({
<div className="flex-1 overflow-auto">
<div className="p-6 lg:p-8">
<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">
{children}
</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() {
return (
<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).
</p>
</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>
)
}
@@ -238,17 +238,40 @@ function NewPurchaseOrderContent() {
{!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">
<Label className="text-base">Vendor</Label>
<Select<number | null> value={vendorId} onValueChange={setVendorId}>
<Label className="text-base">Vendor code</Label>
<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">
<SelectValue placeholder="Select vendor" />
<SelectValue placeholder="Select vendor code" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<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>
))}
</SelectContent>
@@ -113,7 +113,11 @@ export default function ItemDetailPage() {
try {
const result = await itemsApi.update(
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
)
applyItem(result.data)
@@ -385,13 +385,18 @@ export default function NewGrnPage() {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{mode === "direct" && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
)}
<div>
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{mode === "po" && (
<p className="text-sm text-muted-foreground">
Lines default from the PO&apos;s open quantities add a row for anything received that wasn&apos;t ordered.
</p>
)}
</div>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
{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 { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
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 { 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() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : ""
function load() {
warehousesApi
.list()
@@ -54,23 +73,21 @@ export default function WarehousesPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Warehouse code is required"
if (!name.trim()) nextErrors.name = "Warehouse name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code, name })
const warehouse = await warehousesApi.create({ code: generatedCode, name })
toast.success("Warehouse created", `${warehouse.code}${warehouse.name}`)
setOpen(false)
setCode("")
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about warehouses loaded when the dialog opened.
toast.error("Could not create warehouse", errorMessage(err))
} finally {
setSubmitting(false)
@@ -104,19 +121,18 @@ export default function WarehousesPage() {
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<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>
<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}>
<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} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</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>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
@@ -27,9 +28,12 @@ export default function RootLayout({
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
<TooltipProvider>{children}</TooltipProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</body>
</html>
);