fix sales issues

This commit is contained in:
2026-08-05 17:03:49 +05:30
parent 4324ba1a96
commit cbc72ef830
14 changed files with 392 additions and 114 deletions
@@ -286,10 +286,7 @@ export default function PurchaseOrderDetailPage() {
<>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Check className="size-5" />
{submitting ? "Approving…" : "Approve"}
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Check className="size-5" />
{submitting ? "Approving…" : "Approve"}
{submitting ? "Approving…" : "Approve"}
</Button>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
@@ -3,7 +3,7 @@
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 { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
@@ -26,6 +26,18 @@ import { ManagedUser } from "@/types/users"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
@@ -94,7 +106,7 @@ export default function BundleSaleDetailPage() {
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
uomId: line.uomId,
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
@@ -104,7 +116,7 @@ export default function BundleSaleDetailPage() {
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id])
}, [bundleSaleId, params.id, items])
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])
@@ -116,9 +128,9 @@ export default function BundleSaleDetailPage() {
}
function addLine() {
const source = lines[lines.length - 1]
const source = template?.lines[0]
if (!source) return
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
setLines((prev) => [...prev, createBlankLine(source)])
}
function removeLine(key: string) {
@@ -170,6 +182,20 @@ export default function BundleSaleDetailPage() {
}
}
const shortageIssues = bundle && bundle.status === "Draft"
? bundle.lines
.filter((line) => line.includeInBundle)
.filter((line) => Number(line.qty || 0) > 0)
.map((line) => ({
bundleSaleLineId: line.bundleSaleLineId,
itemId: line.itemId,
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
warehouseId: line.warehouseId,
requestedQty: line.qty,
}))
: []
async function cancelBundle() {
if (!bundle) return
setBusy(true)
@@ -236,6 +262,47 @@ export default function BundleSaleDetailPage() {
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</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">
@@ -304,7 +371,7 @@ export default function BundleSaleDetailPage() {
<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 })
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
@@ -319,7 +386,7 @@ export default function BundleSaleDetailPage() {
</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}>
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
@@ -334,15 +401,78 @@ export default function BundleSaleDetailPage() {
</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>
<TableCell className="text-right">
{editing && isDraft ? (
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
) : (
line.includeInBundle ? "Included" : "Excluded"
)}
</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>
))}
{lines.length === 0 ? (
<TableRow>
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add line to start.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</section>
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<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>
@@ -3,7 +3,7 @@
import { Suspense, 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 { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
@@ -27,7 +27,17 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary,
type EditableLine = BundleSaleTemplateLine & { key: string }
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
uomId: templateLine?.uomId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function NewBundleSaleContent() {
const router = useRouter()
@@ -81,10 +91,20 @@ function NewBundleSaleContent() {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(res.lines.map(blankLine))
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
uomId: item?.baseUomId ?? line.uomId,
})
})
: [createBlankLine()]
)
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [templateId])
}, [items, 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])
@@ -94,9 +114,7 @@ function NewBundleSaleContent() {
}
function addLine() {
const source = lines[lines.length - 1] ?? template?.lines[0]
if (!source) return
setLines((prev) => [...prev, blankLine(source)])
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
}
function removeLine(key: string) {
@@ -151,6 +169,22 @@ function NewBundleSaleContent() {
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Need stock before saving this bundle?</div>
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
</div>
<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">
@@ -222,13 +256,14 @@ function NewBundleSaleContent() {
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
<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,
})
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
uomId: item?.baseUomId ?? line.uomId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
@@ -243,7 +278,7 @@ function NewBundleSaleContent() {
</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) })}>
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
@@ -258,12 +293,30 @@ function NewBundleSaleContent() {
</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"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</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>
))}
{lines.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add component to start.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
@@ -84,7 +84,18 @@ export default function BundleSalesPage() {
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(() => rows ?? [], [rows])
const visibleRows = useMemo(
() =>
rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
.toLowerCase()
.includes(query.toLowerCase())
return matchesStatus && matchesQuery
}) ?? [],
[rows, query, status]
)
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 ?? ""}`
@@ -125,7 +136,12 @@ export default function BundleSalesPage() {
))}
</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" />
<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
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -157,6 +157,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
}
@@ -181,14 +186,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
customerId,
warehouseId,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
@@ -378,8 +383,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
<div className="border-t pt-5">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
@@ -432,6 +448,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<Save className="size-4" />
{saving ? "Saving..." : "Save invoice"}
</button>
{postingCheck && !postingCheck.canPost ? (
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Resolve stock in GRN
</Link>
) : null}
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
@@ -496,15 +521,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
/>
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
<td className="px-4 py-3 w-32">
<input
type="number"
@@ -514,6 +539,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</td>
<td className="px-4 py-3 w-24 text-right">
<button
@@ -400,6 +400,11 @@ export default function NewSalesInvoicePage() {
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -2,7 +2,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -155,6 +155,11 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
@@ -172,14 +177,14 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
customerId,
warehouseId,
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
uomId: Number(line.uomId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
@@ -335,10 +340,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
<TableCell className="px-4 py-3">
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
</TableRow>
@@ -350,8 +360,19 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
@@ -111,6 +111,11 @@ export default function NewSalesSlipPage() {
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
@@ -162,7 +167,7 @@ export default function NewSalesSlipPage() {
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
@@ -366,6 +371,11 @@ export default function NewSalesSlipPage() {
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
@@ -165,9 +165,12 @@ export function Header() {
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
// Read after mount, not during render: localStorage doesn't exist on the server, and
// reading it while rendering would desync the hydration pass.
const [user] = useState<AuthUser | null>(() => getStoredUser())
// Read after mount so the first client render matches the server render.
const [user, setUser] = useState<AuthUser | null>(null)
useEffect(() => {
setUser(getStoredUser())
}, [])
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
@@ -278,16 +281,18 @@ export function Header() {
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
<Avatar>
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
{initials(displayName(user))}
{user ? initials(displayName(user)) : "?"}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-foreground sm:block">
{displayName(user)}
{user ? displayName(user) : "Signed in"}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
<p className="text-base font-semibold text-foreground">
{user ? displayName(user) : "Signed in"}
</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
</div>
<DropdownMenuSeparator />
@@ -1,4 +1,4 @@
"use client"
"use client"
import Link from "next/link"
import { Lightbulb, PackageCheck } from "lucide-react"
@@ -11,7 +11,7 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
if (!suggestion || suggestion.lines.length === 0) {
return (
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
No free-issue promotion suggestions were generated for this slip yet.
No free-issue suggestions were generated for this slip yet.
</div>
)
}
@@ -22,10 +22,10 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
<Lightbulb className="size-3.5" />
Backend suggestion
Suggestion only
</div>
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
<p className="text-sm text-muted-foreground">These are suggestions only. You can review them before creating a free issue.</p>
</div>
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Create free issue
@@ -40,14 +40,12 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
<div className="font-medium text-foreground">{line.itemName}</div>
<div className="text-sm text-muted-foreground">{line.itemSku} Qty {line.qty}</div>
</div>
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
Buy {line.triggerQty} get {line.suggestedFreeQty} free
</div>
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Free issue</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-sm">
{line.rewardOptions.map((option, index) => (
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary")}>
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
{option.name}
</span>
@@ -55,11 +53,11 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
</div>
<div className="mt-3 text-sm text-muted-foreground">
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
Free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
)
}
}