Add bundle sales front end integration
This commit is contained in:
@@ -0,0 +1,355 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
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 { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
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"
|
||||||
|
|
||||||
|
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||||
|
|
||||||
|
function statusClass(status: BundleSale["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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleSaleDetailPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const bundleSaleId = Number(params.id)
|
||||||
|
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||||
|
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 [templateId, setTemplateId] = useState<number | null>(null)
|
||||||
|
const [bundleName, setBundleName] = useState("")
|
||||||
|
const [bundlePrice, setBundlePrice] = useState(0)
|
||||||
|
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
|
||||||
|
const [lines, setLines] = useState<EditableLine[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(bundleSaleId)) {
|
||||||
|
setError(`Invalid bundle id '${params.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
bundleApi.getBundle(bundleSaleId),
|
||||||
|
bundleApi.listTemplates({ pageSize: 200 }),
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => {
|
||||||
|
const data = bundleRes
|
||||||
|
setBundle(data)
|
||||||
|
setTemplates(templateRes.items)
|
||||||
|
setCustomers(custRes.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setUsers(userRes.items)
|
||||||
|
setCustomerId(data.customerId)
|
||||||
|
setWarehouseId(data.warehouseId)
|
||||||
|
setCashierUserId(data.cashierUserId)
|
||||||
|
setTemplateId(data.bundleSaleTemplateId)
|
||||||
|
setBundleName(data.bundleName)
|
||||||
|
setBundlePrice(data.bundlePrice)
|
||||||
|
setLines(
|
||||||
|
data.lines.map((line) => ({
|
||||||
|
key: `${line.bundleSaleLineId}`,
|
||||||
|
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||||
|
itemId: line.itemId,
|
||||||
|
uomId: line.uomId,
|
||||||
|
warehouseId: line.warehouseId,
|
||||||
|
qty: line.qty,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
includeInBundle: line.includeInBundle,
|
||||||
|
sortOrder: line.bundleSaleLineId,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [bundleSaleId, params.id])
|
||||||
|
|
||||||
|
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
||||||
|
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||||
|
const isDraft = bundle?.status === "Draft"
|
||||||
|
const canEdit = isDraft
|
||||||
|
|
||||||
|
function updateLine(key: string, patch: Partial<EditableLine>) {
|
||||||
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
const source = lines[lines.length - 1]
|
||||||
|
if (!source) return
|
||||||
|
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBundle() {
|
||||||
|
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const request: UpdateBundleSaleRequest = {
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
cashierUserId,
|
||||||
|
bundleSaleTemplateId: templateId,
|
||||||
|
bundleName,
|
||||||
|
bundlePrice,
|
||||||
|
allowPriceOverride,
|
||||||
|
lines: lines.map(({ key, ...line }) => line),
|
||||||
|
}
|
||||||
|
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
|
||||||
|
setBundle(res)
|
||||||
|
setEditing(false)
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postBundle() {
|
||||||
|
if (!bundle) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
|
||||||
|
if (!check.canPost) {
|
||||||
|
setError("Resolve stock shortages before posting this bundle.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
|
||||||
|
setBundle({ ...bundle, ...updated })
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelBundle() {
|
||||||
|
if (!bundle) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
|
||||||
|
setBundle({ ...bundle, ...updated })
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
|
||||||
|
|
||||||
|
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
|
||||||
|
|
||||||
|
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/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
|
||||||
|
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
|
||||||
|
</div>
|
||||||
|
</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-4" />
|
||||||
|
Print
|
||||||
|
</Link>
|
||||||
|
{canEdit ? (
|
||||||
|
editing ? (
|
||||||
|
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
|
||||||
|
<Edit className="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{isDraft ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Customer</Label>
|
||||||
|
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="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>Warehouse</Label>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="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>Cashier</Label>
|
||||||
|
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="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>Template</Label>
|
||||||
|
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
|
||||||
|
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Bundle name</Label>
|
||||||
|
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Bundle price</Label>
|
||||||
|
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">Component breakdown</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
|
||||||
|
</div>
|
||||||
|
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Item</TableHead>
|
||||||
|
<TableHead>UOM</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="text-right">Include</TableHead>
|
||||||
|
{editing && isDraft ? <TableHead /> : null}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<TableRow key={line.key}>
|
||||||
|
<TableCell className="min-w-72">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
|
const itemId = Number(v)
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||||
|
}} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.itemId} value={String(item.itemId)}>
|
||||||
|
{item.sku} - {item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-40">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((uom) => (
|
||||||
|
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||||
|
{uom.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||||
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||||
|
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||||
|
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
||||||
|
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
|
||||||
|
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { ManagedUser } from "@/types/users"
|
||||||
|
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
|
||||||
|
|
||||||
|
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||||
|
|
||||||
|
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||||
|
|
||||||
|
export default function NewBundleSalePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const templateFromQuery = searchParams.get("templateId")
|
||||||
|
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 [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||||
|
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
|
||||||
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||||
|
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
|
||||||
|
const [bundleName, setBundleName] = useState("Demo Bundle")
|
||||||
|
const [bundlePrice, setBundlePrice] = useState<number>(0)
|
||||||
|
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
|
||||||
|
const [lines, setLines] = useState<EditableLine[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
bundleApi.listTemplates({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setUsers(userRes.items)
|
||||||
|
setTemplates(templateRes.items)
|
||||||
|
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||||
|
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||||
|
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||||
|
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
|
||||||
|
})
|
||||||
|
.catch((err) => setSubmitError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templateId) return
|
||||||
|
bundleApi.getTemplate(templateId).then((res) => {
|
||||||
|
setTemplate(res)
|
||||||
|
setLines(res.lines.map(blankLine))
|
||||||
|
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||||
|
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||||
|
}, [templateId])
|
||||||
|
|
||||||
|
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
||||||
|
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||||
|
|
||||||
|
function updateLine(key: string, patch: Partial<EditableLine>) {
|
||||||
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
const source = lines[lines.length - 1] ?? template?.lines[0]
|
||||||
|
if (!source) return
|
||||||
|
setLines((prev) => [...prev, blankLine(source)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
|
||||||
|
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lines.length === 0) {
|
||||||
|
setSubmitError("Add at least one bundle component line.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
const request: CreateBundleSaleRequest = {
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
cashierUserId,
|
||||||
|
bundleSaleTemplateId: templateId,
|
||||||
|
bundleName,
|
||||||
|
bundlePrice,
|
||||||
|
allowPriceOverride,
|
||||||
|
lines: lines.map(({ key, ...line }) => line),
|
||||||
|
}
|
||||||
|
const res = await bundleApi.createBundle(request)
|
||||||
|
toast.success("Bundle saved", res.bundleNo)
|
||||||
|
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
|
||||||
|
</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 bg-card p-4 shadow-sm">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Customer</Label>
|
||||||
|
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger><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>Warehouse</Label>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger><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>Cashier</Label>
|
||||||
|
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger><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>Template</Label>
|
||||||
|
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
|
||||||
|
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Bundle name</Label>
|
||||||
|
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Bundle price</Label>
|
||||||
|
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Override allowed</Label>
|
||||||
|
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
|
||||||
|
{allowPriceOverride ? "Yes" : "No"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">Editable component rows</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Item</TableHead>
|
||||||
|
<TableHead>UOM</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="text-right">Include</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<TableRow key={line.key}>
|
||||||
|
<TableCell className="min-w-72">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
|
const itemId = Number(v)
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateLine(line.key, {
|
||||||
|
itemId,
|
||||||
|
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.itemId} value={String(item.itemId)}>
|
||||||
|
{item.sku} - {item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-40">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((uom) => (
|
||||||
|
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||||
|
{uom.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||||
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||||
|
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
||||||
|
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
|
||||||
|
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
|
||||||
|
<Button size="lg" onClick={submit} disabled={saving}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{saving ? "Saving..." : "Save draft"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
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 { 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 { PaginationMeta } from "@/types/common"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type StatusFilter = BundleSaleStatus | "All"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||||
|
|
||||||
|
function statusClass(status: BundleSaleStatus) {
|
||||||
|
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 BundleSalesPage() {
|
||||||
|
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
const [searchInput, setSearchInput] = useState("")
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||||
|
.then(([cust, whRes]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null)
|
||||||
|
bundleApi
|
||||||
|
.listBundles({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
status: status === "All" ? undefined : status,
|
||||||
|
q: query || undefined,
|
||||||
|
customerId: customerId ?? undefined,
|
||||||
|
warehouseId: warehouseId ?? undefined,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setRows(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [page, status, query, customerId, warehouseId])
|
||||||
|
|
||||||
|
const visibleRows = useMemo(() => rows ?? [], [rows])
|
||||||
|
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
||||||
|
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
||||||
|
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
||||||
|
|
||||||
|
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">Bundle Sales</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</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/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Bundle
|
||||||
|
</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 bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
|
||||||
|
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
||||||
|
<Filter className="size-4" />
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Customer</div>
|
||||||
|
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger className="h-10">
|
||||||
|
<SelectValue placeholder="All customers" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All customers</SelectItem>
|
||||||
|
{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">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
|
||||||
|
<SelectTrigger className="h-10">
|
||||||
|
<SelectValue placeholder="All warehouses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All warehouses</SelectItem>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomerId(null)
|
||||||
|
setWarehouseId(null)
|
||||||
|
setStatus("All")
|
||||||
|
setSearchInput("")
|
||||||
|
setQuery("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</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">
|
||||||
|
<FileText className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales 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">Bundle</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 text-right">Price</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
|
||||||
|
<TableCell className="px-4 py-3.5 font-medium">
|
||||||
|
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
|
||||||
|
{row.bundleNo}
|
||||||
|
</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 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">
|
||||||
|
<Badge variant="outline" className={statusClass(row.status)}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View bundle ${row.bundleNo}`}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Link href={`/print/sales/bundles/${row.bundleSaleId}`} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Print bundle ${row.bundleNo}`}>
|
||||||
|
<Printer 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-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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function BundleRegisterPage() {
|
||||||
|
redirect("/dashboard/sales/bundles")
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, FileText } from "lucide-react"
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export default function BundleReportsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Bundle Reports</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Bundle-level reporting will be added after the backend module is wired.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dashed p-8 text-muted-foreground">
|
||||||
|
<FileText className="mb-3 size-6" />
|
||||||
|
This screen is a placeholder for bundle sales reporting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -182,12 +182,13 @@ export default function NewFreeIssuePage() {
|
|||||||
discountValue: Number(line.discountValue),
|
discountValue: Number(line.discountValue),
|
||||||
taxPct: Number(line.taxPct),
|
taxPct: Number(line.taxPct),
|
||||||
isFreeIssue: line.isFreeIssue,
|
isFreeIssue: line.isFreeIssue,
|
||||||
parentLineId: line.parentLineId || null,
|
parentLineId: line.parentLineId ?? null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingRowId) {
|
if (editingRowId) {
|
||||||
const latest = await salesApi.getFreeIssue(editingRowId)
|
const latest = await salesApi.getFreeIssue(editingRowId)
|
||||||
|
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
|
||||||
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
|
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
|
||||||
toast.success("Free issue updated")
|
toast.success("Free issue updated")
|
||||||
setEditingRowId(null)
|
setEditingRowId(null)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { companyApi } from "@/lib/api/company"
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -14,7 +13,6 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { CompanyProfile } from "@/types/company"
|
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||||
@@ -64,7 +62,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const invoiceId = Number(resolvedParams.id)
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -86,15 +83,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
companyApi.getProfile(),
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
customersApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
salesApi.getInvoice(invoiceId),
|
salesApi.getInvoice(invoiceId),
|
||||||
])
|
])
|
||||||
.then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
.then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
||||||
setCompany(companyRes.data)
|
|
||||||
setCustomers(customerRes.items)
|
setCustomers(customerRes.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.items)
|
setUoms(uomRes.items)
|
||||||
@@ -269,7 +264,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}/print`} className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}>
|
<Link
|
||||||
|
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
||||||
|
>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
Print
|
Print
|
||||||
</Link>
|
</Link>
|
||||||
@@ -291,12 +291,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm md:text-right">
|
<div className="text-sm md:text-right">
|
||||||
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
|
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">Company details are not configured for this invoice view.</div>
|
||||||
{company?.addressLine1 ?? ""}
|
|
||||||
{company?.city ? `, ${company.city}` : ""}
|
|
||||||
</div>
|
|
||||||
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +414,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
|
|
||||||
<div className="border-t pt-5">
|
<div className="border-t pt-5">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{company?.footerNote ?? "Standard invoice template view."}</p>
|
<p className="mt-2 text-sm text-muted-foreground">Standard invoice template view.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 border-t pt-5 print:hidden">
|
<div className="mt-6 border-t pt-5 print:hidden">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link"
|
|||||||
import { ArrowLeft, Printer } from "lucide-react"
|
import { ArrowLeft, Printer } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { companyApi } from "@/lib/api/company"
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
@@ -16,14 +15,12 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { CompanyProfile } from "@/types/company"
|
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { SalesInvoice } from "@/types/sales"
|
import { SalesInvoice } from "@/types/sales"
|
||||||
|
|
||||||
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
||||||
const invoiceId = Number(params.id)
|
const invoiceId = Number(params.id)
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -36,15 +33,13 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Promise.all([
|
Promise.all([
|
||||||
companyApi.getProfile(),
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
customersApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
salesApi.getInvoice(invoiceId),
|
salesApi.getInvoice(invoiceId),
|
||||||
])
|
])
|
||||||
.then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => {
|
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
||||||
setCompany(companyRes.data)
|
|
||||||
setCustomers(cust.items)
|
setCustomers(cust.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.items)
|
setUoms(uomRes.items)
|
||||||
@@ -94,10 +89,8 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 text-sm md:justify-items-end">
|
<div className="grid gap-2 text-sm md:justify-items-end">
|
||||||
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
|
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||||
{company?.logoUrl ? <img src={company.logoUrl} alt={company.tradeName ?? company.legalName} className="h-12 w-auto object-contain" /> : null}
|
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
|
||||||
<div className="text-muted-foreground">{company?.addressLine1}{company?.city ? `, ${company.city}` : ""}</div>
|
|
||||||
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
|
|
||||||
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
||||||
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||||
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
||||||
@@ -181,9 +174,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
|
||||||
{company?.footerNote ?? "Standard invoice print view."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border p-4">
|
<div className="rounded-2xl border p-4">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
@@ -199,19 +190,6 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{company ? (
|
|
||||||
<div className="mt-6 rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Bank Details</div>
|
|
||||||
<div className="mt-3 grid gap-2 text-sm md:grid-cols-2">
|
|
||||||
<div><span className="text-muted-foreground">Bank</span> <span className="font-medium">{company.bankName ?? "—"}</span></div>
|
|
||||||
<div><span className="text-muted-foreground">Branch</span> <span className="font-medium">{company.bankBranch ?? "—"}</span></div>
|
|
||||||
<div><span className="text-muted-foreground">Account Name</span> <span className="font-medium">{company.accountName ?? "—"}</span></div>
|
|
||||||
<div><span className="text-muted-foreground">Account No</span> <span className="font-medium">{company.accountNumber ?? "—"}</span></div>
|
|
||||||
<div><span className="text-muted-foreground">SWIFT</span> <span className="font-medium">{company.swiftCode ?? "—"}</span></div>
|
|
||||||
<div><span className="text-muted-foreground">VAT</span> <span className="font-medium">{company.vatRegistrationNo ?? "—"}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,17 +99,16 @@ export default function NewSalesInvoicePage() {
|
|||||||
])
|
])
|
||||||
setActiveFocSchemes(
|
setActiveFocSchemes(
|
||||||
freeIssueRes.items.flatMap((issue) => {
|
freeIssueRes.items.flatMap((issue) => {
|
||||||
const firstLine = issue.lines?.[0]
|
if (!issue.itemId) return []
|
||||||
if (!firstLine) return []
|
const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId)
|
||||||
const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId)
|
|
||||||
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
|
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: issue.salesSlipId,
|
id: issue.salesSlipId,
|
||||||
slipNo: issue.slipNo,
|
slipNo: issue.slipNo,
|
||||||
schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`,
|
schemeLabel: issue.schemeLabel,
|
||||||
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
|
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
|
||||||
productLabel: `${item?.sku ?? `SKU-${firstLine.itemId}`} - ${item?.name ?? firstLine.itemName ?? `Item ${firstLine.itemId}`}`,
|
productLabel: `${item?.sku ?? issue.itemSku} - ${item?.name ?? issue.itemName}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function SalesInvoicesPage() {
|
|||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -78,10 +79,15 @@ export default function SalesInvoicesPage() {
|
|||||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="lg">
|
<Link
|
||||||
|
href={printHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||||
|
>
|
||||||
<Printer className="size-5" />
|
<Printer className="size-5" />
|
||||||
Print batch
|
Print batch
|
||||||
</Button>
|
</Link>
|
||||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Invoice
|
New Invoice
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Minus, Plus, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -251,6 +251,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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={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="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>
|
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function SalesSlipsPage() {
|
|||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -78,10 +79,15 @@ export default function SalesSlipsPage() {
|
|||||||
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
|
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="lg">
|
<Link
|
||||||
|
href={printHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||||
|
>
|
||||||
<Printer className="size-5" />
|
<Printer className="size-5" />
|
||||||
Print batch
|
Print batch
|
||||||
</Button>
|
</Link>
|
||||||
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Slip
|
New Slip
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { BundleSale } from "@/types/bundles"
|
||||||
|
|
||||||
|
export default function BundlePrintPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const bundleSaleId = Number(params.id)
|
||||||
|
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(bundleSaleId)) {
|
||||||
|
setError(`Invalid bundle id '${params.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bundleApi.getBundle(bundleSaleId).then((res) => setBundle(res)).catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [bundleSaleId, params.id])
|
||||||
|
|
||||||
|
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle print...</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">Bundle Sales</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">{bundle.bundleNo}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{bundle.bundleName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||||
|
<div><div className="text-muted-foreground">Customer</div><div className="font-medium">{bundle.customerSnapshotName}</div></div>
|
||||||
|
<div><div className="text-muted-foreground">Warehouse</div><div className="font-medium">{bundle.warehouseId}</div></div>
|
||||||
|
<div><div className="text-muted-foreground">Status</div><div className="font-medium">{bundle.status}</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Item</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Price</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{bundle.lines.map((line) => (
|
||||||
|
<TableRow key={line.bundleSaleLineId}>
|
||||||
|
<TableCell>{line.itemId}</TableCell>
|
||||||
|
<TableCell>{line.description}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.qty.toFixed(2)}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { BundleSaleSummary } from "@/types/bundles"
|
||||||
|
|
||||||
|
function statusClass(status: BundleSaleSummary["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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleBatchPrintPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const status = searchParams.get("status") ?? "All"
|
||||||
|
const query = searchParams.get("q") ?? ""
|
||||||
|
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bundleApi.listBundles({ 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.bundleNo} ${row.customerSnapshotName} ${row.bundleName}`.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">Loading bundle print data...</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">Bundle Sales</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">Bundle Batch Print</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current bundle sales.</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Bundle</TableHead>
|
||||||
|
<TableHead>Customer</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Price</TableHead>
|
||||||
|
<TableHead className="text-right">Grand</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows?.map((row) => (
|
||||||
|
<TableRow key={row.bundleSaleId}>
|
||||||
|
<TableCell className="font-medium">{row.bundleNo}</TableCell>
|
||||||
|
<TableCell>{row.customerSnapshotName}</TableCell>
|
||||||
|
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="text-right">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{row.grandTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={statusClass(row.status)}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"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 { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
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 { SalesInvoice } from "@/types/sales"
|
||||||
|
|
||||||
|
export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const resolvedParams = use(params)
|
||||||
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) {
|
||||||
|
setError(`Invalid invoice id '${resolvedParams.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.getInvoice(invoiceId),
|
||||||
|
])
|
||||||
|
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setInvoice(doc.data)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [resolvedParams.id, invoiceId])
|
||||||
|
|
||||||
|
const freeQtyTotal = useMemo(() => invoice?.totals.freeQtyTotal ?? 0, [invoice])
|
||||||
|
|
||||||
|
if (error && !invoice) {
|
||||||
|
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
||||||
|
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invoice-sheet 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="invoice-header grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm md:justify-items-end">
|
||||||
|
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||||
|
<div className="text-muted-foreground">Invoice print view</div>
|
||||||
|
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
||||||
|
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
||||||
|
</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">{invoice.customerSnapshotName}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||||
|
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
|
{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 ?? `#${invoice.warehouseId}`}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.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">{invoice.totals.subtotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[34%]">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">Discount</TableHead>
|
||||||
|
<TableHead className="text-right">Tax</TableHead>
|
||||||
|
<TableHead className="text-right">Line total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<TableRow key={line.salesInvoiceLineId}>
|
||||||
|
<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>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { SalesInvoiceSummary } from "@/types/sales"
|
||||||
|
|
||||||
|
function statusClass(status: SalesInvoiceSummary["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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesInvoiceBatchPrintPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const status = searchParams.get("status") ?? "All"
|
||||||
|
const query = searchParams.get("q") ?? ""
|
||||||
|
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
salesApi
|
||||||
|
.listInvoices({ 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.invoiceNo} ${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 ?? "Invoice 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 Invoices</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">Invoice Batch Print</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current invoices.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead>Customer</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Due</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>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows?.map((row) => (
|
||||||
|
<TableRow key={row.salesInvoiceId}>
|
||||||
|
<TableCell className="font-medium">{row.invoiceNo}</TableCell>
|
||||||
|
<TableCell>{row.customerSnapshotName}</TableCell>
|
||||||
|
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>{new Date(row.invoiceDate).toLocaleDateString()}</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>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={statusClass(row.status)}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"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 { 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>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { 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 { 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesSlipBatchPrintPage() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||||
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
||||||
|
{ 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: "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: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import {
|
||||||
|
BundleSale,
|
||||||
|
BundleSalePostingCheck,
|
||||||
|
BundleSaleSummary,
|
||||||
|
BundleSaleTemplate,
|
||||||
|
BundleSaleTemplateSummary,
|
||||||
|
CreateBundleSaleRequest,
|
||||||
|
UpdateBundleSaleRequest,
|
||||||
|
} from "@/types/bundles"
|
||||||
|
|
||||||
|
export const bundleApi = {
|
||||||
|
listBundles(params: { page?: number; pageSize?: number; status?: string; customerId?: number; warehouseId?: number; q?: string } = {}): Promise<PagedResponse<BundleSaleSummary>> {
|
||||||
|
return apiRequest<PagedResponse<BundleSaleSummary>>(`/bundle-sales${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBundle(bundleSaleId: number): Promise<BundleSale> {
|
||||||
|
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createBundle(request: CreateBundleSaleRequest): Promise<BundleSale> {
|
||||||
|
return apiRequest<BundleSale>("/bundle-sales", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBundle(bundleSaleId: number, request: UpdateBundleSaleRequest): Promise<BundleSale> {
|
||||||
|
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}`, { method: "PUT", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
postBundle(bundleSaleId: number): Promise<BundleSale> {
|
||||||
|
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}/post`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelBundle(bundleSaleId: number): Promise<BundleSale> {
|
||||||
|
return apiRequest<BundleSale>(`/bundle-sales/${bundleSaleId}/cancel`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
checkBundlePosting(bundleSaleId: number): Promise<BundleSalePostingCheck> {
|
||||||
|
return apiRequest<BundleSalePostingCheck>(`/bundle-sales/${bundleSaleId}/posting-check`)
|
||||||
|
},
|
||||||
|
|
||||||
|
listTemplates(params: { page?: number; pageSize?: number; q?: string } = {}): Promise<PagedResponse<BundleSaleTemplateSummary>> {
|
||||||
|
return apiRequest<PagedResponse<BundleSaleTemplateSummary>>(`/bundle-sales/templates${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getTemplate(bundleSaleTemplateId: number): Promise<BundleSaleTemplate> {
|
||||||
|
return apiRequest<BundleSaleTemplate>(`/bundle-sales/templates/${bundleSaleTemplateId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
|
||||||
|
export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled"
|
||||||
|
|
||||||
|
export interface BundleSaleTemplateLine {
|
||||||
|
bundleSaleTemplateLineId: number
|
||||||
|
itemId: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
qty: number
|
||||||
|
unitPrice: number
|
||||||
|
includeInBundle: boolean
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSaleTemplateSummary {
|
||||||
|
bundleSaleTemplateId: number
|
||||||
|
templateCode: string
|
||||||
|
templateName: string
|
||||||
|
description: string | null
|
||||||
|
status: EntityStatus
|
||||||
|
lineCount: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSaleTemplate {
|
||||||
|
bundleSaleTemplateId: number
|
||||||
|
templateCode: string
|
||||||
|
templateName: string
|
||||||
|
description: string | null
|
||||||
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
lines: BundleSaleTemplateLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSaleLine {
|
||||||
|
bundleSaleLineId: number
|
||||||
|
itemId: number
|
||||||
|
description: string
|
||||||
|
qty: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
unitPrice: number
|
||||||
|
lineTotal: number
|
||||||
|
includeInBundle: boolean
|
||||||
|
isComponent: boolean
|
||||||
|
parentLineId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSaleTotals {
|
||||||
|
componentSubtotal: number
|
||||||
|
bundlePrice: number
|
||||||
|
marginAmount: number
|
||||||
|
discountTotal: number
|
||||||
|
taxTotal: number
|
||||||
|
grandTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSaleSummary {
|
||||||
|
bundleSaleId: number
|
||||||
|
bundleNo: string
|
||||||
|
bundleDate: string
|
||||||
|
customerId: number
|
||||||
|
customerSnapshotName: string
|
||||||
|
warehouseId: number
|
||||||
|
bundleName: string
|
||||||
|
bundleCode: string
|
||||||
|
status: BundleSaleStatus
|
||||||
|
componentSubtotal: number
|
||||||
|
bundlePrice: number
|
||||||
|
grandTotal: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSale extends BundleSaleSummary, BundleSaleTotals {
|
||||||
|
cashierUserId: number
|
||||||
|
bundleSaleTemplateId: number
|
||||||
|
updatedAt: string | null
|
||||||
|
lines: BundleSaleLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSalePostingIssue {
|
||||||
|
bundleSaleLineId: number
|
||||||
|
itemId: number
|
||||||
|
itemSku: string
|
||||||
|
itemName: string
|
||||||
|
warehouseId: number
|
||||||
|
requestedQty: number
|
||||||
|
availableQty: number
|
||||||
|
shortQty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleSalePostingCheck {
|
||||||
|
bundleSaleId: number
|
||||||
|
bundleNo: string
|
||||||
|
status: BundleSaleStatus
|
||||||
|
canPost: boolean
|
||||||
|
issues: BundleSalePostingIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBundleSaleRequest {
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
cashierUserId: number
|
||||||
|
bundleSaleTemplateId: number
|
||||||
|
bundleName: string
|
||||||
|
bundlePrice: number
|
||||||
|
allowPriceOverride: boolean
|
||||||
|
lines: BundleSaleTemplateLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateBundleSaleRequest = CreateBundleSaleRequest
|
||||||
Reference in New Issue
Block a user