intigrate others sales , return and implement day end duntinalities
This commit is contained in:
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"
|
||||
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
@@ -199,7 +200,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
|
||||
async function handleDelete() {
|
||||
if (!po) return
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setSaveError(null)
|
||||
setDeleting(true)
|
||||
try {
|
||||
@@ -280,10 +280,21 @@ export default function PurchaseOrderDetailPage() {
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving�" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
{deleting ? "Deleting…" : "Delete draft"}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
{deleting ? "Deleting…" : "Delete draft"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{cancellable && !showCancelForm && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -64,7 +65,6 @@ export default function PurchaseOrdersListPage() {
|
||||
}
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setDeletingId(po.poId)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
@@ -78,7 +78,6 @@ export default function PurchaseOrdersListPage() {
|
||||
}
|
||||
|
||||
async function handleApprove(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
||||
setApprovingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
@@ -203,30 +202,54 @@ export default function PurchaseOrdersListPage() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Approve draft"
|
||||
title="Approve draft"
|
||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||
onClick={() => handleApprove(po)}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||
onClick={() => handleDelete(po)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Approve draft"
|
||||
title="Approve draft"
|
||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title={`Approve ${po.docNo}?`}
|
||||
description="It will be locked for editing once approved."
|
||||
confirmLabel="Approve"
|
||||
onConfirm={() => handleApprove(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function PurchaseReturnsListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -106,6 +107,7 @@ export default function PurchaseReturnsListPage() {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -134,6 +134,28 @@ export default function NewGrnPage() {
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||
// line's discountPct/vatPct (clearing whatever was entered per-item), so it flows
|
||||
// through the normal per-line NetUnitCost/FIFO-layer costing instead of being a
|
||||
// separate header-only adjustment. Independent per field — setting one doesn't
|
||||
// touch the other. Cleared back to blank, the per-line fields become editable again.
|
||||
const [headerDiscountPct, setHeaderDiscountPct] = useState("")
|
||||
const [headerVatPct, setHeaderVatPct] = useState("")
|
||||
|
||||
function applyHeaderDiscountPct(value: string) {
|
||||
setHeaderDiscountPct(value)
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === "") return
|
||||
setLines((prev) => prev.map((l) => ({ ...l, discountPct: trimmed })))
|
||||
}
|
||||
|
||||
function applyHeaderVatPct(value: string) {
|
||||
setHeaderVatPct(value)
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === "") return
|
||||
setLines((prev) => prev.map((l) => ({ ...l, vatPct: trimmed })))
|
||||
}
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
@@ -171,6 +193,8 @@ export default function NewGrnPage() {
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setHeaderDiscountPct("")
|
||||
setHeaderVatPct("")
|
||||
setHeaderError(null)
|
||||
setLineErrors({})
|
||||
}
|
||||
@@ -203,8 +227,8 @@ export default function NewGrnPage() {
|
||||
qty: String(l.qty - l.qtyReceived),
|
||||
unitCost: String(l.unitPrice),
|
||||
poUnitPrice: l.unitPrice,
|
||||
discountPct: "0",
|
||||
vatPct: "0",
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
holdStatus: "Available",
|
||||
warrantyNumbers: [],
|
||||
})
|
||||
@@ -447,6 +471,38 @@ export default function NewGrnPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerDiscountPct}
|
||||
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerVatPct}
|
||||
onChange={(e) => applyHeaderVatPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -460,7 +516,20 @@ export default function NewGrnPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...emptyLine(),
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
@@ -626,6 +695,8 @@ export default function NewGrnPage() {
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
@@ -639,6 +710,8 @@ export default function NewGrnPage() {
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
|
||||
|
||||
import { salesDayEndApi } from "@/lib/api/sales-day-end"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesDayEnd, SalesDayEndPreview } from "@/types/sales"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function SalesDayEndPage() {
|
||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
const [businessDate, setBusinessDate] = useState(todayIso())
|
||||
|
||||
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
|
||||
const [report, setReport] = useState<SalesDayEnd | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.list({ pageSize: 200 })
|
||||
.then((res) => {
|
||||
setCashiers(res.items)
|
||||
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
function loadPreview() {
|
||||
if (!cashierUserId) return
|
||||
setReport(null)
|
||||
setPreviewError(null)
|
||||
setPreviewLoading(true)
|
||||
salesDayEndApi
|
||||
.preview(cashierUserId, businessDate)
|
||||
.then(setPreview)
|
||||
.catch((err) => setPreviewError(errorMessage(err)))
|
||||
.finally(() => setPreviewLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPreview()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cashierUserId, businessDate])
|
||||
|
||||
async function handleClose() {
|
||||
if (!cashierUserId) return
|
||||
setClosing(true)
|
||||
try {
|
||||
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
|
||||
setReport(result)
|
||||
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
|
||||
loadPreview()
|
||||
} catch (err) {
|
||||
toast.error("Could not close the day", errorMessage(err))
|
||||
} finally {
|
||||
setClosing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Cashier</Label>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cashiers.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId} className="text-base">
|
||||
{c.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Business date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={businessDate}
|
||||
max={todayIso()}
|
||||
onChange={(e) => setBusinessDate(e.target.value)}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewLoading && <Skeleton className="h-40 w-full" />}
|
||||
|
||||
{previewError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{previewError}</div>
|
||||
)}
|
||||
|
||||
{!previewLoading && preview && !report && (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
{preview.alreadyClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-success/30 bg-success/5 p-4 text-base text-success">
|
||||
<CheckCircle2 className="size-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.</p>
|
||||
<p className="text-sm text-success/80">{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-semibold">Still Draft — post or cancel these before closing.</p>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{preview.draftSlipsBlockingClose.map((s) => (
|
||||
<li key={`slip-${s.salesSlipId}`}>
|
||||
<Link href={`/dashboard/sales/slips/${s.salesSlipId}`} className="underline">{s.slipNo}</Link> — {s.grandTotal.toFixed(2)}
|
||||
</li>
|
||||
))}
|
||||
{preview.draftBundleSalesBlockingClose.map((b) => (
|
||||
<li key={`bundle-${b.bundleSaleId}`}>
|
||||
<Link href={`/dashboard/sales/bundles/${b.bundleSaleId}`} className="underline">{b.bundleNo}</Link> — {b.grandTotal.toFixed(2)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base text-muted-foreground">
|
||||
{preview.slipCount === 0 && preview.bundleCount === 0
|
||||
? "No sales slips or bundle sales for this cashier on this date."
|
||||
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Subtotal" value={preview.subtotal} />
|
||||
<Stat label="Discount" value={preview.discountTotal} />
|
||||
<Stat label="Tax" value={preview.taxTotal} />
|
||||
<Stat label="Grand total" value={preview.grandTotal} emphasize />
|
||||
</div>
|
||||
|
||||
{!preview.alreadyClosed && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleClose}
|
||||
disabled={closing || preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 || !cashierUserId}
|
||||
>
|
||||
<Lock className="size-5" />
|
||||
{closing ? "Closing…" : "Close day"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.alreadyClosed && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" variant="outline" onClick={handleClose} disabled={closing}>
|
||||
<ReceiptText className="size-5" />
|
||||
{closing ? "Loading…" : "View full report"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex flex-col gap-1 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{report.docNo}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{report.glJournalNo && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
GL journal <span className="font-medium text-foreground">{report.glJournalNo}</span> posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
|
||||
<Stat label="Slips" value={report.slipCount} isCount />
|
||||
<Stat label="Bundles" value={report.bundleCount} isCount />
|
||||
<Stat label="Subtotal" value={report.subtotal} />
|
||||
<Stat label="Discount" value={report.discountTotal} />
|
||||
<Stat label="Tax" value={report.taxTotal} />
|
||||
<Stat label="Grand total" value={report.grandTotal} emphasize />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
|
||||
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{report.itemBreakdown.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
report.itemBreakdown.map((line) => (
|
||||
<TableRow key={line.itemId}>
|
||||
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.slipNumbers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Slips included</h3>
|
||||
<p className="text-sm text-muted-foreground">{report.slipNumbers.join(", ")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.bundleNumbers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Bundle sales included</h3>
|
||||
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}>
|
||||
{isCount ? value : value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -142,6 +142,15 @@ export default function NewFreeIssuePage() {
|
||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||
}
|
||||
|
||||
function clearAllLines() {
|
||||
setLines([blankLine("line-1")])
|
||||
}
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
|
||||
[items]
|
||||
)
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
updateLine(key, { itemId })
|
||||
}
|
||||
@@ -299,18 +308,12 @@ export default function NewFreeIssuePage() {
|
||||
<TableRow key={line.key} className="align-middle">
|
||||
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-80">
|
||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectEditingItem(line.key, Number(v))}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((candidate) => (
|
||||
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||
{candidate.sku} - {candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
items={itemOptions}
|
||||
value={line.itemId || null}
|
||||
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)}
|
||||
placeholder="Search item…"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
@@ -332,9 +335,20 @@ export default function NewFreeIssuePage() {
|
||||
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Free issue lines</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="size-4" /> Add line
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAllLines}
|
||||
disabled={lines.length === 1 && !lines[0].itemId}
|
||||
>
|
||||
<Eraser className="size-4" /> Clear all lines
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="size-4" /> Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
@@ -353,18 +367,12 @@ export default function NewFreeIssuePage() {
|
||||
<TableRow key={line.key} className="align-middle">
|
||||
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-80">
|
||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((candidate) => (
|
||||
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||
{candidate.sku} - {candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
items={itemOptions}
|
||||
value={line.itemId || null}
|
||||
onValueChange={(v) => selectItem(line.key, v ?? 0)}
|
||||
placeholder="Search item…"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-44">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
|
||||
@@ -16,9 +16,11 @@ import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
|
||||
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
||||
|
||||
@@ -75,6 +77,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) {
|
||||
@@ -132,6 +136,26 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
.catch(() => setPostingCheck(null))
|
||||
}, [invoice, invoiceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoice || invoice.status !== "Posted") {
|
||||
setPayments([])
|
||||
return
|
||||
}
|
||||
salesApi.listInvoicePayments(invoiceId).then(setPayments).catch(() => setPayments([]))
|
||||
}, [invoice, invoiceId])
|
||||
|
||||
function handlePaid(_updated: SalesInvoice, payment: SalesInvoicePayment) {
|
||||
setInvoice((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
|
||||
}
|
||||
: prev
|
||||
)
|
||||
setPayments((prev) => [payment, ...prev])
|
||||
}
|
||||
|
||||
const isDraft = invoice?.status === "Draft"
|
||||
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
||||
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
|
||||
@@ -608,18 +632,63 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>This invoice is {invoice.status.toLowerCase()} and cannot be edited.</span>
|
||||
{invoice.status === "Posted" ? (
|
||||
<Link
|
||||
href={`/dashboard/sales/sales-returns/new?invoiceId=${invoice.salesInvoiceId}`}
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-black bg-white px-4 text-sm font-medium text-foreground shadow-sm hover:bg-muted sm:self-auto"
|
||||
>
|
||||
<Undo2 className="size-4" />
|
||||
Return items
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/dashboard/sales/sales-returns/new?invoiceId=${invoice.salesInvoiceId}`}
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-black bg-white px-4 text-sm font-medium text-foreground shadow-sm hover:bg-muted sm:self-auto"
|
||||
>
|
||||
<Undo2 className="size-4" />
|
||||
Return items
|
||||
</Link>
|
||||
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
|
||||
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.status === "Posted" && (
|
||||
<div className="mt-5 flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span className="font-semibold text-foreground">Payment status</span>
|
||||
<span className="text-muted-foreground">
|
||||
Paid {money.format(invoice.totals.paidAmount)} of {money.format(invoice.totals.netPayable)} — balance {money.format(invoice.totals.balanceAmount)}
|
||||
{invoice.glJournalNo ? ` · GL journal ${invoice.glJournalNo}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{payments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Date</th>
|
||||
<th className="py-1 pr-3">Account</th>
|
||||
<th className="py-1 pr-3">Reference</th>
|
||||
<th className="py-1 pr-3">GL journal</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.salesInvoicePaymentId} className="border-t border-border">
|
||||
<td className="py-2 pr-3">{new Date(p.paymentDate).toLocaleString()}</td>
|
||||
<td className="py-2 pr-3">{p.bankAccountName}</td>
|
||||
<td className="py-2 pr-3">{p.reference ?? "—"}</td>
|
||||
<td className="py-2 pr-3">{p.glJournalNo ?? "—"}</td>
|
||||
<td className="py-2 text-right font-mono tabular-nums">{money.format(p.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SalesInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -23,6 +23,12 @@ const sections = [
|
||||
href: "/dashboard/sales/free-issues",
|
||||
icon: PackageX,
|
||||
},
|
||||
{
|
||||
title: "Day End",
|
||||
description: "Close a cashier's day and post it to the ledger.",
|
||||
href: "/dashboard/sales/day-end",
|
||||
icon: Lock,
|
||||
},
|
||||
// {
|
||||
// title: "Reports",
|
||||
// description: "Sales report catalog and query entry point.",
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function SalesReturnsListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -110,6 +111,7 @@ export default function SalesReturnsListPage() {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function AdjustmentsListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -93,6 +94,7 @@ export default function AdjustmentsListPage() {
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<AdjustmentStatusBadge status={a.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -124,6 +124,9 @@ export default function CountDetailPage() {
|
||||
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
|
||||
{postResult.glJournalNo && (
|
||||
<div className="text-sm text-muted-foreground">GL journal: <span className="font-medium text-foreground">{postResult.glJournalNo}</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+20
-3
@@ -8,6 +8,7 @@ import { AlertTriangle, Save } from "lucide-react"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -39,6 +40,22 @@ export default function VendorDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
|
||||
// Every other vendor's code, to de-dupe against when the name edit regenerates this
|
||||
// vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx).
|
||||
const [otherVendorCodes, setOtherVendorCodes] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
vendorsApi
|
||||
.list({ pageSize: 200 })
|
||||
.then((res) => setOtherVendorCodes(res.items.filter((v) => v.vendorId !== vendorId).map((v) => v.code)))
|
||||
.catch(() => {})
|
||||
}, [vendorId])
|
||||
|
||||
function handleNameChange(value: string) {
|
||||
setName(value)
|
||||
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
vendorsApi
|
||||
@@ -177,13 +194,13 @@ export default function VendorDetailPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Code</Label>
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} className="h-12 text-base" disabled={conflict} />
|
||||
<Label className="text-base">Code (auto-generated)</Label>
|
||||
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoice | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against a posted invoice's balance — full or partial, into an
|
||||
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [reference, setReference] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||
// "adjust state while rendering" pattern as GrnPaymentDialog, not inside an effect.
|
||||
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setReference("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accounts !== null) return
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
async function submit() {
|
||||
if (!invoice) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
|
||||
onPaid(invoice, payment)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
|
||||
import { ChevronDownIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ComboboxOption<Value> {
|
||||
value: Value
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps<Value> {
|
||||
items: ComboboxOption<Value>[]
|
||||
value: Value | null
|
||||
onValueChange: (value: Value | null) => void
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
"aria-invalid"?: boolean
|
||||
}
|
||||
|
||||
/** Searchable dropdown for long option lists (items, vendors, …) — a `Select` swap-in
|
||||
* built on Base UI's Combobox, styled to match `select.tsx`'s trigger/popup/item look. */
|
||||
export function Combobox<Value>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Search…",
|
||||
emptyText = "No results found.",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: ComboboxProps<Value>) {
|
||||
const itemToStringLabel = React.useCallback(
|
||||
(v: Value) => items.find((i) => i.value === v)?.label ?? "",
|
||||
[items]
|
||||
)
|
||||
|
||||
return (
|
||||
<ComboboxPrimitive.Root<Value>
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(v) => onValueChange(v ?? null)}
|
||||
itemToStringLabel={itemToStringLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxPrimitive.InputGroup
|
||||
data-slot="combobox-input-group"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ComboboxPrimitive.Input
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-full w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
<ComboboxPrimitive.Icon
|
||||
render={<ChevronDownIcon className="pointer-events-none size-4 shrink-0 text-muted-foreground" />}
|
||||
/>
|
||||
</ComboboxPrimitive.InputGroup>
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
>
|
||||
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</ComboboxPrimitive.Empty>
|
||||
<ComboboxPrimitive.List>
|
||||
{(item: ComboboxOption<Value>) => (
|
||||
<ComboboxPrimitive.Item
|
||||
key={String(item.value)}
|
||||
value={item.value}
|
||||
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
|
||||
>
|
||||
{item.label}
|
||||
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)}
|
||||
</ComboboxPrimitive.List>
|
||||
</ComboboxPrimitive.Popup>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
</ComboboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Cashier day-end close-out (docs/14 Sales API). Preview shows what closing right now
|
||||
// would include (or the existing close, if already closed); close is the one-shot,
|
||||
// idempotent action that locks the day's Posted slips and posts the consolidated GL entry.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateSalesDayEndRequest, SalesDayEnd, SalesDayEndPreview, SalesDayEndSummary } from "@/types/sales"
|
||||
|
||||
export interface ListSalesDayEndsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
cashierUserId?: number
|
||||
}
|
||||
|
||||
export const salesDayEndApi = {
|
||||
list(params: ListSalesDayEndsParams = {}): Promise<PagedResponse<SalesDayEndSummary>> {
|
||||
return apiRequest<PagedResponse<SalesDayEndSummary>>(`/sales-day-end${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(salesDayEndId: number): Promise<SalesDayEnd> {
|
||||
return apiRequest<SalesDayEnd>(`/sales-day-end/${salesDayEndId}`)
|
||||
},
|
||||
|
||||
preview(cashierUserId: number, businessDate?: string): Promise<SalesDayEndPreview> {
|
||||
return apiRequest<SalesDayEndPreview>(`/sales-day-end/preview${buildQuery({ cashierUserId, businessDate })}`)
|
||||
},
|
||||
|
||||
/** Idempotent — closing an already-closed date replays the existing record. */
|
||||
close(request: CreateSalesDayEndRequest): Promise<SalesDayEnd> {
|
||||
return apiRequest<SalesDayEnd>("/sales-day-end", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateSalesInvoicePaymentRequest,
|
||||
CreateSalesInvoiceRequest,
|
||||
CreateSalesSlipRequest,
|
||||
SalesInvoice,
|
||||
SalesInvoiceStatus,
|
||||
SalesInvoicePayment,
|
||||
SalesInvoicePostingCheck,
|
||||
SalesInvoiceSummary,
|
||||
SalesListFilterParams,
|
||||
@@ -65,6 +67,16 @@ export const salesApi = {
|
||||
return apiRequest<SalesInvoice>(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Pay the customer's balance against a posted invoice, in full or in installments. Posts a real GL journal entry. */
|
||||
payInvoice(salesInvoiceId: number, request: CreateSalesInvoicePaymentRequest): Promise<SalesInvoicePayment> {
|
||||
return apiRequest<SalesInvoicePayment>(`/sales-invoices/${salesInvoiceId}/payments`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Payment history for an invoice, newest first. */
|
||||
listInvoicePayments(salesInvoiceId: number): Promise<SalesInvoicePayment[]> {
|
||||
return apiRequest<SalesInvoicePayment[]>(`/sales-invoices/${salesInvoiceId}/payments`)
|
||||
},
|
||||
|
||||
listSlips(params: ListSalesSlipsParams = {}): Promise<PagedResponse<SalesSlipSummary>> {
|
||||
return apiRequest<PagedResponse<SalesSlipSummary>>(`/sales-slips${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
@@ -219,6 +219,8 @@ export interface PurchaseReturn {
|
||||
status: PurchaseReturnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
lines: PurchaseReturnLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
@@ -233,6 +235,7 @@ export interface PurchaseReturnSummary {
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
glJournalNo: string | null
|
||||
}
|
||||
|
||||
export interface CreatePurchaseReturnLineInput {
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SalesInvoiceSummary {
|
||||
status: SalesInvoiceStatus
|
||||
totals: SalesInvoiceTotals
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
}
|
||||
|
||||
export interface SalesInvoicePostingIssue {
|
||||
@@ -95,9 +96,28 @@ export interface SalesInvoice extends SalesInvoiceSummary {
|
||||
customerSnapshotTaxNo: string | null
|
||||
createdBy: number
|
||||
updatedAt: string | null
|
||||
glPostedAt: string | null
|
||||
lines: SalesInvoiceLine[]
|
||||
}
|
||||
|
||||
export interface SalesInvoicePayment {
|
||||
salesInvoicePaymentId: number
|
||||
salesInvoiceId: number
|
||||
amount: number
|
||||
paymentDate: string
|
||||
glBankAccountId: number
|
||||
bankAccountName: string
|
||||
reference: string | null
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateSalesInvoicePaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
export interface CreateSalesInvoiceLineRequest {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
@@ -329,6 +349,8 @@ export interface SalesReturn {
|
||||
status: SalesReturnStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
lines: SalesReturnLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
@@ -344,6 +366,7 @@ export interface SalesReturnSummary {
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
totalQty: number
|
||||
glJournalNo: string | null
|
||||
}
|
||||
|
||||
/** Remaining returnable qty for one sales invoice line (invoiced qty minus already-returned). */
|
||||
@@ -365,3 +388,79 @@ export interface CreateSalesReturnRequest {
|
||||
reasonCodeId: number
|
||||
lines: CreateSalesReturnLineInput[]
|
||||
}
|
||||
|
||||
// Sales Day End -------------------------------------------------------------------
|
||||
|
||||
export interface SalesDayEndItemLine {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
qty: number
|
||||
revenue: number
|
||||
}
|
||||
|
||||
export interface SalesDayEnd {
|
||||
salesDayEndId: number
|
||||
docNo: string
|
||||
cashierUserId: number
|
||||
businessDate: string
|
||||
slipCount: number
|
||||
bundleCount: number
|
||||
subtotal: number
|
||||
discountTotal: number
|
||||
taxTotal: number
|
||||
grandTotal: number
|
||||
costOfGoodsSold: number
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
closedBy: number
|
||||
closedAt: string
|
||||
slipNumbers: string[]
|
||||
bundleNumbers: string[]
|
||||
itemBreakdown: SalesDayEndItemLine[]
|
||||
}
|
||||
|
||||
export interface SalesDayEndSummary {
|
||||
salesDayEndId: number
|
||||
docNo: string
|
||||
cashierUserId: number
|
||||
businessDate: string
|
||||
slipCount: number
|
||||
bundleCount: number
|
||||
grandTotal: number
|
||||
glJournalNo: string | null
|
||||
closedAt: string
|
||||
}
|
||||
|
||||
export interface DraftSlipBlockingClose {
|
||||
salesSlipId: number
|
||||
slipNo: string
|
||||
grandTotal: number
|
||||
}
|
||||
|
||||
export interface DraftBundleBlockingClose {
|
||||
bundleSaleId: number
|
||||
bundleNo: string
|
||||
grandTotal: number
|
||||
}
|
||||
|
||||
export interface SalesDayEndPreview {
|
||||
cashierUserId: number
|
||||
businessDate: string
|
||||
alreadyClosed: boolean
|
||||
slipCount: number
|
||||
bundleCount: number
|
||||
subtotal: number
|
||||
discountTotal: number
|
||||
taxTotal: number
|
||||
grandTotal: number
|
||||
slipNumbers: string[]
|
||||
bundleNumbers: string[]
|
||||
draftSlipsBlockingClose: DraftSlipBlockingClose[]
|
||||
draftBundleSalesBlockingClose: DraftBundleBlockingClose[]
|
||||
}
|
||||
|
||||
export interface CreateSalesDayEndRequest {
|
||||
cashierUserId: number
|
||||
businessDate?: string | null
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ export interface StockAdjustment {
|
||||
status: AdjustmentStatus
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
lines: AdjustmentLine[]
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
@@ -186,6 +188,7 @@ export interface StockAdjustmentSummary {
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
glJournalNo: string | null
|
||||
}
|
||||
|
||||
export interface CreateAdjustmentLineInput {
|
||||
@@ -258,6 +261,8 @@ export interface PostCountResponse {
|
||||
status: CountStatus
|
||||
/** Null when the count had no variance to post. */
|
||||
adjustmentId: number | null
|
||||
/** Null when there was no variance, or the variance's value was 0 either way. */
|
||||
glJournalNo: string | null
|
||||
ledgerRefs: number[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user