ui fixers in 10/16
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user