Refactor code structure for improved readability and maintainability #49
@@ -244,8 +244,8 @@ export default function BundleSalesPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
@@ -271,11 +271,11 @@ export default function BundleSalesPage() {
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Rows loaded</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{visibleRows.length}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{visibleRows.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function SalesDayEndPage() {
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{preview.draftSlipsBlockingClose.map((s) => (
|
||||
<li key={`slip-${s.salesSlipId}`}>
|
||||
<Link href={`/dashboard/sales/slips/${s.salesSlipId}`} className="underline">{s.slipNo}</Link> — {s.grandTotal.toFixed(2)}
|
||||
{s.slipNo} — {s.grandTotal.toFixed(2)}
|
||||
</li>
|
||||
))}
|
||||
{preview.draftBundleSalesBlockingClose.map((b) => (
|
||||
|
||||
@@ -431,8 +431,8 @@ export default function NewFreeIssuePage() {
|
||||
<div className="font-medium">{row.itemName}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.qty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.freeQty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function NewSalesInvoicePage() {
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New invoice</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
|
||||
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function SalesInvoicesPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales Invoices</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -90,7 +90,7 @@ export default function SalesInvoicesPage() {
|
||||
</Link>
|
||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Invoice
|
||||
New Sale
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,10 +172,10 @@ export default function SalesInvoicesPage() {
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</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 text-muted-foreground">-{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">
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
@@ -200,15 +200,15 @@ export default function SalesInvoicesPage() {
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Gross total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Discount total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Net total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
import { FileBarChart, FileText, Lock, PackageX, ReceiptText } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -11,12 +11,6 @@ const sections = [
|
||||
href: "/dashboard/sales/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Slips",
|
||||
description: "Counter-style sales documents.",
|
||||
href: "/dashboard/sales/slips",
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: "Free Issues",
|
||||
description: "Promotional free-issue slips.",
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { FreeIssuePromotionSuggestions } from "@/components/sales/FreeIssuePromotionSuggestions"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { CreateSalesSlipLineRequest, SalesFreeIssueSuggestion, SalesSlip, SalesSlipPostingCheck } from "@/types/sales"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||
|
||||
const money = new Intl.NumberFormat("en-LK", {
|
||||
style: "currency",
|
||||
currency: "LKR",
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 0,
|
||||
warehouseId: 0,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
unitPrice: null,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: "Percentage",
|
||||
discountPct: 0,
|
||||
discountAmount: 0,
|
||||
discountValue: 0,
|
||||
taxPct: 0,
|
||||
isFreeIssue: false,
|
||||
parentLineId: null,
|
||||
})
|
||||
|
||||
export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const slipId = Number(resolvedParams.id)
|
||||
const [slip, setSlip] = useState<SalesSlip | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
const [promotionSuggestion, setPromotionSuggestion] = useState<SalesFreeIssueSuggestion | null>(null)
|
||||
const [postingCheck, setPostingCheck] = useState<SalesSlipPostingCheck | null>(null)
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(slipId)) {
|
||||
setError(`Invalid slip id '${resolvedParams.id}'.`)
|
||||
return
|
||||
}
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
salesApi.getSlip(slipId),
|
||||
])
|
||||
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
|
||||
setCustomers(cust.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
setUsers(userRes.items)
|
||||
setSlip(doc.data)
|
||||
setEtag(doc.etag)
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setCashierUserId(doc.data.cashierUserId)
|
||||
setPromotionSuggestion(null)
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesSlipLineId),
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
freeQty: line.freeQty,
|
||||
unitPrice: line.unitPrice,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: line.discountPct,
|
||||
discountAmount: line.discountAmount,
|
||||
discountValue: 0,
|
||||
taxPct: line.taxPct,
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const suggestions = await salesApi.getFreeIssueSuggestions(slipId)
|
||||
setPromotionSuggestion(suggestions)
|
||||
} catch {
|
||||
setPromotionSuggestion(null)
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [resolvedParams.id, slipId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!slip || slip.status !== "Draft") {
|
||||
setPostingCheck(null)
|
||||
return
|
||||
}
|
||||
salesApi
|
||||
.checkSlipPosting(slipId)
|
||||
.then((result) => setPostingCheck(result))
|
||||
.catch(() => setPostingCheck(null))
|
||||
}, [slip, slipId])
|
||||
|
||||
const subtotal = useMemo(
|
||||
() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice ?? 0), 0),
|
||||
[lines]
|
||||
)
|
||||
|
||||
function updateLine(key: string, patch: Partial<Line>) {
|
||||
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!customerId || !warehouseId || !cashierUserId || !etag) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
discountValue: Number(line.discountValue),
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId || null,
|
||||
})),
|
||||
}
|
||||
const updated = await salesApi.updateSlip(slipId, payload, etag)
|
||||
setSlip(updated.data)
|
||||
setEtag(updated.etag)
|
||||
toast.success("Slip saved", updated.data.slipNo)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function post() {
|
||||
if (!postingCheck?.canPost) {
|
||||
setError("Resolve stock shortages before posting this slip.")
|
||||
return
|
||||
}
|
||||
setActionBusy("post")
|
||||
setError(null)
|
||||
try {
|
||||
const posted = await salesApi.postSlip(slipId)
|
||||
setSlip(posted)
|
||||
toast.success("Slip posted", posted.slipNo)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setActionBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
setActionBusy("cancel")
|
||||
setError(null)
|
||||
try {
|
||||
const cancelled = await salesApi.cancelSlip(slipId)
|
||||
setSlip(cancelled)
|
||||
toast.success("Slip cancelled", cancelled.slipNo)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setActionBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !slip) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (!slip) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip data is loading or unavailable."}</div>
|
||||
|
||||
const locked = slip.status !== "Draft"
|
||||
const canPost = slip.status === "Draft" && (postingCheck?.canPost ?? true) && actionBusy === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales Slip</h1>
|
||||
<p className="text-base text-muted-foreground">{slip.slipNo} · {slip.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/print/sales/slips/${slip.salesSlipId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Link>
|
||||
<Button variant="outline" onClick={save} disabled={saving || locked}><Save className="size-4" />{saving ? "Saving..." : "Save"}</Button>
|
||||
<Button variant="outline" onClick={post} disabled={!canPost}><Send className="size-4" />{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : actionBusy === "post" ? "Posting..." : "Post"}</Button>
|
||||
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
<section className="bg-white">
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Slip</div>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Status:</span>
|
||||
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", slip.status === "Draft" ? "border-amber-200 bg-amber-50 text-amber-800" : slip.status === "Posted" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800")}>
|
||||
{slip.status}
|
||||
</span>
|
||||
<span>Date: {new Date(slip.slipDate).toLocaleDateString()}</span>
|
||||
<span>Cashier: {users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm md:text-right">
|
||||
<div className="font-semibold text-foreground">{customers.find((c) => c.customerId === slip.customerId)?.displayName ?? customers.find((c) => c.customerId === slip.customerId)?.name ?? slip.customerSnapshotName}</div>
|
||||
<div className="text-muted-foreground">Warehouse: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
|
||||
<div className="text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 py-5 md:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{slip.customerSnapshotName}</div>
|
||||
<div className="text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
|
||||
</div>
|
||||
<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(slip.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(slip.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{money.format(slip.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slip.lines.map((line) => (
|
||||
<TableRow key={line.salesSlipLineId}>
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Item</th>
|
||||
<th className="py-1 pr-3">Warehouse</th>
|
||||
<th className="py-1 pr-3 text-right">Requested</th>
|
||||
<th className="py-1 pr-3 text-right">Available</th>
|
||||
<th className="py-1 text-right">Short</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{postingCheck.issues.map((issue) => (
|
||||
<tr key={issue.salesSlipLineId} className="border-t border-amber-200/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{issue.itemSku}</div>
|
||||
<div className="text-xs text-amber-900/70">{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.availableQty.toFixed(0)}</td>
|
||||
<td className="py-2 text-right font-mono tabular-nums">{issue.shortQty.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{slip.status === "Draft" ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)} disabled={locked}>
|
||||
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Warehouse</Label>
|
||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)} disabled={locked}>
|
||||
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Cashier</Label>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)} disabled={locked}>
|
||||
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={addLine} disabled={locked}><Plus className="size-4" /> Add line</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>UOM</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Free</TableHead>
|
||||
<TableHead>Unit price</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell>
|
||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))} disabled={locked}>
|
||||
<SelectTrigger className="h-11!"><SelectValue placeholder="Item" /></SelectTrigger>
|
||||
<SelectContent>{items.map((i) => <SelectItem key={i.itemId} value={String(i.itemId)}>{i.sku} - {i.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
<FreeIssuePromotionSuggestions suggestion={promotionSuggestion} />
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Back</Link>
|
||||
<Button variant="outline" onClick={save} disabled={saving || locked}>Save</Button>
|
||||
<Button onClick={post} disabled={!canPost}>Post</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 0,
|
||||
warehouseId: 0,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
unitPrice: null,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: "Percentage",
|
||||
discountPct: 0,
|
||||
discountAmount: 0,
|
||||
discountValue: 0,
|
||||
taxPct: 0,
|
||||
isFreeIssue: false,
|
||||
parentLineId: null,
|
||||
})
|
||||
|
||||
const lkr = new Intl.NumberFormat("en-LK", {
|
||||
style: "currency",
|
||||
currency: "LKR",
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
export default function NewSalesSlipPage() {
|
||||
const router = useRouter()
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([cust, itemRes, uomRes, whRes, userRes]) => {
|
||||
setCustomers(cust.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
setUsers(userRes.items)
|
||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||
setLines([
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
||||
},
|
||||
])
|
||||
})
|
||||
.catch((err) => setSubmitError(errorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function updateLine(key: string, patch: Partial<Line>) {
|
||||
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||
}
|
||||
|
||||
const grossTotal = useMemo(
|
||||
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
|
||||
[lines],
|
||||
)
|
||||
const discountTotal = useMemo(
|
||||
() =>
|
||||
lines.reduce((sum, line) => {
|
||||
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||
const mode = String(line.discountMode)
|
||||
return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100))
|
||||
}, 0),
|
||||
[lines],
|
||||
)
|
||||
const taxTotal = useMemo(
|
||||
() =>
|
||||
lines.reduce((sum, line) => {
|
||||
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||
const mode = String(line.discountMode)
|
||||
const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
|
||||
const taxable = Math.max(0, gross - discount)
|
||||
return sum + taxable * (Number(line.taxPct || 0) / 100)
|
||||
}, 0),
|
||||
[lines],
|
||||
)
|
||||
const netTotal = Math.max(0, grossTotal - discountTotal)
|
||||
const payableTotal = netTotal + taxTotal
|
||||
|
||||
async function submit() {
|
||||
if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.")
|
||||
if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.")
|
||||
if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.")
|
||||
|
||||
const payload: CreateSalesSlipRequest = {
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
discountValue: Number(line.discountValue),
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId || null,
|
||||
})),
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const created = await salesApi.createSlip(payload)
|
||||
toast.success("Slip created", created.data.slipNo)
|
||||
router.push(`/dashboard/sales/slips/${created.data.salesSlipId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New sales slip</h1>
|
||||
<p className="text-base text-muted-foreground">Create counter sales slips from the live backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="size-4" /> Add line
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} disabled={saving}>
|
||||
<Save className="size-4" /> {saving ? "Saving..." : "Save slip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||
|
||||
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||
<h2 className="text-sm font-semibold">Slip header</h2>
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Warehouse</Label>
|
||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cashier</Label>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.userId} value={String(u.userId)}>
|
||||
{u.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Slip summary</Label>
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
|
||||
Draft
|
||||
</Badge>
|
||||
<span>{lkr.format(payableTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Slip lines</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="size-4" /> Add line
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">#</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line, idx) => {
|
||||
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||
const lineDiscount =
|
||||
String(line.discountMode) === "Amount"
|
||||
? Number(line.discountAmount || 0)
|
||||
: lineGross * (Number(line.discountPct || 0) / 100)
|
||||
const lineNet = Math.max(0, lineGross - lineDiscount)
|
||||
|
||||
return (
|
||||
<TableRow key={line.key} className="align-middle">
|
||||
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-64">
|
||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((candidate) => (
|
||||
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||
{candidate.sku} - {candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={line.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
|
||||
<TableCell className="px-4 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={lines.length === 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||
<h3 className="text-sm font-semibold">Slip notes</h3>
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<li className="rounded-md border border-border px-3 py-2">Cashier posting follows the standard sales-slip workflow.</li>
|
||||
<li className="rounded-md border border-border px-3 py-2">Free issue lines are captured from the slip itself, not from a separate register here.</li>
|
||||
<li className="rounded-md border border-border px-3 py-2">Use the slip detail page after save to post or cancel.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<dl className="rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]">
|
||||
{[
|
||||
["Gross", lkr.format(grossTotal)],
|
||||
["Discount", `-${lkr.format(discountTotal)}`],
|
||||
["Net", lkr.format(netTotal)],
|
||||
["Tax", lkr.format(taxTotal)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between py-1.5">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="font-mono tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
|
||||
<dt className="font-semibold">Payable</dt>
|
||||
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" onClick={submit} disabled={saving}>
|
||||
{saving ? "Saving..." : "Create slip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales"
|
||||
|
||||
type StatusFilter = SalesSlipStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||
|
||||
function statusClass(status: SalesSlipStatus) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||
case "Posted":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
case "Cancelled":
|
||||
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesSlipsPage() {
|
||||
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
salesApi
|
||||
.listSlips({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, status])
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) =>
|
||||
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
) ?? [],
|
||||
[rows, query]
|
||||
)
|
||||
const hasFilters = status !== "All" || query.length > 0
|
||||
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 printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales Slips</h1>
|
||||
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={printHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
<Printer className="size-5" />
|
||||
Print batch
|
||||
</Link>
|
||||
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Slip
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setStatus(t)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by customer or slip number"
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||
<Package2 className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No slips match your filters." : "No slips yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">Slip</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">Status</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">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
|
||||
<TableCell className="px-4 py-3.5 font-medium">
|
||||
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="hover:underline">
|
||||
{row.slipNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-end">
|
||||
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border text-muted-foreground hover:bg-muted hover:text-foreground" aria-label={`View slip ${row.slipNo}`}>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Gross total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Discount total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Net total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ function LoginForm() {
|
||||
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<Image src="/images/logo%20(2).png" alt="Hexa ERP" width={300} height={150} />
|
||||
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesSlip } from "@/types/sales"
|
||||
|
||||
export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const slipId = Number(resolvedParams.id)
|
||||
const [slip, setSlip] = useState<SalesSlip | null>(null)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(slipId)) {
|
||||
setError(`Invalid slip id '${resolvedParams.id}'.`)
|
||||
return
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
salesApi.getSlip(slipId),
|
||||
])
|
||||
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
|
||||
setCustomers(cust.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
setUsers(userRes.items)
|
||||
setSlip(doc.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [resolvedParams.id, slipId])
|
||||
|
||||
const subtotal = useMemo(() => slip?.totals.subtotal ?? 0, [slip])
|
||||
|
||||
if (error && !slip) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!slip) {
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
const customer = customers.find((c) => c.customerId === slip.customerId)
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slip</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h1>
|
||||
<div className="mt-2 text-sm text-muted-foreground">Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
||||
<div className="mt-2 text-lg font-semibold text-foreground">{slip.customerSnapshotName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
|
||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${slip.warehouseId}`}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? slip.warehouseId}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<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">{slip.totals.subtotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{slip.totals.discountTotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Net total</div><div className="text-right font-semibold">{slip.totals.grandTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>UOM</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Free</TableHead>
|
||||
<TableHead className="text-right">Unit price</TableHead>
|
||||
<TableHead className="text-right">Line total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slip.lines.map((line) => (
|
||||
<TableRow key={line.salesSlipLineId}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</TableCell>
|
||||
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cashier</div>
|
||||
<div className="mt-2 text-sm text-foreground">{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SalesSlipSummary } from "@/types/sales"
|
||||
|
||||
function statusClass(status: SalesSlipSummary["status"]) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||
case "Posted":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
case "Cancelled":
|
||||
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}
|
||||
}
|
||||
|
||||
function SalesSlipBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
salesApi
|
||||
.listSlips({ page: 1, pageSize: 200 })
|
||||
.then((res) => setRows(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const visibleRows = rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery =
|
||||
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
})
|
||||
|
||||
if (error && !rows) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!rows) {
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip batch print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slips</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">Slip Batch Print</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current slips.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Slip</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Lines</TableHead>
|
||||
<TableHead className="text-right">Gross</TableHead>
|
||||
<TableHead className="text-right">Discount</TableHead>
|
||||
<TableHead className="text-right">Net</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows?.map((row) => (
|
||||
<TableRow key={row.salesSlipId}>
|
||||
<TableCell className="font-medium">{row.slipNo}</TableCell>
|
||||
<TableCell>{row.customerSnapshotName}</TableCell>
|
||||
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SalesSlipBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<SalesSlipBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
@@ -114,15 +115,14 @@ const navItems: {
|
||||
icon: ReceiptText,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
||||
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
|
||||
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
|
||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
],
|
||||
},
|
||||
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
@@ -144,7 +144,6 @@ const navItems: {
|
||||
],
|
||||
},
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "Production",
|
||||
code: "production",
|
||||
@@ -275,13 +274,14 @@ function SidebarContent({
|
||||
)}
|
||||
>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!iconOnly && (
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
|
||||
{iconOnly ? (
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={165} height={40} className="h-10 w-auto" priority />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -407,13 +407,11 @@ function SidebarContent({
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-center pt-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{!iconOnly && (
|
||||
<p className="pt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} Hexa ERP
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
production: "Production",
|
||||
templates: "Templates",
|
||||
runs: "Runs",
|
||||
invoices: "Sales",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
|
||||
@@ -79,6 +79,9 @@ const STOCK_TITLES: Record<string, string> = {
|
||||
}
|
||||
|
||||
function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/sales/invoices") return "Sales"
|
||||
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
|
||||
|
||||
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
|
||||
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
|
||||
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SalesSlipSummary } from "@/types/sales"
|
||||
|
||||
export function FreeIssueSuggestions({ rows }: { rows: SalesSlipSummary[] }) {
|
||||
if (rows.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Free issue suggestions</h2>
|
||||
<p className="text-sm text-muted-foreground">Recent free-issue slips created from the free-issues page.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/sales/free-issues" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
Open free issues
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((row) => (
|
||||
<Link
|
||||
key={row.salesSlipId}
|
||||
href={`/dashboard/sales/free-issues/${row.salesSlipId}`}
|
||||
className="rounded-xl border bg-muted/20 p-3 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-foreground">{row.slipNo}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{row.status}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{row.customerSnapshotName}</div>
|
||||
<div className="mt-2 text-sm text-foreground">Free qty total: {row.totals.freeQtyTotal.toFixed(2)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 829 KiB |
Reference in New Issue
Block a user