bug fixers 10/20
This commit is contained in:
@@ -371,10 +371,21 @@ export default function ExpensesPage() {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{summary ? (
|
||||
<>
|
||||
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} />
|
||||
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} />
|
||||
{/* Cards double as filters (click again to clear). "Outstanding (all time)" ignores the
|
||||
period, so it has no matching list filter and stays plain. */}
|
||||
<StatCard
|
||||
label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt}
|
||||
onClick={() => setStatus((s) => (s === "Posted" ? "All" : "Posted"))} active={status === "Posted"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet}
|
||||
onClick={() => setPaymentFilter((p) => (p === "Unpaid" ? "All" : "Unpaid"))} active={paymentFilter === "Unpaid"}
|
||||
/>
|
||||
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
|
||||
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} />
|
||||
<StatCard
|
||||
label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle}
|
||||
onClick={() => setPaymentFilter((p) => (p === "Overdue" ? "All" : "Overdue"))} active={paymentFilter === "Overdue"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
|
||||
@@ -97,14 +97,23 @@ export default function HelpPage() {
|
||||
}
|
||||
}, [tickets])
|
||||
|
||||
// Set by clicking a stat card (click again to clear).
|
||||
const [statusFilter, setStatusFilter] = useState<"All" | "Open" | "InProgress" | "Resolved">("All")
|
||||
|
||||
function toggleStatus(next: "Open" | "InProgress" | "Resolved") {
|
||||
setStatusFilter((current) => (current === next ? "All" : next))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!tickets) return []
|
||||
const term = debouncedSearch.toLowerCase()
|
||||
if (!term) return tickets
|
||||
return tickets.filter(
|
||||
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
|
||||
(t) =>
|
||||
(statusFilter === "All" || t.status === statusFilter) &&
|
||||
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
|
||||
)
|
||||
}, [tickets, debouncedSearch])
|
||||
}, [tickets, debouncedSearch, statusFilter])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
@@ -210,10 +219,10 @@ export default function HelpPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
|
||||
<StatCard label="Open" value={stats.open} icon={Inbox} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} onClick={() => { setStatusFilter("All"); setPage(1) }} active={statusFilter === "All"} />
|
||||
<StatCard label="Open" value={stats.open} icon={Inbox} onClick={() => toggleStatus("Open")} active={statusFilter === "Open"} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} onClick={() => toggleStatus("InProgress")} active={statusFilter === "InProgress"} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} onClick={() => toggleStatus("Resolved")} active={statusFilter === "Resolved"} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
|
||||
@@ -170,10 +170,12 @@ export default function EmployeesPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
{/* Cards double as the status filter (click again to clear). "Inactive" spans several
|
||||
statuses (suspended, resigned, retired…), so it has no single filter and stays plain. */}
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} onClick={() => setStatusFilter(undefined)} active={statusFilter === undefined} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))} active={statusFilter === "Active"} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))} active={statusFilter === "Terminated"} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
|
||||
@@ -238,7 +238,14 @@ export default function LeaveRequestsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
|
||||
{/* "Pending" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
|
||||
<KpiRow
|
||||
tiles={stats ? [{
|
||||
label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock,
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
|
||||
active: statusFilter === "Submitted",
|
||||
}] : null}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
|
||||
@@ -113,28 +113,31 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{/* Icon-only to keep the column narrow; the label lives in aria-label/title. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="success"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canApprove || approvingId === po.poId}
|
||||
onClick={() => handleApprove(po, reload)}
|
||||
aria-label={`Approve ${po.docNo}`}
|
||||
title={approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canApprove}
|
||||
onClick={() => {
|
||||
setRejectReason("")
|
||||
setRejectTarget({ po, reload })
|
||||
}}
|
||||
aria-label={`Reject ${po.docNo}`}
|
||||
title="Reject"
|
||||
>
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,31 +19,15 @@ import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -147,16 +131,7 @@ function NewPurchaseOrderContent() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
|
||||
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
|
||||
|
||||
function loadItems() {
|
||||
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
|
||||
@@ -187,46 +162,6 @@ function NewPurchaseOrderContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function resetVendorForm() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVCurrency("LKR")
|
||||
setVOptional(EMPTY_VENDOR_OPTIONAL)
|
||||
setVErrors({})
|
||||
}
|
||||
|
||||
async function handleCreateVendor() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
|
||||
setVErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setVSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({
|
||||
code: generatedVendorCode,
|
||||
name: vName,
|
||||
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
|
||||
currency: vCurrency,
|
||||
...vendorOptionalPayload(procConfig, vOptional),
|
||||
})
|
||||
await loadVendors()
|
||||
setVendorId(result.data.vendorId)
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setVendorDialogOpen(false)
|
||||
resetVendorForm()
|
||||
} catch (err) {
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setVSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
@@ -446,62 +381,19 @@ function NewPurchaseOrderContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||
<DialogTrigger
|
||||
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!vErrors.name}>
|
||||
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
|
||||
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
)}
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={vOptional}
|
||||
onChange={(patch) => setVOptional((prev) => ({ ...prev, ...patch }))}
|
||||
idPrefix="po-v"
|
||||
errors={vErrors}
|
||||
/>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : 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={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
|
||||
{vSubmitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" onClick={() => setVendorDialogOpen(true)}>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
<CreateVendorDialog
|
||||
open={vendorDialogOpen}
|
||||
onOpenChange={setVendorDialogOpen}
|
||||
description="Create a supplier record without leaving this PO. Its code is generated from the name."
|
||||
onCreated={(vendor) => {
|
||||
// Active vendors only in this picker — a new one is Active, so add it and select it.
|
||||
setVendors((prev) => [...(prev ?? []), vendor])
|
||||
setVendorId(vendor.vendorId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{branchingEnabled && isAdmin && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
|
||||
import { PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
@@ -23,6 +23,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [submittingId, setSubmittingId] = useState<number | null>(null)
|
||||
const [pdfId, setPdfId] = useState<number | null>(null)
|
||||
// Shared with the list's Status filter, so the "Pending Approval" tile and the dropdown stay in sync.
|
||||
const [status, setStatus] = useState<PurchaseOrderStatus | "All">("All")
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
|
||||
setDeletingId(po.poId)
|
||||
@@ -83,9 +85,16 @@ export default function PurchaseOrdersListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
|
||||
<KpiRow
|
||||
tiles={stats ? [{
|
||||
label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock,
|
||||
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")), active: status === "PendingApproval",
|
||||
}] : null}
|
||||
/>
|
||||
|
||||
<PurchaseOrderListView
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
renderActions={(po, { reload }) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
|
||||
@@ -78,7 +78,13 @@ export default function RequisitionsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
|
||||
{/* "Open" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
|
||||
<KpiRow
|
||||
tiles={stats ? [{
|
||||
label: "Open Requisitions", value: stats.openRequisitions, icon: Send,
|
||||
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")), active: status === "Submitted",
|
||||
}] : null}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function RfqsListPage() {
|
||||
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Set by the "Open RFQs" tile (click again to clear).
|
||||
const [openOnly, setOpenOnly] = useState(false)
|
||||
|
||||
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
|
||||
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
|
||||
@@ -46,7 +48,8 @@ export default function RfqsListPage() {
|
||||
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
|
||||
}
|
||||
|
||||
const visibleRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
|
||||
const branchRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
|
||||
const visibleRfqs = openOnly && branchRfqs ? branchRfqs.filter((r) => r.status === "Open") : branchRfqs
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -61,7 +64,7 @@ export default function RfqsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText }] : null} />
|
||||
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText, onClick: () => setOpenOnly((v) => !v), active: openOnly }] : null} />
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams, usePathname, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Plus, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
|
||||
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
@@ -19,7 +20,7 @@ import { WastageRecord, wastageApi } from "@/lib/api/wastage"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { Brand, Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, SubCategory, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { ItemPriceHistory } from "@/types/approvals"
|
||||
|
||||
@@ -43,6 +44,8 @@ import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
import { WarrantyFields } from "@/components/products/WarrantyFields"
|
||||
import { AddUomDialog } from "@/components/products/AddUomDialog"
|
||||
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
|
||||
|
||||
/**
|
||||
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
|
||||
@@ -102,6 +105,11 @@ function ItemDetailPage() {
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
// Edit screen pickers — shown only while Product configuration has subcategories / brands on.
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
const [subCategoryList, setSubCategoryList] = useState<{ categoryId: number | null; items: SubCategory[] }>({ categoryId: null, items: [] })
|
||||
const [addUomOpen, setAddUomOpen] = useState(false)
|
||||
const [addBrandOpen, setAddBrandOpen] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Profile activity: full recent movement history for this item, across every warehouse.
|
||||
@@ -118,14 +126,11 @@ function ItemDetailPage() {
|
||||
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
|
||||
const [description, setDescription] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
|
||||
// Not editable here — they are chosen on the create screen's builder.
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
const [brandId, setBrandId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
// Default vendor, tax class, and tracking mode are no longer editable on this page —
|
||||
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||
// Default vendor isn't editable on this page — carried through unchanged so a save doesn't clear it.
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
// Warranty: edited in the Warranty section below (options follow Product configuration).
|
||||
@@ -211,6 +216,7 @@ function ItemDetailPage() {
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then(setConfig).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200, status: "Active" }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
|
||||
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
|
||||
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
|
||||
@@ -218,6 +224,17 @@ function ItemDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
// Subcategories are fetched per category, like the create screen.
|
||||
// Tagged with the category they belong to, so a stale list is never shown for another one.
|
||||
useEffect(() => {
|
||||
if (categoryId === null || !config?.subcategoriesEnabled) return
|
||||
categoriesApi
|
||||
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
|
||||
.then((res) => setSubCategoryList({ categoryId, items: res.items }))
|
||||
.catch(() => setSubCategoryList({ categoryId, items: [] }))
|
||||
}, [categoryId, config?.subcategoriesEnabled])
|
||||
const subCategories = subCategoryList.categoryId === categoryId ? subCategoryList.items : []
|
||||
|
||||
// Recent activity + stock value: independent of the edit form above, loaded once the
|
||||
// item id is known. A large page size is fine here — one item's lifetime history.
|
||||
useEffect(() => {
|
||||
@@ -313,7 +330,7 @@ function ItemDetailPage() {
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
|
||||
@@ -326,20 +343,32 @@ function ItemDetailPage() {
|
||||
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
|
||||
// rules set under Reorder settings survive an unrelated edit (name, price, …).
|
||||
const saved = alertFromRules(item.reorder ?? [])
|
||||
// Alert warehouse switched off: the picker is hidden and a changed alert watches every warehouse.
|
||||
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : saved.warehouseId
|
||||
const alertChanged =
|
||||
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId))
|
||||
!!config?.lowStockAlertsEnabled &&
|
||||
(lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || alertWarehouseId !== saved.warehouseId)))
|
||||
|
||||
// Follow Product configuration: a switched-off feature's value is not sent (the server
|
||||
// answers CONFIG_DISABLED for a gated value), so saving still works after a switch is turned off.
|
||||
const allowPrices = !!config?.sellingPricesEnabled
|
||||
const allowWarranty = !!config?.warrantyPeriodsEnabled
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await itemsApi.update(
|
||||
item.itemId,
|
||||
{
|
||||
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
|
||||
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number,
|
||||
subCategoryId: config?.subcategoriesEnabled ? subCategoryId : null,
|
||||
brandId: config?.brandsEnabled ? brandId : null,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
|
||||
warranty: allowWarranty ? warranty : "NonWarranty",
|
||||
warrantyPeriodMonths: allowWarranty && warranty === "Warranty" ? warrantyPeriodMonths : null,
|
||||
taxRateId,
|
||||
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
|
||||
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
|
||||
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
|
||||
sellingMinPrice: allowPrices && sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
|
||||
sellingMaxPrice: allowPrices && sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
|
||||
sellingFlatPrice: allowPrices && sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
|
||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||
},
|
||||
@@ -347,7 +376,8 @@ function ItemDetailPage() {
|
||||
)
|
||||
let saved = result.data
|
||||
if (alertChanged) {
|
||||
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
|
||||
const watchedId = config?.alertWarehouseEnabled ? warehouseId : null
|
||||
const watched = watchedId !== null ? [watchedId] : warehouses.map((w) => w.warehouseId)
|
||||
const settings = lowStockAt.trim()
|
||||
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
|
||||
: []
|
||||
@@ -428,114 +458,171 @@ function ItemDetailPage() {
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to item
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
|
||||
<p className="text-base text-muted-foreground">Update this item's basic info, pricing and classification.</p>
|
||||
</div>
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
|
||||
</div>
|
||||
/** Mobile-first section shell, same as the New Item screen. */
|
||||
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
|
||||
// Tracking mode and base UOM give meaning to every stock row already recorded, so they are
|
||||
// only changeable while the item has no movements yet (the server freezes the UOM too).
|
||||
const hasHistory = ledger === null || ledger.length > 0
|
||||
const trackingOptions: TrackingMode[] = [
|
||||
"None",
|
||||
...(config?.batchListsEnabled || trackingMode === "Batch" ? (["Batch"] as const) : []),
|
||||
...(config?.serialNumbersEnabled || trackingMode === "Serial" ? (["Serial"] as const) : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 sm:gap-6">
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to item
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Edit Item</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
{item.sku} — {item.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
|
||||
</div>
|
||||
)}
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={sectionClass}>
|
||||
<h2 className="text-lg font-semibold text-foreground">Basic details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base" required>Item name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12! text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12! text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Barcode (optional)</Label>
|
||||
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12! text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Category</Label>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={(v) => {
|
||||
setCategoryId(v)
|
||||
// A subcategory belongs to one category — the old pick no longer fits.
|
||||
if (v !== categoryId) setSubCategoryId(null)
|
||||
}}
|
||||
disabled={conflict}
|
||||
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
|
||||
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Barcode (optional)</Label>
|
||||
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
{config?.sellingPricesEnabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling min price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling max price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling flat price (fixed value)</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
{pricesLocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling prices need approval (Settings → Approvals).{" "}
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
|
||||
Request a price change
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={subCategoryId}
|
||||
onValueChange={setSubCategoryId}
|
||||
disabled={conflict || subCategories.length === 0}
|
||||
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
|
||||
<SelectItem value={null} className="text-base">None</SelectItem>
|
||||
{subCategories.map((s) => <SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
|
||||
</div>
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
|
||||
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
<Label className="text-base">Brand (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select<number | null>
|
||||
value={brandId}
|
||||
onValueChange={setBrandId}
|
||||
disabled={conflict}
|
||||
items={brands.map((b) => ({ label: b.name, value: b.brandId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base"><SelectValue placeholder="Select brand" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">None</SelectItem>
|
||||
{brands.map((b) => <SelectItem key={b.brandId} value={b.brandId} className="text-base">{b.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddBrandOpen(true)} aria-label="Add brand" disabled={conflict}>
|
||||
<Plus className="size-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<h2 className="text-lg font-semibold text-foreground">Units & stock</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Base UOM</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict || hasHistory} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
|
||||
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
|
||||
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
{!hasHistory && (
|
||||
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddUomOpen(true)} aria-label="Add UOM" disabled={conflict}>
|
||||
<Plus className="size-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
|
||||
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
|
||||
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
|
||||
</p>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
{hasHistory && <p className="text-sm text-muted-foreground">Locked — this item already has stock history.</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
|
||||
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
|
||||
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
|
||||
</p>
|
||||
</div>
|
||||
{/* Off: hidden, and the item keeps the nature it already has (the server allows that). */}
|
||||
{config?.stockNatureEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
|
||||
@@ -547,56 +634,168 @@ function ItemDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{config?.trackingModeEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict || hasHistory}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{trackingOptions.map((m) => <SelectItem key={m} value={m} className="text-base">{m}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasHistory
|
||||
? "Locked — this item already has stock history, so its tracking can't change."
|
||||
: config.serialNumbersEnabled
|
||||
? "Choose Serial if each unit needs its own serial number on receipt."
|
||||
: config.batchListsEnabled
|
||||
? "Batch tracking is available; serial tracking is currently disabled in product settings."
|
||||
: "Batch and serial tracking are currently disabled in product settings."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{config?.lowStockAlertsEnabled && (
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
|
||||
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert at" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
|
||||
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
|
||||
</div>
|
||||
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
|
||||
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${config.alertWarehouseEnabled && warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
|
||||
</div>
|
||||
{config.alertWarehouseEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<WarrantyFields
|
||||
config={config}
|
||||
customMonths={customWarrantyMonths}
|
||||
warranty={warranty}
|
||||
months={warrantyPeriodMonths}
|
||||
onChange={(w, m) => {
|
||||
setWarranty(w)
|
||||
setWarrantyPeriodMonths(m)
|
||||
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
|
||||
}}
|
||||
error={errors.warrantyPeriodMonths || undefined}
|
||||
disabled={conflict}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
||||
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
|
||||
{config?.sellingPricesEnabled && (
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
|
||||
<p className="text-sm text-muted-foreground">Optional — leave blank to sell at stock value.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Min price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Max price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Flat price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
</div>
|
||||
{pricesLocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling prices need approval (Settings → Approvals).{" "}
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
|
||||
Request a price change
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||
<p className="text-sm text-muted-foreground">Mark whether this item is sold under warranty.</p>
|
||||
</div>
|
||||
<WarrantyFields
|
||||
hideLabel
|
||||
config={config}
|
||||
customMonths={customWarrantyMonths}
|
||||
warranty={warranty}
|
||||
months={warrantyPeriodMonths}
|
||||
onChange={(w, m) => {
|
||||
setWarranty(w)
|
||||
setWarrantyPeriodMonths(m)
|
||||
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
|
||||
}}
|
||||
error={errors.warrantyPeriodMonths || undefined}
|
||||
disabled={conflict}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shown whenever the item has images so they can still be removed; uploads follow the Item images switch. */}
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
|
||||
</h2>
|
||||
{!imagesEnabled && <p className="text-sm text-muted-foreground">Adding images is switched off in Product configuration.</p>}
|
||||
</div>
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
</section>
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
|
||||
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Link href={`/dashboard/products/${item.itemId}`} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSave} disabled={saving || conflict} className="w-full sm:w-auto sm:min-w-40">
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<AddUomDialog
|
||||
open={addUomOpen}
|
||||
onOpenChange={setAddUomOpen}
|
||||
onCreated={(uom) => {
|
||||
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
|
||||
setBaseUomId(uom.uomId)
|
||||
}}
|
||||
/>
|
||||
<AddBrandDialog
|
||||
open={addBrandOpen}
|
||||
onOpenChange={setAddBrandOpen}
|
||||
onCreated={(brand) => {
|
||||
setBrands((prev) => [...prev, brand])
|
||||
setBrandId(brand.brandId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -672,15 +871,17 @@ function ItemDetailPage() {
|
||||
|
||||
{/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
|
||||
made through the Edit dialog's simple alert fields. */}
|
||||
<ItemReorderPanel
|
||||
key={JSON.stringify(item.reorder ?? [])}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder ?? []}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
|
||||
/>
|
||||
{config?.lowStockAlertsEnabled && (
|
||||
<ItemReorderPanel
|
||||
key={JSON.stringify(item.reorder ?? [])}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder ?? []}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
|
||||
@@ -254,6 +254,19 @@ export default function NewItemPage() {
|
||||
if (!config.batchListsEnabled && trackingMode === "Batch") {
|
||||
setTrackingMode("None")
|
||||
}
|
||||
if (!config.trackingModeEnabled && trackingMode !== "None") {
|
||||
setTrackingMode("None")
|
||||
}
|
||||
if (!config.stockNatureEnabled && stockNature !== "Stocked") {
|
||||
setStockNature("Stocked")
|
||||
}
|
||||
if (!config.lowStockAlertsEnabled && (lowStockAt || reorderQty)) {
|
||||
setLowStockAt("")
|
||||
setReorderQty("")
|
||||
}
|
||||
if (!config.alertWarehouseEnabled && warehouseId !== null) {
|
||||
setWarehouseId(null)
|
||||
}
|
||||
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
|
||||
setWarranty("NonWarranty")
|
||||
setWarrantyPeriodMonths(null)
|
||||
@@ -267,6 +280,10 @@ export default function NewItemPage() {
|
||||
}, [
|
||||
config,
|
||||
trackingMode,
|
||||
stockNature,
|
||||
lowStockAt,
|
||||
reorderQty,
|
||||
warehouseId,
|
||||
warranty,
|
||||
config?.serialNumbersEnabled,
|
||||
config?.batchListsEnabled,
|
||||
@@ -585,7 +602,7 @@ export default function NewItemPage() {
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
|
||||
@@ -629,8 +646,11 @@ export default function NewItemPage() {
|
||||
let created = 0
|
||||
const createdIds: number[] = []
|
||||
try {
|
||||
const effectiveStockNature = config?.stockNatureEnabled ? stockNature : "Stocked"
|
||||
const effectiveTrackingMode =
|
||||
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
!config?.trackingModeEnabled
|
||||
? "None"
|
||||
: !config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
? "None"
|
||||
: !config?.batchListsEnabled && trackingMode === "Batch"
|
||||
? "None"
|
||||
@@ -652,7 +672,7 @@ export default function NewItemPage() {
|
||||
subCategoryId,
|
||||
brandId,
|
||||
baseUomId,
|
||||
stockNature,
|
||||
stockNature: effectiveStockNature,
|
||||
trackingMode: effectiveTrackingMode,
|
||||
warranty: effectiveWarranty,
|
||||
warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
|
||||
@@ -696,8 +716,9 @@ export default function NewItemPage() {
|
||||
|
||||
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
|
||||
// images — the items exist either way, and the rules can be fixed under Reorder settings.
|
||||
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
|
||||
if (lowStockAt.trim() && watched.length > 0) {
|
||||
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : null
|
||||
const watched = alertWarehouseId !== null ? [alertWarehouseId] : warehouses.map((w) => w.warehouseId)
|
||||
if (config?.lowStockAlertsEnabled && lowStockAt.trim() && watched.length > 0) {
|
||||
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
|
||||
let failed = 0
|
||||
for (const itemId of createdIds) {
|
||||
@@ -1000,6 +1021,7 @@ export default function NewItemPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config?.stockNatureEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
|
||||
@@ -1013,6 +1035,8 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{config?.trackingModeEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
|
||||
@@ -1041,9 +1065,11 @@ export default function NewItemPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{config?.lowStockAlertsEnabled && (
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
|
||||
@@ -1097,6 +1123,7 @@ export default function NewItemPage() {
|
||||
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
|
||||
</p>
|
||||
</div>
|
||||
{config.alertWarehouseEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
@@ -1117,8 +1144,10 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
||||
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
|
||||
|
||||
@@ -259,6 +259,12 @@ export default function ItemsPage() {
|
||||
}
|
||||
|
||||
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
|
||||
|
||||
/** Stat-card filter: toggles the stock-level filter (same one as the "Stock level" dropdown). */
|
||||
function filterByStock(next: ItemStockFilter | "All") {
|
||||
setStock((current) => (current === next ? "All" : next))
|
||||
setPage(1)
|
||||
}
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
|
||||
|
||||
function changeCategory(next: number | "All") {
|
||||
@@ -377,9 +383,9 @@ export default function ItemsPage() {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{itemStats ? (
|
||||
<>
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} />
|
||||
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
|
||||
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} onClick={() => filterByStock("All")} active={stock === "All"} />
|
||||
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} onClick={() => filterByStock("LowStock")} active={stock === "LowStock"} />
|
||||
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} onClick={() => filterByStock("OutOfStock")} active={stock === "OutOfStock"} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
@@ -426,8 +432,12 @@ export default function ItemsPage() {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
{config?.trackingModeEnabled && (
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
)}
|
||||
{config?.stockNatureEnabled && (
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -56,6 +56,10 @@ export default function ProductSettingsPage() {
|
||||
customVariantsEnabled: config.customVariantsEnabled,
|
||||
itemImagesEnabled: config.itemImagesEnabled,
|
||||
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
|
||||
stockNatureEnabled: config.stockNatureEnabled,
|
||||
trackingModeEnabled: config.trackingModeEnabled,
|
||||
lowStockAlertsEnabled: config.lowStockAlertsEnabled,
|
||||
alertWarehouseEnabled: config.alertWarehouseEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
@@ -224,6 +228,54 @@ export default function ProductSettingsPage() {
|
||||
busy={saving === "brandsEnabled"}
|
||||
onChange={(v) => toggle("brandsEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Stock nature"
|
||||
description="Lets items be Stocked, Non-stocked or Service. Off hides the choice and new items are Stocked; existing items keep their nature."
|
||||
checked={config.stockNatureEnabled}
|
||||
busy={saving === "stockNatureEnabled"}
|
||||
onChange={(v) => toggle("stockNatureEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Tracking mode"
|
||||
description="Shows the tracking choice (None, Batch, Serial) on items. Off hides it and new items are untracked; existing items keep their mode."
|
||||
checked={config.trackingModeEnabled}
|
||||
busy={saving === "trackingModeEnabled"}
|
||||
onChange={(v) => toggle("trackingModeEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Low stock alert"
|
||||
description="Shows the low-stock alert (alert-at and reorder qty) on item create and the item page. Off hides it; alerts already set keep working."
|
||||
checked={config.lowStockAlertsEnabled}
|
||||
busy={saving === "lowStockAlertsEnabled"}
|
||||
onChange={(v) =>
|
||||
v
|
||||
? toggle("lowStockAlertsEnabled", true)
|
||||
: // The warehouse picker lives in the alert section, so switching it off turns that off too.
|
||||
save(
|
||||
"lowStockAlertsEnabled",
|
||||
{ lowStockAlertsEnabled: false, alertWarehouseEnabled: false },
|
||||
config.alertWarehouseEnabled
|
||||
? "Low stock alert and alert warehouse disabled."
|
||||
: "Low stock alert disabled.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Alert warehouse"
|
||||
description={
|
||||
config.lowStockAlertsEnabled
|
||||
? "Lets the low-stock alert watch one chosen warehouse. Off: the alert watches every warehouse."
|
||||
: "Needs Low stock alert switched on."
|
||||
}
|
||||
checked={config.alertWarehouseEnabled}
|
||||
busy={saving === "alertWarehouseEnabled"}
|
||||
disabled={!config.lowStockAlertsEnabled}
|
||||
onChange={(v) => toggle("alertWarehouseEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
@@ -249,6 +301,10 @@ const LABELS: Record<keyof ProductConfig, string> = {
|
||||
customVariantsEnabled: "Custom variants",
|
||||
itemImagesEnabled: "Item images",
|
||||
customWarrantyPeriodsEnabled: "Custom warranty periods",
|
||||
stockNatureEnabled: "Stock nature",
|
||||
trackingModeEnabled: "Tracking mode",
|
||||
lowStockAlertsEnabled: "Low stock alert",
|
||||
alertWarehouseEnabled: "Alert warehouse",
|
||||
updatedAt: "Updated at",
|
||||
updatedBy: "Updated by",
|
||||
}
|
||||
@@ -265,6 +321,10 @@ type FlagKey = keyof Pick<
|
||||
| "customVariantsEnabled"
|
||||
| "itemImagesEnabled"
|
||||
| "customWarrantyPeriodsEnabled"
|
||||
| "stockNatureEnabled"
|
||||
| "trackingModeEnabled"
|
||||
| "lowStockAlertsEnabled"
|
||||
| "alertWarehouseEnabled"
|
||||
>
|
||||
|
||||
function ToggleRow({
|
||||
|
||||
@@ -296,7 +296,13 @@ export default function GrnListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
|
||||
{/* "Pending" = Draft (DashboardService) — the tile toggles the Status filter to Draft. */}
|
||||
<KpiRow
|
||||
tiles={stats ? [{
|
||||
label: "Pending GRNs", value: stats.pendingGrns, icon: Clock,
|
||||
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")), active: status === "Draft",
|
||||
}] : null}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
searchFirst
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Banknote,
|
||||
CreditCard,
|
||||
FileDown,
|
||||
Hash,
|
||||
Landmark,
|
||||
LineChart,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
@@ -19,73 +20,26 @@ import {
|
||||
|
||||
import { dayEndReportApi } from "@/lib/api/day-end-report"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { formatAmount, formatReportDate, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useClientPagination } from "@/hooks/use-client-pagination"
|
||||
import { maskAccountNumber } from "@/types/bank-deposits"
|
||||
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
|
||||
import { DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
|
||||
import { KV, localIso, mobileCard } from "@/components/sales/day-end-report-parts"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const MAX_RANGE_DAYS = 92
|
||||
|
||||
/** Local yyyy-MM-dd, `offsetDays` from today. */
|
||||
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")}`
|
||||
}
|
||||
|
||||
function daysBetween(from: string, to: string): number {
|
||||
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
type Loaded<T> = { key: string; data: T | null; error: string | null }
|
||||
|
||||
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
|
||||
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. */
|
||||
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`. */
|
||||
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. */
|
||||
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")
|
||||
/** "1 sale" / "12 sales". */
|
||||
const salesCountLabel = (n: number) => `${n} sale${n === 1 ? "" : "s"}`
|
||||
|
||||
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
|
||||
function ProfitStatement({ day }: { day: DayEndReportDay }) {
|
||||
@@ -227,35 +181,33 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
|
||||
)
|
||||
}
|
||||
|
||||
export default function DayEndReportPage() {
|
||||
// useSearchParams needs a Suspense boundary for the static build.
|
||||
export default function DayEndReportRoute() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||
<DayEndReportPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function DayEndReportPage() {
|
||||
// ?date=yyyy-MM-dd opens a given day (Day-wise Income links here); otherwise today.
|
||||
const dateParam = useSearchParams().get("date")
|
||||
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
|
||||
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
|
||||
// their own branch server-side, whatever is sent.
|
||||
const branchId = isAdmin ? activeBranchId : null
|
||||
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
|
||||
|
||||
const [date, setDate] = useState(localIso())
|
||||
const [from, setFrom] = useState(localIso(-29))
|
||||
const [to, setTo] = useState(localIso())
|
||||
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
|
||||
|
||||
const rangeDays = from && to ? daysBetween(from, to) : 0
|
||||
const rangeError = !from || !to
|
||||
? "Pick both dates"
|
||||
: rangeDays < 1
|
||||
? "'To' must be on or after 'From'"
|
||||
: rangeDays > MAX_RANGE_DAYS
|
||||
? `Pick at most ${MAX_RANGE_DAYS} days`
|
||||
: null
|
||||
// Fixed for the page — not user-editable.
|
||||
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : localIso()
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const dayKey = `${date}|${branchId}`
|
||||
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
|
||||
const day = dayResult?.key === dayKey ? dayResult : null
|
||||
|
||||
const rangeKey = `${from}|${to}|${branchId}`
|
||||
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
|
||||
const range = rangeResult?.key === rangeKey ? rangeResult : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!date) return
|
||||
@@ -269,39 +221,15 @@ export default function DayEndReportPage() {
|
||||
}
|
||||
}, [date, branchId, dayKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (rangeError) return
|
||||
let cancelled = false
|
||||
dayEndReportApi
|
||||
.getDaily({ from, to, branchId })
|
||||
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
|
||||
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [from, to, branchId, rangeKey, rangeError])
|
||||
|
||||
async function downloadDay() {
|
||||
if (!day?.data) return
|
||||
setDownloading("day")
|
||||
setDownloading(true)
|
||||
try {
|
||||
await downloadDayEndReportPdf(day.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRange() {
|
||||
if (!range?.data) return
|
||||
setDownloading("range")
|
||||
try {
|
||||
await downloadDailyIncomePdf(range.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +241,6 @@ export default function DayEndReportPage() {
|
||||
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
|
||||
const salesPage = useClientPagination(sales, date)
|
||||
const expensePage = useClientPagination(expenses, date)
|
||||
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -324,7 +251,7 @@ export default function DayEndReportPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
A day's sales, how they were paid, its expenses and the resulting income — plus income day by day.
|
||||
A day's sales, how they were paid, its expenses and the resulting income.
|
||||
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -332,15 +259,23 @@ export default function DayEndReportPage() {
|
||||
|
||||
{/* --- One day ------------------------------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="der-date" className="text-base">Business date</Label>
|
||||
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" />
|
||||
{/* Date on the right, above the Day-wise income / PDF buttons. */}
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{/* Fixed, not editable: today, or the day opened from Day-wise Income (?date=). */}
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-sm text-muted-foreground">Business date</span>
|
||||
<span className="text-lg font-semibold text-foreground tabular-nums">{formatReportDate(date)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Link href="/dashboard/sales/day-wise-income" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
<LineChart className="size-4" />
|
||||
Day-wise income
|
||||
</Link>
|
||||
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading ? "Creating PDF…" : "Download day PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{day?.error && <p className="text-sm text-destructive">{day.error}</p>}
|
||||
@@ -354,29 +289,20 @@ export default function DayEndReportPage() {
|
||||
{s && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Sales</h2>
|
||||
{/* Each amount card carries its sale count as the hint line, instead of a separate count card. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} />
|
||||
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
|
||||
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
|
||||
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
|
||||
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
|
||||
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
|
||||
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
|
||||
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
|
||||
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} hint={salesCountLabel(s.salesCount)} />
|
||||
<StatCard
|
||||
label="Income"
|
||||
value={formatSignedAmount(s.income)}
|
||||
icon={s.income >= 0 ? TrendingUp : TrendingDown}
|
||||
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"}
|
||||
className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
|
||||
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
|
||||
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
|
||||
label="Paid amount"
|
||||
value={formatAmount(s.paidAmount)}
|
||||
icon={Wallet}
|
||||
hint={`${salesCountLabel(s.salesCount - s.unpaidSalesCount)} paid`}
|
||||
/>
|
||||
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} hint={`${salesCountLabel(s.cashSalesCount)} by cash`} />
|
||||
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} hint={`${salesCountLabel(s.cardSalesCount)} by card`} />
|
||||
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} hint={`${salesCountLabel(s.unpaidSalesCount)} not paid`} />
|
||||
</div>
|
||||
{/* Expenses and income cards live on Day-wise Income (Sales → Day-wise Income). */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
|
||||
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
|
||||
@@ -566,172 +492,6 @@ export default function DayEndReportPage() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* --- Day-wise income ----------------------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
||||
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="der-from">From</Label>
|
||||
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="der-to">To</Label>
|
||||
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
|
||||
</div>
|
||||
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
|
||||
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
|
||||
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
|
||||
|
||||
{!rangeError && range?.data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
|
||||
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
|
||||
<StatCard
|
||||
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
|
||||
value={formatSignedAmount(range.data.totals.income)}
|
||||
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
|
||||
value={profitOrLoss(range.data.totals.netProfit).amount}
|
||||
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
|
||||
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
|
||||
<DayEndIncomeChart days={range.data.days} />
|
||||
</div>
|
||||
|
||||
{/* Phones: one card per day (newest first), tap to show that day above. */}
|
||||
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
|
||||
{dayPage.pageRows.map((d) => {
|
||||
const p = profitOrLoss(d.netProfit)
|
||||
return (
|
||||
<li key={d.date}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => d.date && setDate(d.date)}
|
||||
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span>
|
||||
<span className="block font-medium">{formatReportDate(d.date)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
|
||||
</span>
|
||||
<span className={cn("text-right", p.isLoss && "text-destructive")}>
|
||||
<span className="block text-xs">{p.label}</span>
|
||||
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
|
||||
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
|
||||
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
|
||||
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
|
||||
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
|
||||
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
|
||||
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
|
||||
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className={mobileCard(true)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Total · {range.data.totals.salesCount} sales</span>
|
||||
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
|
||||
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
|
||||
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
|
||||
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
|
||||
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
|
||||
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-card">
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Sales</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cash</TableHead>
|
||||
<TableHead className="text-right">Card</TableHead>
|
||||
<TableHead className="text-right">Count</TableHead>
|
||||
<TableHead className="text-right">Expenses</TableHead>
|
||||
<TableHead className="text-right">Income</TableHead>
|
||||
<TableHead className="text-right">Profit / loss</TableHead>
|
||||
<TableHead className="text-right">Deposited</TableHead>
|
||||
<TableHead className="text-right">Cash in hand</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dayPage.pageRows.map((d) => (
|
||||
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
|
||||
<TableCell>{formatReportDate(d.date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={d.income} />
|
||||
<ProfitCell value={d.netProfit} />
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={range.data.totals.income} bold />
|
||||
<ProfitCell value={range.data.totals.netProfit} bold />
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
|
||||
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
{dayPage.pagination.totalItems > 10 && (
|
||||
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,12 +65,13 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
|
||||
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
type PaymentFilter = "All" | "Unpaid" | "Paid"
|
||||
type PaymentFilter = "All" | "Unpaid" | "Paid" | "Overdue"
|
||||
|
||||
const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
||||
{ value: "All", label: "All payments" },
|
||||
{ value: "Unpaid", label: "Balance due" },
|
||||
{ value: "Paid", label: "Fully paid" },
|
||||
{ value: "Overdue", label: "Overdue" },
|
||||
]
|
||||
|
||||
export default function ApprovedInvoicesPage() {
|
||||
@@ -155,7 +156,8 @@ function ApprovedInvoices() {
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
|
||||
overdue: salesPayment === "Overdue" ? true : undefined,
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
|
||||
@@ -189,7 +191,8 @@ function ApprovedInvoices() {
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
|
||||
overdue: salesPayment === "Overdue" ? true : undefined,
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((data) => setTotalsResult({ key: totalsKey, data }))
|
||||
@@ -265,6 +268,8 @@ function ApprovedInvoices() {
|
||||
|
||||
<InvoiceTotalsCards
|
||||
totals={cardTotals}
|
||||
payment={salesPayment}
|
||||
onPaymentChange={(next) => changeSalesPayment(salesPayment === next ? "All" : next)}
|
||||
pageOnly={pageOnly}
|
||||
scope={[
|
||||
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
|
||||
@@ -662,8 +667,15 @@ const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow
|
||||
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
|
||||
*/
|
||||
function InvoiceTotalsCards({
|
||||
totals, scope, pageOnly,
|
||||
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
|
||||
totals, scope, pageOnly, payment, onPaymentChange,
|
||||
}: {
|
||||
totals: SalesInvoiceListTotals | null | undefined
|
||||
scope: string
|
||||
pageOnly: boolean
|
||||
/** The payment filter the cards toggle — clicking a card filters the list (again = clear). */
|
||||
payment: PaymentFilter
|
||||
onPaymentChange: (next: PaymentFilter) => void
|
||||
}) {
|
||||
if (totals === undefined) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
@@ -682,9 +694,9 @@ function InvoiceTotalsCards({
|
||||
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} />
|
||||
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} onClick={() => onPaymentChange("All")} active={payment === "All"} />
|
||||
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
|
||||
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" />
|
||||
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" onClick={() => onPaymentChange("Paid")} active={payment === "Paid"} />
|
||||
<StatCard
|
||||
label="Balance due"
|
||||
value={formatAmount(totals.balanceTotal)}
|
||||
@@ -692,6 +704,8 @@ function InvoiceTotalsCards({
|
||||
hint="Still to collect"
|
||||
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
|
||||
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
|
||||
onClick={() => onPaymentChange("Unpaid")}
|
||||
active={payment === "Unpaid"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
@@ -700,6 +714,8 @@ function InvoiceTotalsCards({
|
||||
hint="Past due date, not fully paid"
|
||||
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
|
||||
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
|
||||
onClick={() => onPaymentChange("Overdue")}
|
||||
active={payment === "Overdue"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, FileDown, Receipt, ReceiptText, TrendingDown, TrendingUp } from "lucide-react"
|
||||
|
||||
import { dayEndReportApi } from "@/lib/api/day-end-report"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDailyIncomePdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useClientPagination } from "@/hooks/use-client-pagination"
|
||||
import { DayEndDailyReport } from "@/types/sales"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
|
||||
import { IncomeCell, KV, localIso, mobileCard, ProfitCell } from "@/components/sales/day-end-report-parts"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const MAX_RANGE_DAYS = 92
|
||||
|
||||
function daysBetween(from: string, to: string): number {
|
||||
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
type Loaded<T> = { key: string; data: T | null; error: string | null }
|
||||
|
||||
/**
|
||||
* Day-wise income (Sales → Day-wise Income): sales, expenses, income and profit/loss per day over a range
|
||||
* (≤ 92 days), with a chart and a PDF. Clicking a day opens that day in the Day-End Report.
|
||||
*/
|
||||
export default function DayWiseIncomePage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
|
||||
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
|
||||
// their own branch server-side, whatever is sent.
|
||||
const branchId = isAdmin ? activeBranchId : null
|
||||
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
|
||||
|
||||
const [from, setFrom] = useState(localIso(-29))
|
||||
const [to, setTo] = useState(localIso())
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const rangeDays = from && to ? daysBetween(from, to) : 0
|
||||
const rangeError = !from || !to
|
||||
? "Pick both dates"
|
||||
: rangeDays < 1
|
||||
? "'To' must be on or after 'From'"
|
||||
: rangeDays > MAX_RANGE_DAYS
|
||||
? `Pick at most ${MAX_RANGE_DAYS} days`
|
||||
: null
|
||||
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const rangeKey = `${from}|${to}|${branchId}`
|
||||
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
|
||||
const range = rangeResult?.key === rangeKey ? rangeResult : null
|
||||
|
||||
useEffect(() => {
|
||||
if (rangeError) return
|
||||
let cancelled = false
|
||||
dayEndReportApi
|
||||
.getDaily({ from, to, branchId })
|
||||
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
|
||||
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [from, to, branchId, rangeKey, rangeError])
|
||||
|
||||
async function downloadRange() {
|
||||
if (!range?.data) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
await downloadDailyIncomePdf(range.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openDay(date: string | null | undefined) {
|
||||
if (date) router.push(`/dashboard/sales/day-end-report?date=${date}`)
|
||||
}
|
||||
|
||||
// Paged in the browser (the report returns whole lists); a new range starts on page 1.
|
||||
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Day-wise Income</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days). Click a day to open its Day-End Report.
|
||||
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap sm:justify-end">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dwi-from">From</Label>
|
||||
<Input id="dwi-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dwi-to">To</Label>
|
||||
<Input id="dwi-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
|
||||
</div>
|
||||
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading ? "Creating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
|
||||
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
|
||||
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
|
||||
|
||||
{!rangeError && range?.data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||
<StatCard
|
||||
label="Sales"
|
||||
value={formatAmount(range.data.totals.totalSales)}
|
||||
icon={ReceiptText}
|
||||
hint={`${range.data.totals.salesCount} sale${range.data.totals.salesCount === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
|
||||
<StatCard
|
||||
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
|
||||
value={formatSignedAmount(range.data.totals.income)}
|
||||
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
|
||||
value={profitOrLoss(range.data.totals.netProfit).amount}
|
||||
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
|
||||
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
|
||||
<DayEndIncomeChart days={range.data.days} />
|
||||
</div>
|
||||
|
||||
{/* Phones: one card per day (newest first), tap to open that day's report. */}
|
||||
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
|
||||
{dayPage.pageRows.map((d) => {
|
||||
const p = profitOrLoss(d.netProfit)
|
||||
return (
|
||||
<li key={d.date}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDay(d.date)}
|
||||
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span>
|
||||
<span className="block font-medium">{formatReportDate(d.date)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
|
||||
</span>
|
||||
<span className={cn("text-right", p.isLoss && "text-destructive")}>
|
||||
<span className="block text-xs">{p.label}</span>
|
||||
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
|
||||
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
|
||||
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
|
||||
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
|
||||
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
|
||||
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
|
||||
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
|
||||
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className={mobileCard(true)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Total · {range.data.totals.salesCount} sales</span>
|
||||
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
|
||||
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 border-t pt-2">
|
||||
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
|
||||
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
|
||||
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
|
||||
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
|
||||
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-card">
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Sales</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cash</TableHead>
|
||||
<TableHead className="text-right">Card</TableHead>
|
||||
<TableHead className="text-right">Count</TableHead>
|
||||
<TableHead className="text-right">Expenses</TableHead>
|
||||
<TableHead className="text-right">Income</TableHead>
|
||||
<TableHead className="text-right">Profit / loss</TableHead>
|
||||
<TableHead className="text-right">Deposited</TableHead>
|
||||
<TableHead className="text-right">Cash in hand</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dayPage.pageRows.map((d) => (
|
||||
<TableRow key={d.date} className="cursor-pointer" onClick={() => openDay(d.date)} title="Open this day's Day-End Report">
|
||||
<TableCell>{formatReportDate(d.date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={d.income} />
|
||||
<ProfitCell value={d.netProfit} />
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={range.data.totals.income} bold />
|
||||
<ProfitCell value={range.data.totals.netProfit} bold />
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
|
||||
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
{dayPage.pagination.totalItems > 10 && (
|
||||
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,10 +48,23 @@ export default function UsersPage() {
|
||||
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
|
||||
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
// Set by clicking a stat card (click again to clear) — narrows the list to that group.
|
||||
const [cardFilter, setCardFilter] = useState<"All" | "Active" | "Inactive" | "Admins">("All")
|
||||
|
||||
function toggleCardFilter(next: "Active" | "Inactive" | "Admins") {
|
||||
setCardFilter((current) => (current === next ? "All" : next))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function load() {
|
||||
usersApi
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
q: debouncedSearch || undefined,
|
||||
status: cardFilter === "Active" || cardFilter === "Inactive" ? cardFilter : undefined,
|
||||
isAdmin: cardFilter === "Admins" ? true : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -65,7 +78,7 @@ export default function UsersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch])
|
||||
useEffect(load, [page, pageSize, debouncedSearch, cardFilter])
|
||||
|
||||
function loadStats() {
|
||||
Promise.all([
|
||||
@@ -138,10 +151,10 @@ export default function UsersPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
|
||||
<StatCard label="Total users" value={stats.total} icon={UsersIcon} onClick={() => { setCardFilter("All"); setPage(1) }} active={cardFilter === "All"} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => toggleCardFilter("Active")} active={cardFilter === "Active"} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} onClick={() => toggleCardFilter("Inactive")} active={cardFilter === "Inactive"} />
|
||||
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} onClick={() => toggleCardFilter("Admins")} active={cardFilter === "Admins"} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
|
||||
@@ -43,25 +43,27 @@ export default function CountsListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
// Set by the "Pending Counts" tile (click again to clear). "Pending" = Counted, awaiting posting (DashboardService).
|
||||
const [pendingOnly, setPendingOnly] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query])
|
||||
useEffect(() => setPage(1), [query, pendingOnly])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
stockCountsApi
|
||||
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.list({ page, pageSize, q: query || undefined, status: pendingOnly ? "Counted" : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.then((res) => {
|
||||
setCounts(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, query, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, query, pendingOnly, activeBranchId, branchingEnabled])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -73,7 +75,7 @@ export default function CountsListPage() {
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
const hasFilters = query.length > 0
|
||||
const hasFilters = query.length > 0 || pendingOnly
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -90,7 +92,7 @@ export default function CountsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList, onClick: () => setPendingOnly((v) => !v), active: pendingOnly }] : null} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
|
||||
+14
-158
@@ -9,7 +9,6 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -19,15 +18,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -35,14 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
@@ -53,10 +36,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function VendorsPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -67,14 +49,7 @@ export default function VendorsPage() {
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("LKR")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Separate from the paginated table list above — this needs every existing code (up to the
|
||||
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
|
||||
@@ -85,8 +60,6 @@ export default function VendorsPage() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -131,56 +104,6 @@ export default function VendorsPage() {
|
||||
|
||||
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
|
||||
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
|
||||
function resetForm() {
|
||||
setName("")
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
setCurrency("LKR")
|
||||
setOptional(EMPTY_VENDOR_OPTIONAL)
|
||||
setBranchFilterId(null)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
/** 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 (!currency.trim()) nextErrors.currency = "Currency 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, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
setAllVendorCodes((codes) => [...codes, result.data.code])
|
||||
} catch (err) {
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about codes loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hard delete. The server refuses (409) a vendor any purchase document or item still
|
||||
// references — those can only be deactivated, from the detail page's status toggle.
|
||||
async function handleDelete(vendor: Vendor) {
|
||||
@@ -218,85 +141,18 @@ export default function VendorsPage() {
|
||||
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Vendor
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="v-name" required>Name</FieldLabel>
|
||||
<Input id="v-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="v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="v-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="v"
|
||||
errors={errors}
|
||||
/>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
|
||||
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||
</Field>
|
||||
{showBranchPicker && (
|
||||
<Field data-invalid={!!errors.branch}>
|
||||
<FieldLabel htmlFor="v-branch" required>Branch</FieldLabel>
|
||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => { setBranchFilterId(v); clearError("branch") }}>
|
||||
<SelectTrigger id="v-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={() => setOpen(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>
|
||||
<Button size="lg" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-5" />
|
||||
New Vendor
|
||||
</Button>
|
||||
<CreateVendorDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onCreated={(vendor) => {
|
||||
load()
|
||||
setAllVendorCodes((codes) => [...codes, vendor.code])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||
createdBy?: number
|
||||
/** true = fully paid, false = balance still due. */
|
||||
paid?: boolean
|
||||
/** true = past its due date with a balance still owed. */
|
||||
overdue?: boolean
|
||||
/** Matches invoice number or customer name. */
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -394,6 +394,14 @@ export interface ProductConfig {
|
||||
itemImagesEnabled: boolean
|
||||
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
|
||||
customWarrantyPeriodsEnabled: boolean
|
||||
/** Server-enforced on item writes: off = new items are Stocked; existing natures are kept. */
|
||||
stockNatureEnabled: boolean
|
||||
/** Server-enforced on item writes: off = new items are untracked (None); existing modes are kept. */
|
||||
trackingModeEnabled: boolean
|
||||
/** Advisory: shows the low-stock alert section on item screens. Saved reorder rules keep working. */
|
||||
lowStockAlertsEnabled: boolean
|
||||
/** Advisory: lets the low-stock alert watch one warehouse. Needs `lowStockAlertsEnabled`. */
|
||||
alertWarehouseEnabled: boolean
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
@@ -410,6 +418,10 @@ export interface UpdateProductConfigRequest {
|
||||
customVariantsEnabled: boolean
|
||||
itemImagesEnabled: boolean
|
||||
customWarrantyPeriodsEnabled: boolean
|
||||
stockNatureEnabled: boolean
|
||||
trackingModeEnabled: boolean
|
||||
lowStockAlertsEnabled: boolean
|
||||
alertWarehouseEnabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user