sales & grn fixers
This commit is contained in:
@@ -109,7 +109,8 @@ export default function CustomersPage() {
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
|
||||
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="ml-auto mt-4 w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
|
||||
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
|
||||
@@ -37,6 +37,7 @@ import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
|
||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||
@@ -419,6 +420,16 @@ export default function ItemDetailPage() {
|
||||
dialog closes so this copy picks up what changed there. */}
|
||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||
|
||||
<ItemReorderPanel
|
||||
key={item.itemId}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
ListFilter,
|
||||
ListTree,
|
||||
Package,
|
||||
PackageX,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
@@ -25,6 +27,7 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
|
||||
|
||||
@@ -36,6 +39,7 @@ import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
@@ -123,6 +127,8 @@ export default function ItemsPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
|
||||
const dashboardStats = useDashboardStats()
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
@@ -211,6 +217,29 @@ export default function ItemsPage() {
|
||||
setLoadingMore(true)
|
||||
setPage((p) => p + 1)
|
||||
}
|
||||
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
|
||||
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
itemsApi.overview({ page: 1, pageSize: 1 }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
|
||||
])
|
||||
.then(([total, low, out]) => {
|
||||
if (cancelled) return
|
||||
setItemStats({
|
||||
total: total.pagination.totalItems,
|
||||
lowStock: low.pagination.totalItems,
|
||||
outOfStock: out.pagination.totalItems,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
@@ -331,6 +360,24 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
{/* Passed as a string so StatCard shows the full 2dp amount, not its compact "1.2M" form. */}
|
||||
{dashboardStats ? (
|
||||
<StatCard label="Stock value" value={formatMoney(dashboardStats.stockValuationTotal)} icon={CircleDollarSign} />
|
||||
) : (
|
||||
<Skeleton className="h-22.5 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same filter bar as the Sales list, but search on the left and every filter
|
||||
(category, status, "Advanced" popover) grouped on the right. */}
|
||||
<FilterBar
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
|
||||
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
|
||||
// replaced by a locked field, and the server forces it too.
|
||||
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
|
||||
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
|
||||
// treated (and sent) as 0% — the server rejects anything else.
|
||||
const allowDiscounts = grnConfig?.allowDiscounts ?? true
|
||||
const allowVat = grnConfig?.allowVat ?? true
|
||||
function effectiveLine(l: DraftLine): DraftLine {
|
||||
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
|
||||
}
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
// Missing/failed config just means "pick a warehouse" — never block the form on it.
|
||||
grnConfigApi.get().then((r) => r.data).catch(() => null),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
.then(([wh, it, uo, ve, po, config]) => {
|
||||
setGrnConfig(config)
|
||||
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
setHeaderDiscountPct("")
|
||||
setHeaderVatPct("")
|
||||
setHeaderError(null)
|
||||
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
if (!nextPoId) {
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
|
||||
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map((l): DraftLine => {
|
||||
const draft: DraftLine = {
|
||||
@@ -357,40 +375,7 @@ export default function NewGrnPage() {
|
||||
try {
|
||||
const result = await itemsApi.scan(code)
|
||||
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
||||
setLines((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
|
||||
)
|
||||
if (existingIdx >= 0) {
|
||||
const next = [...prev]
|
||||
const merged: DraftLine = {
|
||||
...next[existingIdx],
|
||||
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
||||
batchNo: scannedBatchNo || next[existingIdx].batchNo,
|
||||
}
|
||||
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||
return next
|
||||
}
|
||||
// Fill the first still-blank row (itemId never picked) instead of piling on a new
|
||||
// one below it — the untouched starter row, or one left empty after its item was
|
||||
// cleared, would otherwise sit there forever while every scan adds another line.
|
||||
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
||||
const draft: DraftLine = {
|
||||
...emptyLine(),
|
||||
itemId: result.itemId,
|
||||
qty: "1",
|
||||
batchNo: scannedBatchNo,
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
}
|
||||
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
||||
if (blankIdx >= 0) {
|
||||
const next = [...prev]
|
||||
next[blankIdx] = { ...filled, key: next[blankIdx].key }
|
||||
return next
|
||||
}
|
||||
return [...prev, filled]
|
||||
})
|
||||
addItemLine(result.itemId, scannedBatchNo)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setScanError(`No item or lot found for "${code}"`)
|
||||
@@ -402,6 +387,45 @@ export default function NewGrnPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the scan box and the "Add item" search: bumps qty on an existing line for the same
|
||||
// item + batch, else fills the first blank row or appends a new line for it.
|
||||
function addItemLine(itemId: number, batchNo = "") {
|
||||
setLines((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(l) => l.itemId === itemId && l.batchNo.trim() === batchNo.trim()
|
||||
)
|
||||
if (existingIdx >= 0) {
|
||||
const next = [...prev]
|
||||
const merged: DraftLine = {
|
||||
...next[existingIdx],
|
||||
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
||||
batchNo: batchNo || next[existingIdx].batchNo,
|
||||
}
|
||||
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||
return next
|
||||
}
|
||||
// Fill the first still-blank row (itemId never picked) instead of piling on a new
|
||||
// one below it — the untouched starter row, or one left empty after its item was
|
||||
// cleared, would otherwise sit there forever while every scan adds another line.
|
||||
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
||||
const draft: DraftLine = {
|
||||
...emptyLine(),
|
||||
itemId,
|
||||
qty: "1",
|
||||
batchNo,
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
}
|
||||
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
||||
if (blankIdx >= 0) {
|
||||
const next = [...prev]
|
||||
next[blankIdx] = { ...filled, key: next[blankIdx].key }
|
||||
return next
|
||||
}
|
||||
return [...prev, filled]
|
||||
})
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
const itemOptions = useMemo<SuggestOption[]>(
|
||||
() =>
|
||||
(items ?? []).map((i) => {
|
||||
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return {
|
||||
value: String(i.itemId),
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
detail: detail || undefined,
|
||||
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
|
||||
badgeTone: "muted",
|
||||
}
|
||||
}),
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||
vatPct: allowVat ? line.vatPct : "0",
|
||||
warranty: item?.warranty ?? null,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||
holdStatus: l.holdStatus,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
@@ -690,18 +732,28 @@ export default function NewGrnPage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{allowWarehouseSelection ? (
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{(() => {
|
||||
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
|
||||
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -709,37 +761,43 @@ export default function NewGrnPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerDiscountPct}
|
||||
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{(allowDiscounts || allowVat) && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
{allowDiscounts && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerDiscountPct}
|
||||
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allowVat && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerVatPct}
|
||||
onChange={(e) => applyHeaderVatPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||
Setting a rate here applies it to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerVatPct}
|
||||
onChange={(e) => applyHeaderVatPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
|
||||
<ScanInput
|
||||
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
||||
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...emptyLine(),
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||
<Button
|
||||
type="button"
|
||||
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "lines" && (
|
||||
<>
|
||||
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
|
||||
the last row of the table, under the Item column (same layout as Sales). */}
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="w-full md:hidden"
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
{lines.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
|
||||
No items yet — start typing in "Add item" above, or scan a barcode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked editable cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -949,38 +997,44 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
{allowDiscounts && (
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -1131,39 +1173,45 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{allowDiscounts && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
@@ -103,7 +103,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
// Includes the cash customer: a cash sale's draft must still show (and keep) it in the picker.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
@@ -232,7 +233,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return roundPrice(Number(line.unitPrice))
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
@@ -386,11 +387,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
|
||||
<div className="grid gap-6 py-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||
<div className="text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
|
||||
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
@@ -399,23 +399,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="text-muted-foreground">Net payable</div>
|
||||
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
@@ -452,6 +435,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the lines, right-aligned — where an invoice's totals are read. The
|
||||
"highlight" layout shows Net payable as a banner up top instead. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="flex justify-end py-5">
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Net payable</div>
|
||||
<div className="border-t pt-2 text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||
<div className="py-5">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||
@@ -610,7 +615,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -12,16 +12,17 @@ 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"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -110,6 +111,8 @@ const lkr = new Intl.NumberFormat("en-LK", {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
const qtyFormat = new Intl.NumberFormat("en-LK", { maximumFractionDigits: 3 })
|
||||
|
||||
function NewSalesInvoiceContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -160,6 +163,17 @@ function NewSalesInvoiceContent() {
|
||||
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
||||
[customers, salesConfig],
|
||||
)
|
||||
// The dropdown rows carry extra customer details, so pass explicit trigger labels — otherwise
|
||||
// the Select wrapper derives them from the rows and the closed trigger shows the details too.
|
||||
const customerSelectItems = useMemo(() => {
|
||||
const items = regularCustomers.map((c) => ({
|
||||
value: String(c.customerId),
|
||||
label: `${c.customerCode} — ${c.displayName ?? c.name}`,
|
||||
}))
|
||||
if (cashCustomerId !== null)
|
||||
items.unshift({ value: String(cashCustomerId), label: salesConfig?.cashCustomerName ?? "Cash Customer" })
|
||||
return items
|
||||
}, [regularCustomers, cashCustomerId, salesConfig])
|
||||
|
||||
function selectCustomer(nextCustomerId: number | null) {
|
||||
setCustomerId(nextCustomerId)
|
||||
@@ -358,7 +372,7 @@ function NewSalesInvoiceContent() {
|
||||
let price = resolvedPrices[key]
|
||||
if (price === undefined) {
|
||||
try {
|
||||
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
|
||||
price = roundPrice((await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -436,15 +450,78 @@ function NewSalesInvoiceContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Issuable stock per item at the sale's warehouse, for the "Add item" dropdown. Reloaded when the
|
||||
// warehouse changes; null until loaded (or with no warehouse), shown as "Stock: —" then. Falls back
|
||||
// to the stock enquiry list (all pages) if the sales stock-summary endpoint isn't reachable.
|
||||
const [stockByItem, setStockByItem] = useState<Map<number, number> | null>(null)
|
||||
useEffect(() => {
|
||||
if (!warehouseId) return
|
||||
let cancelled = false
|
||||
async function load(whId: number): Promise<Map<number, number>> {
|
||||
try {
|
||||
const rows = await salesApi.getStockSummary(whId)
|
||||
return new Map(rows.map((r) => [r.itemId, r.available]))
|
||||
} catch {
|
||||
// Older backend without stock-summary: try the stock enquiry list (needs the "stock"
|
||||
// permission), else ask the per-item sales endpoint for each stocked item.
|
||||
const map = new Map<number, number>()
|
||||
try {
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await stockApi.onHandList({ warehouseId: whId, page, pageSize: 200 })
|
||||
res.items.forEach((r) => map.set(r.itemId, r.available))
|
||||
if (res.items.length < 200) break
|
||||
}
|
||||
return map
|
||||
} catch {
|
||||
const queue = items.filter((i) => i.stockNature === "Stocked").map((i) => i.itemId)
|
||||
const worker = async () => {
|
||||
for (let id = queue.shift(); id !== undefined && !cancelled; id = queue.shift()) {
|
||||
const a = await salesApi.getStockAvailability(id, whId).catch(() => null)
|
||||
if (a) map.set(id, a.available)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: 6 }, worker))
|
||||
return map
|
||||
}
|
||||
}
|
||||
}
|
||||
load(warehouseId)
|
||||
.then((map) => !cancelled && setStockByItem(map))
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
setStockByItem(null)
|
||||
}
|
||||
}, [warehouseId, items])
|
||||
|
||||
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
|
||||
// template values "bundle:" so one handler can tell them apart on select.
|
||||
// template values "bundle:" so one handler can tell them apart on select. Each item row carries a
|
||||
// second line with its available stock and key details (UOM, batch/serial tracking, warranty, tax).
|
||||
const itemOptions = useMemo(
|
||||
() =>
|
||||
items.map((i) => {
|
||||
const price = getSuggestedUnitPrice(items, i.itemId)
|
||||
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
|
||||
const stocked = i.stockNature === "Stocked"
|
||||
const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
|
||||
const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name
|
||||
const detail = [
|
||||
uom,
|
||||
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
|
||||
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
|
||||
i.taxRateName ? `Tax: ${i.taxRateName}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return {
|
||||
value: `item:${i.itemId}`,
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
hint: price !== null ? lkr.format(price) : undefined,
|
||||
detail: detail || undefined,
|
||||
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
|
||||
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
|
||||
}
|
||||
}),
|
||||
[items],
|
||||
[items, uoms, stockByItem],
|
||||
)
|
||||
|
||||
const bundleTemplateOptions = useMemo(
|
||||
@@ -612,7 +689,7 @@ function NewSalesInvoiceContent() {
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
unitPrice: line.unitPrice === null ? null : roundPrice(Number(line.unitPrice)),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
||||
@@ -661,7 +738,11 @@ function NewSalesInvoiceContent() {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => selectCustomer(v ? Number(v) : null)}>
|
||||
<Select
|
||||
value={customerId ? String(customerId) : ""}
|
||||
onValueChange={(v) => selectCustomer(v ? Number(v) : null)}
|
||||
items={customerSelectItems}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
@@ -671,11 +752,17 @@ function NewSalesInvoiceContent() {
|
||||
{salesConfig?.cashCustomerName ?? "Cash Customer"}
|
||||
</SelectItem>
|
||||
)}
|
||||
{regularCustomers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||
{c.customerCode} — {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{regularCustomers.map((c) => {
|
||||
const details = [c.customerType, c.phone, c.email, c.city].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||
<div className="flex flex-col">
|
||||
<span>{c.customerCode} — {c.displayName ?? c.name}</span>
|
||||
{details && <span className="text-sm text-muted-foreground">{details}</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -927,10 +1014,10 @@ function NewSalesInvoiceContent() {
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
{...navProps(line.key, "price")}
|
||||
className="h-11 text-sm tabular-nums"
|
||||
/>
|
||||
@@ -1085,9 +1172,9 @@ function NewSalesInvoiceContent() {
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
{...navProps(line.key, "price")}
|
||||
className="h-11 text-right text-sm tabular-nums"
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
FileText,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Plus,
|
||||
Printer,
|
||||
Receipt,
|
||||
Tag,
|
||||
} from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
@@ -21,6 +37,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
@@ -30,6 +47,22 @@ import { Warehouse } from "@/types/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = SalesInvoiceStatus | "All"
|
||||
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
|
||||
type SortOrder = "asc" | "desc"
|
||||
|
||||
/** Same clickable sort header as the Items table. */
|
||||
function SortableHeader({ label, active, order, onClick, alignRight }: { label: string; active: boolean; order: SortOrder; onClick: () => void; alignRight?: boolean }) {
|
||||
return (
|
||||
<button type="button" className={cn("flex items-center gap-1 hover:text-foreground", alignRight && "ml-auto")} onClick={onClick}>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -69,8 +102,23 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
/** Past its due date (the "Due" column, `invoiceDate`) with money still owed — Draft and Cancelled
|
||||
* invoices are never overdue. Compared by calendar day, so an invoice due today is not overdue. */
|
||||
function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
if (row.status === "Draft" || row.status === "Cancelled" || row.totals.balanceAmount <= 0) return false
|
||||
const due = new Date(row.invoiceDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
due.setHours(0, 0, 0, 0)
|
||||
return due < today
|
||||
}
|
||||
|
||||
/** Money still owed on a live invoice — shown in red. A cancelled invoice owes nothing. */
|
||||
function hasRemaining(row: SalesInvoiceSummary): boolean {
|
||||
return row.status !== "Cancelled" && row.totals.balanceAmount > 0
|
||||
}
|
||||
|
||||
export default function SalesInvoicesPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
@@ -89,9 +137,23 @@ export default function SalesInvoicesPage() {
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
// The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through
|
||||
// `rows` so its buttons follow payments/confirms; viewRefresh reloads the sheet after those.
|
||||
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [viewRefresh, setViewRefresh] = useState(0)
|
||||
const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||
|
||||
// Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash
|
||||
// customer's invoices can't be ticked — they're paid one at a time.
|
||||
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||
// While invoices are ticked the list is narrowed to their customer (overriding the Customer
|
||||
// filter); clearing the selection falls back to whatever filter was chosen.
|
||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||
const listCustomerId = selectedCustomerId ?? customerId
|
||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
@@ -104,11 +166,13 @@ export default function SalesInvoicesPage() {
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setPageSize(rowsPerPage)
|
||||
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,7 +183,7 @@ export default function SalesInvoicesPage() {
|
||||
pageSize,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
customerId,
|
||||
customerId: listCustomerId,
|
||||
warehouseId,
|
||||
...dateRangeBounds(dateRange),
|
||||
})
|
||||
@@ -129,7 +193,7 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -142,6 +206,7 @@ export default function SalesInvoicesPage() {
|
||||
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
||||
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
||||
toast.success("Invoice confirmed", posted.invoiceNo)
|
||||
setViewRefresh((n) => n + 1)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
} finally {
|
||||
@@ -150,6 +215,7 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
|
||||
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
|
||||
setViewRefresh((n) => n + 1)
|
||||
setRows(
|
||||
(prev) =>
|
||||
prev?.map((r) =>
|
||||
@@ -162,6 +228,77 @@ export default function SalesInvoicesPage() {
|
||||
: r
|
||||
) ?? prev
|
||||
)
|
||||
// Keep a ticked invoice's balance current, and untick it once nothing is left to pay.
|
||||
setSelected((prev) => {
|
||||
const sel = prev.get(payment.salesInvoiceId)
|
||||
if (!sel) return prev
|
||||
const next = new Map(prev)
|
||||
const balanceAmount = sel.totals.balanceAmount - payment.amount
|
||||
if (balanceAmount <= 0) next.delete(payment.salesInvoiceId)
|
||||
else next.set(payment.salesInvoiceId, { ...sel, totals: { ...sel.totals, paidAmount: sel.totals.paidAmount + payment.amount, balanceAmount } })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleBulkConfirmed(salesInvoiceId: number) {
|
||||
const asPosted = (r: SalesInvoiceSummary) => (r.salesInvoiceId === salesInvoiceId ? { ...r, status: "Posted" as const } : r)
|
||||
setRows((prev) => prev?.map(asPosted) ?? prev)
|
||||
setSelected((prev) => {
|
||||
const sel = prev.get(salesInvoiceId)
|
||||
return sel ? new Map(prev).set(salesInvoiceId, asPosted(sel)) : prev
|
||||
})
|
||||
}
|
||||
|
||||
function handleBulkPaid(payments: SalesInvoicePayment[]) {
|
||||
payments.forEach((p) => handlePaid(p, "Posted"))
|
||||
// Partly-paid ones would stay ticked via handlePaid; a finished payment clears the whole selection.
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
/** Null when the row can be ticked, else why not (shown as the checkbox's tooltip). */
|
||||
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||
if (selected.has(row.salesInvoiceId)) return null
|
||||
if (row.status === "Cancelled") return "Cancelled invoices can't be paid"
|
||||
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleSelected(row: SalesInvoiceSummary) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||
else next.set(row.salesInvoiceId, row)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
const reason = notSelectableReason(row)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={reason !== null}
|
||||
title={reason ?? "Select to pay together"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => toggleSelected(row)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
|
||||
// server's order.
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
const visibleRows = useMemo(
|
||||
@@ -171,14 +308,43 @@ export default function SalesInvoicesPage() {
|
||||
) ?? [],
|
||||
[rows, query]
|
||||
)
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sortKey) return visibleRows
|
||||
const value = (r: SalesInvoiceSummary): string | number => {
|
||||
switch (sortKey) {
|
||||
case "invoiceNo": return r.invoiceNo
|
||||
case "customer": return r.customerSnapshotName
|
||||
case "date": return new Date(r.createdAt).getTime()
|
||||
case "due": return new Date(r.invoiceDate).getTime()
|
||||
case "net": return r.totals.grandTotal
|
||||
case "paid": return r.totals.paidAmount
|
||||
case "remaining": return r.status === "Cancelled" ? 0 : r.totals.balanceAmount
|
||||
}
|
||||
}
|
||||
return [...visibleRows].sort((a, b) => {
|
||||
const va = value(a), vb = value(b)
|
||||
const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb))
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
}, [visibleRows, sortKey, sortOrder])
|
||||
|
||||
// Header "select all": ticks every tickable row on screen, but only when they're all one customer
|
||||
// (e.g. once a selection has narrowed the list) — invoices of different customers never mix.
|
||||
const tickable = sortedRows.filter((r) => notSelectableReason(r) === null)
|
||||
const canSelectAll = tickable.length > 0 && new Set(tickable.map((r) => r.customerId)).size === 1
|
||||
const allTicked = tickable.length > 0 && tickable.every((r) => selected.has(r.salesInvoiceId))
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||
const printRange = dateRangeBounds(dateRange)
|
||||
const printHref =
|
||||
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
|
||||
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -188,15 +354,6 @@ export default function SalesInvoicesPage() {
|
||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Link
|
||||
href={printHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
||||
>
|
||||
<Printer className="size-5" />
|
||||
Print batch
|
||||
</Link>
|
||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Sale
|
||||
@@ -235,7 +392,11 @@ export default function SalesInvoicesPage() {
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
|
||||
<Select value={customerId ? String(customerId) : "All"} onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}>
|
||||
<Select
|
||||
value={listCustomerId ? String(listCustomerId) : "All"}
|
||||
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
|
||||
disabled={selectedCustomerId !== null}
|
||||
>
|
||||
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All customers</SelectItem>
|
||||
@@ -291,6 +452,28 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-col gap-2 border-b bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||
<span className="font-semibold tabular-nums">{formatAmount(selectedBalance)}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Showing only this customer's invoices — clear the selection to see all.
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && (
|
||||
@@ -319,17 +502,22 @@ export default function SalesInvoicesPage() {
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
setViewInvoice(row)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
if (e.key === "Enter") setViewInvoice(row)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="pt-1" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
@@ -343,13 +531,12 @@ export default function SalesInvoicesPage() {
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date / Due</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
|
||||
{new Date(row.createdAt).toLocaleDateString()} /{" "}
|
||||
<span className={cn(isOverdue(row) && "text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Lines</p>
|
||||
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Gross / Discount</p>
|
||||
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
|
||||
@@ -358,36 +545,54 @@ export default function SalesInvoicesPage() {
|
||||
<p className="text-muted-foreground">Net</p>
|
||||
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Paid / Remaining</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{row.totals.paidAmount.toFixed(2)} /{" "}
|
||||
<span className={cn(hasRemaining(row) && "font-semibold text-destructive")}>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,45 +607,83 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Due</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3">
|
||||
<Checkbox
|
||||
checked={canSelectAll && allTicked}
|
||||
disabled={!canSelectAll}
|
||||
onCheckedChange={(c) => toggleAll(c === true)}
|
||||
aria-label="Select all"
|
||||
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Invoice" active={sortKey === "invoiceNo"} order={sortOrder} onClick={() => toggleSort("invoiceNo")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Customer" active={sortKey === "customer"} order={sortOrder} onClick={() => toggleSort("customer")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Net" active={sortKey === "net"} order={sortOrder} onClick={() => toggleSort("net")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Paid" active={sortKey === "paid"} order={sortOrder} onClick={() => toggleSort("paid")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Remaining" active={sortKey === "remaining"} order={sortOrder} onClick={() => toggleSort("remaining")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
{sortedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.salesInvoiceId}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
setViewInvoice(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-4 py-3.5 font-medium">
|
||||
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<TableCell className="px-3 py-3.5 font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Receipt className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||
className="hover:underline"
|
||||
data-no-row-nav
|
||||
onClick={(e) => {
|
||||
// Plain click opens the pop-up; Ctrl/⌘-click still opens the full page in a new tab.
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
setViewInvoice(row)
|
||||
}}
|
||||
>
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
<p className="text-xs font-normal text-muted-foreground">{row.invoiceType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gross {row.totals.subtotal.toFixed(2)}
|
||||
{row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5 text-right tabular-nums", hasRemaining(row) && "font-semibold text-destructive")}>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
@@ -448,13 +691,13 @@ export default function SalesInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPrintInvoice(row)}
|
||||
onClick={() => setViewInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
>
|
||||
@@ -463,26 +706,29 @@ export default function SalesInvoicesPage() {
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -494,7 +740,7 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden border-t px-4 py-4 md:block">
|
||||
<div className="hidden px-4 pb-4 md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
@@ -508,11 +754,53 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={handleBulkConfirmed}
|
||||
onPaid={handleBulkPaid}
|
||||
/>
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
||||
invoiceNo={printInvoice?.invoiceNo}
|
||||
open={printInvoice !== null}
|
||||
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== null}
|
||||
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||
refreshKey={viewRefresh}
|
||||
actions={
|
||||
viewRow && (
|
||||
<>
|
||||
{viewRow.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={confirmingId === viewRow.salesInvoiceId}
|
||||
onClick={() => handleConfirm(viewRow)}
|
||||
aria-label={`Confirm ${viewRow.invoiceNo}`}
|
||||
title="Confirm"
|
||||
>
|
||||
{confirmingId === viewRow.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(viewRow)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SalesReturnsListPage() {
|
||||
|
||||
useEffect(load, [page, pageSize, query])
|
||||
useEffect(() => {
|
||||
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||
Promise.all([customersApi.list({ pageSize: 200, includeCashCustomer: true }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||
.then(([c, w, rc]) => {
|
||||
setCustomers(c.items)
|
||||
setWarehouses(w.items)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
import { Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -26,6 +26,12 @@ const cards = [
|
||||
icon: ShoppingCart,
|
||||
description: "Allow cash customer sales or require picking a customer",
|
||||
},
|
||||
{
|
||||
title: "GRN Configuration",
|
||||
href: "/dashboard/settings/grn-configuration",
|
||||
icon: PackageCheck,
|
||||
description: "Warehouse selection and the default receiving warehouse",
|
||||
},
|
||||
{
|
||||
title: "Invoice Template",
|
||||
href: "/dashboard/settings/invoice-template",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Settings } from "lucide-react"
|
||||
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { GrnConfig, UpdateGrnConfigRequest } from "@/types/grn"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/**
|
||||
* GRN Configuration — singleton goods-receipt options (same pattern as Sales Configuration).
|
||||
* "Allow warehouse selection" + default receiving warehouse: a saved default pins New GRN to that
|
||||
* one warehouse (server-forced too); with none saved, the picker shows unless selection is off.
|
||||
* "Allow discounts" / "Allow VAT": off hides those columns (and whole-GRN rates) on New GRN and
|
||||
* receives every line at 0% — the server rejects anything else.
|
||||
*/
|
||||
export default function GrnConfigurationPage() {
|
||||
const [config, setConfig] = useState<GrnConfig | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
grnConfigApi
|
||||
.get()
|
||||
.then((res) => {
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
useEffect(() => {
|
||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||
}, [])
|
||||
|
||||
const savedDefault = warehouses.find((w) => w.warehouseId === config?.defaultWarehouseId)
|
||||
const savedDefaultInList = savedDefault !== undefined
|
||||
const savedDefaultLabel = savedDefault
|
||||
? `${savedDefault.code} — ${savedDefault.name}`
|
||||
: (config?.defaultWarehouseName ?? `Warehouse #${config?.defaultWarehouseId}`)
|
||||
|
||||
// PUT is a full replacement — send every flag, overriding just the one changed.
|
||||
async function saveFlag(patch: Partial<UpdateGrnConfigRequest>, message: string) {
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await grnConfigApi.update(
|
||||
{
|
||||
allowWarehouseSelection: config.allowWarehouseSelection,
|
||||
defaultWarehouseId: config.defaultWarehouseId,
|
||||
allowDiscounts: config.allowDiscounts,
|
||||
allowVat: config.allowVat,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Configuration saved", message)
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">GRN Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">Choose how goods are received in this deployment.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||
|
||||
{!error && config && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Warehouse</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.allowWarehouseSelection}
|
||||
onCheckedChange={(next) => {
|
||||
// Turning selection off needs somewhere for receipts to go — the server rejects it too.
|
||||
if (!next && !config.defaultWarehouseId) {
|
||||
toast.error("Pick a default warehouse first", "Choose the default receiving warehouse below, then turn this off.")
|
||||
return
|
||||
}
|
||||
saveFlag({ allowWarehouseSelection: next }, `Warehouse selection ${next ? "enabled" : "disabled"}.`)
|
||||
}}
|
||||
disabled={saving}
|
||||
aria-label="Allow warehouse selection"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Allow warehouse selection</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: with no default warehouse saved below, New GRN shows a Warehouse picker.
|
||||
Off: a default warehouse must be saved. Whenever a default is saved, New GRN
|
||||
shows only that warehouse.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
|
||||
<span className="text-base font-medium text-foreground">Default receiving warehouse</span>
|
||||
<Select
|
||||
value={config.defaultWarehouseId ? String(config.defaultWarehouseId) : ""}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
const picked = warehouses.find((w) => w.warehouseId === Number(v))
|
||||
saveFlag({ defaultWarehouseId: Number(v) }, `Default receiving warehouse: ${picked?.name ?? v}.`)
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder={config.defaultWarehouseName ?? "Select warehouse"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* The warehouse list can be branch-scoped — keep the saved default selectable
|
||||
(and labelled in the trigger) even when it isn't in this user's list. */}
|
||||
{config.defaultWarehouseId && !savedDefaultInList && (
|
||||
<SelectItem value={String(config.defaultWarehouseId)} className="text-base">
|
||||
{config.defaultWarehouseName ?? `Warehouse #${config.defaultWarehouseId}`}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.defaultWarehouseId ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-foreground">
|
||||
New GRN shows only: <span className="font-medium">{savedDefaultLabel}</span>
|
||||
</span>
|
||||
{/* Clearing brings the picker back — only possible while selection is allowed. */}
|
||||
{config.allowWarehouseSelection && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => saveFlag({ defaultWarehouseId: null }, "Default receiving warehouse cleared.")}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Not set — New GRN lets the user pick a warehouse. Required to turn warehouse
|
||||
selection off.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.allowDiscounts}
|
||||
onCheckedChange={(next) => saveFlag({ allowDiscounts: next }, `Discounts ${next ? "allowed" : "not allowed"}.`)}
|
||||
disabled={saving}
|
||||
aria-label="Allow discounts"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Allow discounts</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: GRN lines show a Disc % column, plus a whole-GRN discount % that applies to
|
||||
every line. Off: both are hidden and goods are received without discounts.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.allowVat}
|
||||
onCheckedChange={(next) => saveFlag({ allowVat: next }, `VAT ${next ? "allowed" : "not allowed"}.`)}
|
||||
disabled={saving}
|
||||
aria-label="Allow VAT"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Allow VAT</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: GRN lines show a VAT % column (pre-filled from the item's tax rate), plus a
|
||||
whole-GRN VAT %. Off: both are hidden and every line is received at 0% VAT, even
|
||||
for items that have a tax rate.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user