ui fixers in 10/16

This commit is contained in:
2026-10-16 14:34:28 +05:30
parent 379ad204bd
commit e289b8430f
47 changed files with 1904 additions and 1046 deletions
@@ -46,7 +46,6 @@ import {
Receipt,
ReceiptText,
Scale,
ScanLine,
ScrollText,
Settings,
ShieldCheck,
@@ -62,12 +61,8 @@ import {
type LucideIcon,
} from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
const SERIALS_HREF = "/dashboard/stock/serials"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
@@ -109,8 +104,6 @@ const navItems: {
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
],
},
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
{
title: "Procurement",
code: "procurement",
@@ -159,9 +152,6 @@ const navItems: {
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
@@ -250,7 +240,6 @@ function SidebarContent({
onClose,
pathname,
isMobile,
approvalCount,
}: {
items: typeof navItems
collapsed: boolean
@@ -258,8 +247,6 @@ function SidebarContent({
onClose?: () => void
pathname: string
isMobile: boolean
/** Requests the user can approve — the badge on Approvals. */
approvalCount: number
}) {
const iconOnly = !isMobile && collapsed
@@ -381,11 +368,6 @@ function SidebarContent({
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <span className="flex-1">{item.title}</span>}
{item.code === "approvals" && approvalCount > 0 && (
<span className="ml-auto rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums">
{approvalCount}
</span>
)}
</Link>
)}
</div>
@@ -453,8 +435,6 @@ export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { navCodes, loading } = useAuth()
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
const { serialNumbersEnabled } = useTrackingSettings()
// 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
@@ -468,16 +448,8 @@ export function AppSidebar() {
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry).
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
// someone has most likely just approved or submitted something.
const [approvalCount, setApprovalCount] = useState(0)
useEffect(() => {
if (loading) return
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
}, [loading, pathname])
const visibleItems = loading
? []
: navItems
@@ -492,7 +464,7 @@ export function AppSidebar() {
children: (bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)),
),
}))
// Close mobile menu on route change
@@ -517,7 +489,6 @@ export function AppSidebar() {
onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname}
isMobile={false}
approvalCount={approvalCount}
/>
</div>
@@ -554,7 +525,6 @@ export function AppSidebar() {
onClose={() => setMobileOpen(false)}
pathname={pathname}
isMobile={true}
approvalCount={approvalCount}
/>
</div>
</>
@@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record<string, string> = {
vendors: "Vendors",
warehouse: "Warehouses",
receiving: "Receiving",
grn: "Goods Receipt Notes",
grn: "Goods Received Notes",
stock: "Stock",
enquiry: "Enquiry",
ledger: "Ledger",
@@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record<string, string> = {
"purchase-invoices": "Purchase Invoices",
"purchase-returns": "Purchase Returns",
production: "Production",
hrm: "HRM",
templates: "Templates",
runs: "Runs",
invoices: "Sales",
"day-end": "Approved Invoices",
}
function labelFor(segment: string): string {
@@ -51,6 +53,17 @@ function labelFor(segment: string): string {
return segment.charAt(0).toUpperCase() + segment.slice(1)
}
/**
* Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the
* same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.)
*/
const GROUP_LANDING: Record<string, string> = {
"/dashboard/production": "/dashboard/production/runs",
"/dashboard/receiving": "/dashboard/receiving/grn",
"/dashboard/sales/bundles": "/dashboard/sales/bundles/templates",
"/dashboard/help/tickets": "/dashboard/help",
}
const linkClass =
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
@@ -61,10 +74,10 @@ export function Breadcrumbs() {
// Nothing to show above the dashboard root itself.
if (segments.length <= 1) return null
const crumbs = segments.map((segment, i) => ({
href: "/" + segments.slice(0, i + 1).join("/"),
label: labelFor(segment),
}))
const crumbs = segments.map((segment, i) => {
const path = "/" + segments.slice(0, i + 1).join("/")
return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) }
})
return (
<Breadcrumb className="mb-4 print:hidden">
@@ -72,7 +85,7 @@ export function Breadcrumbs() {
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<Fragment key={crumb.href}>
<Fragment key={crumb.key}>
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
@@ -108,10 +108,12 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note"
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
@@ -336,7 +338,8 @@ export function Header() {
<Building2 className="size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* Open below the pill (not over it), left edges lined up; at least 14rem wide, or the pill's width if that is wider. */}
<SelectContent alignItemWithTrigger={false} side="bottom" align="start" sideOffset={6} className="w-auto min-w-[max(var(--anchor-width),14rem)] p-1">
<SelectItem value={ALL_BRANCHES}>All Branches</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>
@@ -425,10 +425,10 @@ export function AddTitleDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Adding…" : "Add title"}
</Button>
</DialogFooter>
@@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
</DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground">
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
Pay the full balance or change the amount to pay part of it now.{" "}
@@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
</div>
{accountsError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
Retry
@@ -220,11 +223,13 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
</Button>
</DialogFooter>
@@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground">
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
@@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
</Button>
</DialogFooter>
@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
import { Brand } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
/**
* Creates a brand from a popup — used by the Brand tab and inline from New Item. Same
* validation and endpoint as before; the caller receives the created row.
*/
export function AddBrandDialog({
open,
onOpenChange,
onCreated,
description = "The new brand is selected for this item and offered on other items too.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (brand: Brand) => void
description?: string
}) {
const [name, setName] = useState("")
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function close(next: boolean) {
if (submitting) return
if (!next) {
setName("")
setError(null)
}
onOpenChange(next)
}
async function handleSave() {
const invalid = validateBrandName(name).name
if (invalid) {
setError(invalid)
toast.error("Could not create brand", invalid)
return
}
setSubmitting(true)
try {
const { data: brand } = await brandsApi.create({ name: name.trim() })
toast.success("Brand created", brand.name)
onCreated(brand)
setName("")
setError(null)
onOpenChange(false)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add brand</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="new-brand-name" className="text-base" required>
Name
</Label>
<Input
id="new-brand-name"
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}}
placeholder="e.g. Bosch"
aria-invalid={!!error}
className="h-12 text-base"
/>
<FieldError errors={[error ? { message: error } : undefined]} />
</div>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
{submitting ? "Saving…" : "Add Brand"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
/**
* Creates a UOM without leaving the current form (e.g. New Item). Same validation and
* endpoint as the UOM tab; the caller receives the created row to add and select it.
*/
export function AddUomDialog({
open,
onOpenChange,
onCreated,
description = "The new UOM is selected for this item and offered on other items too.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (uom: Uom) => void
description?: string
}) {
const [name, setName] = useState("")
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function close(next: boolean) {
if (submitting) return
if (!next) {
setName("")
setError(null)
}
onOpenChange(next)
}
async function handleSave() {
const invalid = validateUomName(name).name
if (invalid) {
setError(invalid)
toast.error("Could not create UOM", invalid)
return
}
setSubmitting(true)
try {
const uom = await uomsApi.create({ name: name.trim() })
toast.success("UOM created", uom.name)
onCreated(uom)
setName("")
setError(null)
onOpenChange(false)
} catch (err) {
setError(fieldErrors(err)?.name ?? errorMessage(err))
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add unit of measure</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="new-uom-name" className="text-base" required>
Name
</Label>
<Input
id="new-uom-name"
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}}
placeholder="e.g. Box-12"
aria-invalid={!!error}
className="h-12 text-base"
/>
<FieldError errors={[error ? { message: error } : undefined]} />
</div>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={handleSave} disabled={submitting}>
{submitting ? "Saving…" : "Add UOM"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -8,6 +8,7 @@ import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
import { Brand } from "@/types/master-data"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -25,9 +26,7 @@ export function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
@@ -41,34 +40,14 @@ export function BrandPanel() {
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load brands", errorMessage(err))
})
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create brand", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
@@ -119,25 +98,18 @@ export function BrandPanel() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<div className="flex justify-end">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
<AddBrandDialog
open={addOpen}
onOpenChange={setAddOpen}
onCreated={load}
description="Brands are offered on every item."
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -4,13 +4,12 @@ import { useEffect, useState } from "react"
import { Plus, Ruler } from "lucide-react"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { errorMessage } from "@/lib/error-map"
import { Uom } from "@/types/master-data"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -22,16 +21,17 @@ export function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load UOMs", errorMessage(err))
})
}
useEffect(load, [])
@@ -50,51 +50,20 @@ export function UomPanel() {
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create UOM", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<div className="flex justify-end">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setAddOpen(true)}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
<AddUomDialog
open={addOpen}
onOpenChange={setAddOpen}
onCreated={load}
description="Units of measure are offered on every item."
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -0,0 +1,38 @@
"use client"
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
/**
* Serial / warranty numbers shown as From – To ranges instead of one row per unit. A single
* run reads inline ("10 serial numbers: SN-001 – SN-010"); several runs expand behind a toggle.
*/
export function SerialRanges({ label, values }: { label: string; values: string[] }) {
const ranges = toSerialRanges(values)
if (ranges.length === 0) return null
const total = ranges.reduce((n, r) => n + r.count, 0)
const heading = `${total} ${label}${total === 1 ? "" : "s"}`
if (ranges.length <= 2) {
return (
<p className="break-all">
{heading}: <span className="font-mono font-medium text-foreground">{formatSerialRanges(ranges)}</span>
</p>
)
}
return (
<details>
<summary className="cursor-pointer select-none">
{heading} in {ranges.length} ranges: <span className="font-mono text-foreground">{formatSerialRanges(ranges)}</span>
</summary>
<ul className="mt-1 flex max-h-48 flex-col gap-0.5 overflow-y-auto font-mono">
{ranges.map((r) => (
<li key={r.from} className="flex justify-between gap-4">
<span className="break-all text-foreground">{r.count === 1 ? r.from : `${r.from} – ${r.to}`}</span>
<span className="shrink-0 tabular-nums">×{r.count}</span>
</li>
))}
</ul>
</details>
)
}
@@ -1,14 +1,41 @@
"use client"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js"
import { useTheme } from "next-themes"
import { Line } from "react-chartjs-2"
import { useIsMobile } from "@/hooks/use-mobile"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { DayEndReportDay } from "@/types/sales"
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */
const MOBILE_POINTS = 5
/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the
* legend box is made taller after it measures itself — the extra height sits below its items. */
const LEGEND_GAP_PX = 16
const legendGap: Plugin<"line"> = {
id: "legendGap",
beforeInit(chart) {
const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined
if (!legend) return
const fit = legend.fit.bind(legend)
legend.fit = function () {
fit()
this.height += LEGEND_GAP_PX
}
},
}
/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */
const LEGEND_TEXT_GAP = String.fromCharCode(0x2002)
/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */
const compactAmount = (v: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v)
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
const SERIES = {
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
@@ -17,9 +44,12 @@ const SERIES = {
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) {
const { resolvedTheme } = useTheme()
const isMobile = useIsMobile()
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays
const trimmed = days.length < allDays.length
const labels = days.map((d) => formatReportDate(d.date))
const line = (label: string, data: number[], color: string, width: number) => ({
@@ -27,50 +57,84 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
data,
borderColor: color,
backgroundColor: color,
borderWidth: width,
borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width,
pointRadius: days.length > 45 ? 0 : 3,
pointHoverRadius: 5,
tension: 0.25,
fill: false,
})
const tickFont = { size: isMobile ? 10 : 12 }
return (
<div className="h-72 w-full">
<Line
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
<div className="flex w-full flex-col gap-1">
{trimmed && (
<p className="text-xs text-muted-foreground">
Showing the last {days.length} days — the table below lists every day.
</p>
)}
<div className="h-64 w-full sm:h-72">
<Line
plugins={[legendGap]}
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
// Legend on top (start-aligned on desktop, centred on phones) with small labels on phones.
legend: {
position: "top",
align: isMobile ? "center" : "start",
labels: {
color: c.text,
usePointStyle: true,
boxWidth: isMobile ? 6 : 8,
font: { size: isMobile ? 11 : 12 },
// Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it.
generateLabels: (chart) =>
ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })),
},
},
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
},
},
},
},
},
scales: {
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
},
}}
/>
scales: {
x: {
grid: { display: false },
ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont },
},
y: {
grid: { color: c.grid },
ticks: {
color: c.text,
font: tickFont,
maxTicksLimit: isMobile ? 5 : 8,
callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))),
},
},
},
}}
/>
</div>
</div>
)
}
@@ -147,9 +147,9 @@ function SerialPicker({
className="h-7 px-2 text-xs"
disabled={toAdd.length === 0}
onClick={selectAll}
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined}
>
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
Select all
</Button>
</div>
<div className="max-h-60 overflow-y-auto rounded-md border">
@@ -63,7 +63,10 @@ interface PaymentLinesEditorProps {
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
const paid = paymentLinesTotal(lines)
const remaining = round2(balance - paid)
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
// Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up
// it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them.
const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]"
const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden"
function updateLine(key: number, patch: Partial<PaymentLine>) {
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
@@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
return (
<div className="flex flex-col gap-2">
<div className={`grid ${cols} gap-2 text-sm font-medium`}>
<div className={`hidden gap-2 text-sm font-medium sm:grid ${cols}`}>
<span>Payment type</span>
{methodOptions && <span>Method</span>}
<span>Paid amount</span>
@@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</div>
{lines.map((line, idx) => (
<div key={line.key} className={`grid ${cols} items-start gap-2`}>
<div>
<div
key={line.key}
className={`grid grid-cols-2 items-start gap-2 rounded-lg border p-3 sm:rounded-none sm:border-0 sm:p-0 ${cols}`}
>
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span className={mobileLabel}>Payment type {lines.length > 1 ? idx + 1 : ""}</span>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
<SelectTrigger
className="w-full text-base"
className="h-11 w-full text-base sm:h-9"
aria-label={`Payment type, line ${idx + 1}`}
aria-invalid={!!errors[`account-${line.key}`]}
>
@@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
</div>
{methodOptions && (
<div>
<div className="flex flex-col gap-1">
<span className={mobileLabel}>Method</span>
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}>
<SelectTrigger className="h-11 w-full text-base sm:h-9" aria-label={`Payment method, line ${idx + 1}`}>
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
@@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
</Select>
</div>
)}
<div>
<div className={`flex flex-col gap-1 sm:col-span-1 ${methodOptions ? "" : "col-span-2"}`}>
<span className={mobileLabel}>Paid amount</span>
<Input
type="number"
step="0.01"
min="0.01"
inputMode="decimal"
value={line.amount}
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
aria-label={`Paid amount, line ${idx + 1}`}
aria-invalid={!!errors[`amount-${line.key}`]}
className="text-right tabular-nums"
className="h-11 text-right text-base tabular-nums sm:h-9"
/>
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
</div>
<div className="flex gap-1">
<div className="col-span-2 flex items-center justify-between gap-1 sm:col-span-1 sm:justify-start">
<Button
type="button"
variant="ghost"
@@ -148,12 +158,13 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
disabled={lines.length === 1}
aria-label={`Remove line ${idx + 1}`}
className="text-destructive hover:bg-destructive/10 disabled:text-muted-foreground"
>
<Trash2 className="size-4" />
</Button>
{/* Add sits on the last line itself, so a new split appears right below it. */}
{idx === lines.length - 1 && (
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
<Button type="button" variant="outline" size="sm" onClick={addLine} className="h-10 whitespace-nowrap sm:mt-1 sm:h-8">
<Plus className="size-4" />
Add line
</Button>
@@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
))}
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
<div className="grid w-full grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm sm:ml-auto sm:max-w-64">
<span className="text-muted-foreground">Total</span>
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
<span className="text-muted-foreground">Paid amount</span>
@@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
<DialogTitle className="pr-8 leading-snug">
Pay {ordered.length} invoices — {customerName}
</DialogTitle>
</DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{draftCount > 0 && (
<p className="text-sm text-muted-foreground">
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
@@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
</p>
)}
<div className="max-h-48 overflow-y-auto rounded-lg border">
<div className="max-h-48 shrink-0 overflow-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Invoice</th>
<th className="px-3 py-2 text-left font-medium">Date</th>
<th className="hidden px-3 py-2 text-left font-medium sm:table-cell">Date</th>
<th className="px-3 py-2 text-right font-medium">Balance</th>
<th className="px-3 py-2 text-right font-medium">Applied</th>
</tr>
@@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
<tbody>
{allocation.map(({ inv, applied }) => (
<tr key={inv.salesInvoiceId} className="border-t">
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
<td className="px-3 py-2 font-medium whitespace-nowrap">{inv.invoiceNo}</td>
<td className="hidden px-3 py-2 sm:table-cell">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
</tr>
@@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
/>
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
</Button>
</DialogFooter>
@@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader>
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
<p className="text-sm text-muted-foreground">
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
@@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
/>
</FieldGroup>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
</Button>
</DialogFooter>
@@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div>
)
}
+47 -24
View File
@@ -46,63 +46,86 @@ export interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
/** Small muted line under the value, e.g. "All time" or "3 with balance due". */
hint?: string
delta?: number
deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean
trend?: number[]
className?: string
/** Extra classes for the value, e.g. a success/destructive colour. */
valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string
}
/**
* The app's KPI tile: a tinted icon square on the left, then label / bold value / optional
* hint stacked beside it. Every list page's stat row uses this so they all read the same.
*/
export function StatCard({
label,
value,
icon: Icon,
hint,
delta,
deltaLabel,
invert = false,
trend,
className,
valueClassName,
iconClassName,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
return (
// A size container: the card reads its *own* width, so it lays out the same whether a page puts
// it in a 2-up phone grid or a wide desktop row.
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{/* 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">
{Icon && (
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-xs">
<span
<div
className={cn(
"font-semibold",
isGood ? "text-success" : "text-destructive"
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
iconClassName
)}
>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
<Icon className="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>
<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)}>
{formatValue(value)}
</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta ? (
<div className="flex items-center gap-1 text-xs">
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
)}
</div>
)}
</div>
</div>
)
}