diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx new file mode 100644 index 0000000..3efe88f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx @@ -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(null) + const [editing, setEditing] = useState(false) + const [templates, setTemplates] = useState([]) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [templateId, setTemplateId] = useState(null) + const [bundleName, setBundleName] = useState("") + const [bundlePrice, setBundlePrice] = useState(0) + const [allowPriceOverride, setAllowPriceOverride] = useState(false) + const [lines, setLines] = useState([]) + const [error, setError] = useState(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) { + 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
{error}
+ if (!bundle) return
Loading bundle sale...
+ + const printHref = `/print/sales/bundles/${bundle.bundleSaleId}` + + return ( +
+
+
+ + + +
+

{bundle.bundleNo}

+

{bundle.bundleName}

+
+
+
+ + + Print + + {canEdit ? ( + editing ? ( + + ) : ( + + ) + ) : null} + {isDraft ? ( + <> + + + + ) : null} +
+
+ + {error ?
{error}
: null} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setBundleName(e.target.value)} disabled={!editing || !isDraft} /> +
+
+ + setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} /> +
+
+
+ +
+
+
+

Component breakdown

+

{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}

+
+ {editing && isDraft ? : {bundle.status}} +
+
+ + + + Item + UOM + Qty + Unit price + Include + {editing && isDraft ? : null} + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + {line.includeInBundle ? "Yes" : "No"} + {editing && isDraft ? : null} + + ))} + +
+
+
+ +
+
+
Component subtotal
{componentSubtotal.toFixed(2)}
+
Bundle price
{bundlePrice.toFixed(2)}
+
Margin
{(bundlePrice - componentSubtotal).toFixed(2)}
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx new file mode 100644 index 0000000..42337bf --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx @@ -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([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [templates, setTemplates] = useState([]) + const [template, setTemplate] = useState(null) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [templateId, setTemplateId] = useState(templateFromQuery ? Number(templateFromQuery) : null) + const [bundleName, setBundleName] = useState("Demo Bundle") + const [bundlePrice, setBundlePrice] = useState(0) + const [allowPriceOverride, setAllowPriceOverride] = useState(false) + const [lines, setLines] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [submitError, setSubmitError] = useState(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) { + 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
Loading masters...
+ + return ( +
+
+ + + +
+

Create bundle sale

+

Create a fixed bundle from a stored template.

+
+
+ + {submitError ?
{submitError}
: null} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setBundleName(e.target.value)} /> +
+
+ + setBundlePrice(Number(e.target.value))} /> +
+
+ + +
+
+
+ +
+
+
+

Editable component rows

+

These rows are sent to the backend and stored with the bundle.

+
+ +
+
+ + + + Item + UOM + Qty + Unit price + Include + + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> + updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /> + {line.includeInBundle ? "Yes" : "No"} + + + + + ))} + +
+
+
+ +
+
+
Component subtotal
{componentSubtotal.toFixed(2)}
+
Bundle price
{bundlePrice.toFixed(2)}
+
Margin
{(bundlePrice - componentSubtotal).toFixed(2)}
+
+
+ +
+ Cancel + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx new file mode 100644 index 0000000..927fdc0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx @@ -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(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [showFilters, setShowFilters] = useState(false) + const [customers, setCustomers] = useState([]) + const [warehouses, setWarehouses] = useState([]) + + 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 ( +
+
+
+

Bundle Sales

+

Fixed bundle register with draft, posted, and cancelled states.

+
+
+ + + Print batch + + + + New Bundle + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+
+ setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" /> + +
+
+ + {showFilters && ( +
+
+
Customer
+ +
+
+
Warehouse
+ +
+
+ +
+
+ )} + + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Bundle + Customer + Date + Price + Grand + Status + View + + + + {visibleRows.map((row) => ( + + + + {row.bundleNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {row.bundlePrice.toFixed(2)} + {row.grandTotal.toFixed(2)} + + + {row.status} + + + +
+ + + + + + +
+
+
+ ))} +
+
+
+ +
+
+
+
Rows loaded
+
{visibleRows.length}
+
+
+
Grand total
+
{bundleTotal.toFixed(2)}
+
+
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/register/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/register/page.tsx new file mode 100644 index 0000000..ade96a5 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/register/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function BundleRegisterPage() { + redirect("/dashboard/sales/bundles") +} diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx new file mode 100644 index 0000000..b6022c6 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/bundles/reports/page.tsx @@ -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 ( +
+
+ + + +
+

Bundle Reports

+

Bundle-level reporting will be added after the backend module is wired.

+
+
+
+ + This screen is a placeholder for bundle sales reporting. +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx index 8e1bf9c..2dced6d 100644 --- a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx @@ -182,12 +182,13 @@ export default function NewFreeIssuePage() { discountValue: Number(line.discountValue), taxPct: Number(line.taxPct), isFreeIssue: line.isFreeIssue, - parentLineId: line.parentLineId || null, + parentLineId: line.parentLineId ?? null, })), } if (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) toast.success("Free issue updated") setEditingRowId(null) diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 113ec6d..269be9b 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -5,7 +5,6 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react" -import { companyApi } from "@/lib/api/company" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" @@ -14,7 +13,6 @@ import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" -import { CompanyProfile } from "@/types/company" import { Customer } from "@/types/customers" import { ItemListItem, Uom, Warehouse } from "@/types/master-data" 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 [invoice, setInvoice] = useState(null) - const [company, setCompany] = useState(null) const [customers, setCustomers] = useState([]) const [items, setItems] = useState([]) const [uoms, setUoms] = useState([]) @@ -86,15 +83,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i } Promise.all([ - companyApi.getProfile(), customersApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 }), salesApi.getInvoice(invoiceId), ]) - .then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => { - setCompany(companyRes.data) + .then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => { setCustomers(customerRes.items) setItems(itemRes.items) setUoms(uomRes.items) @@ -269,7 +264,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i

{invoice.invoiceNo}

- + Print @@ -281,9 +281,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
-
Sales Invoice
-

{invoice.invoiceNo}

-
+
Sales Invoice
+

{invoice.invoiceNo}

+
Status: {invoice.status} Type: {invoice.invoiceType} @@ -291,12 +291,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
-
{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}
-
- {company?.addressLine1 ?? ""} - {company?.city ? `, ${company.city}` : ""} -
- {company?.taxRegistrationNo ?
Tax No: {company.taxRegistrationNo}
: null} +
ERP Core Trading
+
Company details are not configured for this invoice view.
@@ -418,7 +414,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
Notes
-

{company?.footerNote ?? "Standard invoice template view."}

+

Standard invoice template view.

diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx index 6fca4fb..c86302d 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx @@ -5,7 +5,6 @@ import Link from "next/link" import { ArrowLeft, Printer } from "lucide-react" import { salesApi } from "@/lib/api/sales" -import { companyApi } from "@/lib/api/company" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" 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 { cn } from "@/lib/utils" import { Customer } from "@/types/customers" -import { CompanyProfile } from "@/types/company" import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { SalesInvoice } from "@/types/sales" export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) { const invoiceId = Number(params.id) const [invoice, setInvoice] = useState(null) - const [company, setCompany] = useState(null) const [customers, setCustomers] = useState([]) const [items, setItems] = useState([]) const [uoms, setUoms] = useState([]) @@ -36,15 +33,13 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string return } Promise.all([ - companyApi.getProfile(), customersApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 }), salesApi.getInvoice(invoiceId), ]) - .then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => { - setCompany(companyRes.data) + .then(([cust, itemRes, uomRes, whRes, doc]) => { setCustomers(cust.items) setItems(itemRes.items) setUoms(uomRes.items) @@ -94,10 +89,8 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
Status: {invoice.status} · Type: {invoice.invoiceType}
-
{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}
- {company?.logoUrl ? {company.tradeName : null} -
{company?.addressLine1}{company?.city ? `, ${company.city}` : ""}
- {company?.taxRegistrationNo ?
Tax No: {company.taxRegistrationNo}
: null} +
ERP Core Trading
+
Company details are not configured for this print view.
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
Printed: {new Date().toLocaleString()}
Free qty total: {freeQtyTotal.toFixed(2)}
@@ -181,9 +174,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
Notes
-

- {company?.footerNote ?? "Standard invoice print view."} -

+

Standard invoice print view.

Totals
@@ -199,19 +190,6 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
- {company ? ( -
-
Bank Details
-
-
Bank {company.bankName ?? "—"}
-
Branch {company.bankBranch ?? "—"}
-
Account Name {company.accountName ?? "—"}
-
Account No {company.accountNumber ?? "—"}
-
SWIFT {company.swiftCode ?? "—"}
-
VAT {company.vatRegistrationNo ?? "—"}
-
-
- ) : null}
) diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx index 6da9053..4b84e07 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -99,17 +99,16 @@ export default function NewSalesInvoicePage() { ]) setActiveFocSchemes( freeIssueRes.items.flatMap((issue) => { - const firstLine = issue.lines?.[0] - if (!firstLine) return [] - const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId) + if (!issue.itemId) return [] + const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId) const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId) return [ { id: issue.salesSlipId, slipNo: issue.slipNo, - schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`, + schemeLabel: issue.schemeLabel, 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}`, }, ] }), diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index 5e306e3..d439ef7 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -69,6 +69,7 @@ export default function SalesInvoicesPage() { const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` return (
@@ -78,10 +79,15 @@ export default function SalesInvoicesPage() {

Invoice register with filters, posting flow, and settlement tracking.

- + New Invoice diff --git a/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx index 3d7f552..9a50c88 100644 --- a/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx @@ -2,7 +2,7 @@ import { use, useEffect, useMemo, useState } from "react" 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 { customersApi } from "@/lib/api/customers" @@ -251,6 +251,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
+ + + Print + diff --git a/Frontend/erp-system/app/dashboard/sales/slips/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx index 98806e3..ad31b4a 100644 --- a/Frontend/erp-system/app/dashboard/sales/slips/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx @@ -69,6 +69,7 @@ export default function SalesSlipsPage() { const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` return (
@@ -78,10 +79,15 @@ export default function SalesSlipsPage() {

Counter sales register with posting and cancellation flow.

- + New Slip diff --git a/Frontend/erp-system/app/layout.tsx b/Frontend/erp-system/app/layout.tsx index 73ba6c1..28ad97b 100644 --- a/Frontend/erp-system/app/layout.tsx +++ b/Frontend/erp-system/app/layout.tsx @@ -27,6 +27,7 @@ export default function RootLayout({ diff --git a/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx new file mode 100644 index 0000000..1c834ad --- /dev/null +++ b/Frontend/erp-system/app/print/sales/bundles/[id]/page.tsx @@ -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(null) + const [error, setError] = useState(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
{error}
+ if (!bundle) return
Loading bundle print...
+ + return ( +
+
+ +
+
+
Bundle Sales
+

{bundle.bundleNo}

+

{bundle.bundleName}

+
+
+
Customer
{bundle.customerSnapshotName}
+
Warehouse
{bundle.warehouseId}
+
Status
{bundle.status}
+
+
+ + + + Item + Description + Qty + Price + Total + + + + {bundle.lines.map((line) => ( + + {line.itemId} + {line.description} + {line.qty.toFixed(2)} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/bundles/page.tsx b/Frontend/erp-system/app/print/sales/bundles/page.tsx new file mode 100644 index 0000000..ac0db97 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/bundles/page.tsx @@ -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(null) + const [error, setError] = useState(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
{error}
+ if (!rows) return
Loading bundle print data...
+ + return ( +
+
+ +
+
+
Bundle Sales
+

Bundle Batch Print

+

Printed register snapshot of current bundle sales.

+
+
+ + + + Bundle + Customer + Date + Price + Grand + Status + + + + {visibleRows?.map((row) => ( + + {row.bundleNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {row.bundlePrice.toFixed(2)} + {row.grandTotal.toFixed(2)} + + + {row.status} + + + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx new file mode 100644 index 0000000..629418d --- /dev/null +++ b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx @@ -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(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [error, setError] = useState(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
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === invoice.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId) + + return ( +
+
+ +
+ +
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
Status: {invoice.status} · Type: {invoice.invoiceType}
+
+
+
ERP Core Trading
+
Invoice print view
+
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
+
Printed: {new Date().toLocaleString()}
+
Free qty total: {freeQtyTotal.toFixed(2)}
+
+
+ +
+
+
Customer
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} + {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
+
+
Totals
+
+
Subtotal
{invoice.totals.subtotal.toFixed(2)}
+
Discount
{invoice.totals.discountTotal.toFixed(2)}
+
Free qty
{freeQtyTotal.toFixed(2)}
+
Tax
{invoice.totals.taxTotal.toFixed(2)}
+
Net payable
{invoice.totals.netPayable.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Discount + Tax + Line total + + + + {invoice.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(2)} + {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} + {line.unitPrice.toFixed(2)} + {line.discountAmount.toFixed(2)} + {line.taxAmount.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/invoices/page.tsx b/Frontend/erp-system/app/print/sales/invoices/page.tsx new file mode 100644 index 0000000..ab16f55 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/invoices/page.tsx @@ -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(null) + const [error, setError] = useState(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
{error}
+ } + + if (!rows) { + return
{error ?? "Invoice batch print data is loading or unavailable."}
+ } + + return ( +
+
+ +
+ +
+
Sales Invoices
+

Invoice Batch Print

+

Printed register snapshot of current invoices.

+
+ +
+ + + + Invoice + Customer + Date + Due + Lines + Gross + Discount + Net + Status + + + + {visibleRows?.map((row) => ( + + {row.invoiceNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.invoiceDate).toLocaleDateString()} + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + + {row.status} + + + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx new file mode 100644 index 0000000..24ca5db --- /dev/null +++ b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx @@ -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(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [error, setError] = useState(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
{error}
+ } + + if (!slip) { + return
{error ?? "Slip print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === slip.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId) + + return ( +
+
+ +
+ +
+
Sales Slip
+

{slip.slipNo}

+
Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}
+
+ +
+
+
Customer
+
{slip.customerSnapshotName}
+
Customer ID: {slip.customerId}
+ {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouse?.code ?? slip.warehouseId}
+
+
+
Totals
+
+
Subtotal
{slip.totals.subtotal.toFixed(2)}
+
Discount
{slip.totals.discountTotal.toFixed(2)}
+
Free qty
{slip.totals.freeQtyTotal.toFixed(2)}
+
Net total
{slip.totals.grandTotal.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Line total + + + + {slip.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(0)} + {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} + {line.unitPrice.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+ +
+
Cashier
+
{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}
+
Subtotal: {subtotal.toFixed(2)}
+
+
+ ) +} diff --git a/Frontend/erp-system/app/print/sales/slips/page.tsx b/Frontend/erp-system/app/print/sales/slips/page.tsx new file mode 100644 index 0000000..0c941b4 --- /dev/null +++ b/Frontend/erp-system/app/print/sales/slips/page.tsx @@ -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(null) + const [error, setError] = useState(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
{error}
+ } + + if (!rows) { + return
{error ?? "Slip batch print data is loading or unavailable."}
+ } + + return ( +
+
+ +
+ +
+
Sales Slips
+

Slip Batch Print

+

Printed register snapshot of current slips.

+
+ +
+ + + + Slip + Customer + Date + Status + Lines + Gross + Discount + Net + + + + {visibleRows?.map((row) => ( + + {row.slipNo} + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + + + {row.status} + + + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index a286926..5585562 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -110,6 +110,7 @@ const navItems: { children: [ { title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, { 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: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, ], diff --git a/Frontend/erp-system/lib/api/bundles.ts b/Frontend/erp-system/lib/api/bundles.ts new file mode 100644 index 0000000..9400d3f --- /dev/null +++ b/Frontend/erp-system/lib/api/bundles.ts @@ -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> { + return apiRequest>(`/bundle-sales${buildQuery(params)}`) + }, + + getBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}`) + }, + + createBundle(request: CreateBundleSaleRequest): Promise { + return apiRequest("/bundle-sales", { method: "POST", body: request }) + }, + + updateBundle(bundleSaleId: number, request: UpdateBundleSaleRequest): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}`, { method: "PUT", body: request }) + }, + + postBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/post`, { method: "POST" }) + }, + + cancelBundle(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/cancel`, { method: "POST" }) + }, + + checkBundlePosting(bundleSaleId: number): Promise { + return apiRequest(`/bundle-sales/${bundleSaleId}/posting-check`) + }, + + listTemplates(params: { page?: number; pageSize?: number; q?: string } = {}): Promise> { + return apiRequest>(`/bundle-sales/templates${buildQuery(params)}`) + }, + + getTemplate(bundleSaleTemplateId: number): Promise { + return apiRequest(`/bundle-sales/templates/${bundleSaleTemplateId}`) + }, +} diff --git a/Frontend/erp-system/types/bundles.ts b/Frontend/erp-system/types/bundles.ts new file mode 100644 index 0000000..d5d8ae7 --- /dev/null +++ b/Frontend/erp-system/types/bundles.ts @@ -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