bug fixers 10/20

This commit is contained in:
2026-10-20 15:33:19 +05:30
parent 0e423b806c
commit 47479fb4f2
43 changed files with 13191 additions and 811 deletions
@@ -33,6 +33,7 @@ import {
LayoutList,
LayoutTemplate,
LineChart,
CalendarRange,
ListTree,
Lock,
MapPin,
@@ -138,6 +139,8 @@ export const navItems: {
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// Split out of the Day-End Report page; same data and permission code, so no seeded NavItem of its own.
{ title: "Day-wise Income", code: "sales.day-end-report", href: "/dashboard/sales/day-wise-income", icon: CalendarRange },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
@@ -109,6 +109,7 @@ 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-wise-income") return "Day-wise Income"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM"
@@ -78,6 +78,8 @@ export function PurchaseOrderListView({
actionsHeader = "Actions",
emptyMessage,
onTotalChange,
status: controlledStatus,
onStatusChange,
}: {
/** Pins the list to one status (the approval queue); hides the status filter. */
fixedStatus?: PurchaseOrderStatus
@@ -88,6 +90,9 @@ export function PurchaseOrderListView({
emptyMessage?: ReactNode
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
onTotalChange?: (total: number | null) => void
/** Optional controlled status filter — lets the page drive it (e.g. from a stat card). */
status?: PurchaseOrderStatus | "All"
onStatusChange?: (status: PurchaseOrderStatus | "All") => void
}) {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
@@ -103,7 +108,9 @@ export function PurchaseOrderListView({
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [innerStatus, setInnerStatus] = useState<StatusFilter>("All")
const status = controlledStatus ?? innerStatus
const setStatus = onStatusChange ?? setInnerStatus
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
const [sortKey, setSortKey] = useState<SortKey>("created")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
@@ -27,6 +27,7 @@ export function WarrantyFields({
onChange,
error,
disabled,
hideLabel,
}: {
config: ProductConfig
/** Active rows of the Warranty Periods master, in months. */
@@ -36,6 +37,8 @@ export function WarrantyFields({
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
error?: string
disabled?: boolean
/** Leave out the "Warranty" label when the surrounding section already has that heading. */
hideLabel?: boolean
}) {
const groups = warrantyPeriodGroups(config, customMonths)
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
@@ -71,7 +74,7 @@ export function WarrantyFields({
return (
<div className="flex flex-col gap-3">
<Label className="text-base">Warranty</Label>
{!hideLabel && <Label className="text-base">Warranty</Label>}
<RadioGroup
value={warranty}
onValueChange={(v) => {
@@ -0,0 +1,48 @@
import { formatSignedAmount, profitOrLoss } from "@/lib/format"
import { cn } from "@/lib/utils"
import { TableCell } from "@/components/ui/table"
/** Shared by the Day-End Report and Day-wise Income pages. */
/** Local yyyy-MM-dd, `offsetDays` from today. */
export function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
export function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
export function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
export function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
export const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
@@ -7,6 +7,9 @@ export interface KpiTileSpec {
label: string
value: number
icon?: LucideIcon
/** Makes the tile a filter toggle for the list below (see StatCard). */
onClick?: () => void
active?: boolean
}
/**
@@ -20,7 +23,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
{tiles
? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} onClick={tile.onClick} active={tile.active} />
))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div>
@@ -58,6 +58,10 @@ export interface StatCardProps {
valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */
onClick?: () => void
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
}
/**
@@ -76,18 +80,29 @@ export function StatCard({
className,
valueClassName,
iconClassName,
onClick,
active = false,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
// Clickable cards are real buttons (keyboard + screen reader); plain cards stay divs.
const Root = onClick ? "button" : "div"
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
<Root
{...(onClick
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})}
className={cn(
"@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
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
)}
>
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
@@ -122,10 +137,11 @@ export function StatCard({
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
// Wraps rather than truncating, so a hint like "12 sales by card" is never cut off in a narrow card.
hint && <p className="text-xs leading-snug wrap-break-word text-muted-foreground">{hint}</p>
)}
</div>
</div>
</div>
</Root>
)
}
@@ -0,0 +1,200 @@
"use client"
import { useEffect, useState } from "react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
/** Currency isn't asked for on create; new vendors get this (the server requires one). Editable later on the vendor page. */
const DEFAULT_VENDOR_CURRENCY = "LKR"
/**
* The one "New vendor" modal — used by the Vendors list and by the New Purchase Order vendor picker's "+" button,
* so both create vendors the same way. The code is generated from the name (de-duped against every existing code),
* the optional fields follow Procurement Configuration, and the branch follows the header's branch switcher
* (an admin with no branch picked chooses one here).
*/
export function CreateVendorDialog({
open,
onOpenChange,
onCreated,
description = "Create a supplier record. Its code is generated from the name.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (vendor: Vendor) => void
description?: string
}) {
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const { config: procConfig } = useProcurementConfig()
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [pickedBranchId, setPickedBranchId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every existing code (up to the server's page-size cap), not just one page, so the generated code is unique.
const [existingCodes, setExistingCodes] = useState<string[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? pickedBranchId) : myBranchId) : null
const generatedCode = name.trim() ? generateVendorCode(name, existingCodes) : ""
useEffect(() => {
if (!open) return
vendorsApi.list({ pageSize: 200 }).then((res) => setExistingCodes(res.items.map((v) => v.code))).catch(() => {})
}, [open])
useEffect(() => {
if (open && showBranchPicker) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [open, showBranchPicker])
function reset() {
setName("")
setTerms("")
setTaxReg("")
setOptional(EMPTY_VENDOR_OPTIONAL)
setPickedBranchId(null)
setErrors({})
}
function close(next: boolean) {
onOpenChange(next)
if (!next) reset()
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedCode,
name,
...vendorTermsTaxPayload(procConfig, terms, taxReg),
currency: DEFAULT_VENDOR_CURRENCY,
...vendorOptionalPayload(procConfig, optional),
branchId: effectiveBranchId ?? 0,
})
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
onCreated(result.data)
close(false)
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cv-name" required>Name</FieldLabel>
<Input id="cv-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cv-code">Code (auto-generated)</FieldLabel>
<Input id="cv-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="cv-terms">Payment terms (optional)</FieldLabel>
<Input id="cv-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="cv-taxreg">Tax registration (optional)</FieldLabel>
<Input id="cv-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="cv"
errors={errors}
/>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="cv-branch" required>Branch</FieldLabel>
<Select<number | null> value={pickedBranchId} onValueChange={(v) => { setPickedBranchId(v); clearError("branch") }}>
<SelectTrigger id="cv-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}