sales fixers
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { trackingTags } from "@/lib/tracking-tags"
|
||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
|
||||
|
||||
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||
|
||||
const blankLine = (warehouseId = 0): Line => ({
|
||||
key: "line-1",
|
||||
itemId: 0,
|
||||
warehouseId,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: "Percentage",
|
||||
discountPct: 0,
|
||||
discountAmount: 0,
|
||||
discountValue: 0,
|
||||
taxPct: 0,
|
||||
isFreeIssue: false,
|
||||
parentLineId: null,
|
||||
})
|
||||
|
||||
const LIST_HREF = "/dashboard/sales/free-issues"
|
||||
|
||||
/** Mobile-first section shell, same as the New Item screen. */
|
||||
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
|
||||
|
||||
/**
|
||||
* New / Edit free issue as a full page (Sales → Free Issues). A free issue is one "buy X get Y free" line for a
|
||||
* customer, warehouse and cashier. Pass `salesSlipId` to edit an existing one.
|
||||
*/
|
||||
export function FreeIssueForm({ salesSlipId }: { salesSlipId?: number }) {
|
||||
const router = useRouter()
|
||||
const editing = salesSlipId !== undefined
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
const [line, setLine] = useState<Line>(blankLine())
|
||||
const [slipNo, setSlipNo] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
editing ? salesApi.getFreeIssue(salesSlipId) : Promise.resolve(null),
|
||||
])
|
||||
.then(([cust, itemRes, uomRes, whRes, userRes, detail]) => {
|
||||
setCustomers(cust.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
setUsers(userRes.items)
|
||||
if (detail) {
|
||||
const d = detail.data
|
||||
const first = d.lines[0]
|
||||
setSlipNo(d.slipNo)
|
||||
setCustomerId(d.customerId)
|
||||
setWarehouseId(d.warehouseId)
|
||||
setCashierUserId(d.cashierUserId)
|
||||
setLine({
|
||||
...blankLine(d.warehouseId),
|
||||
itemId: first?.itemId ?? 0,
|
||||
warehouseId: first?.warehouseId ?? d.warehouseId,
|
||||
qty: first?.qty ?? 1,
|
||||
freeQty: first?.freeQty ?? 0,
|
||||
unitPrice: first?.unitPrice ?? null,
|
||||
discountMode: first?.discountMode ?? "Percentage",
|
||||
discountPct: first?.discountPct ?? 0,
|
||||
discountAmount: first?.discountAmount ?? 0,
|
||||
taxPct: first?.taxPct ?? 0,
|
||||
isFreeIssue: first?.isFreeIssue ?? false,
|
||||
parentLineId: first?.parentLineId ?? null,
|
||||
})
|
||||
} else {
|
||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||
// No item pre-selected — the user searches for one.
|
||||
setLine(blankLine(whRes.items[0]?.warehouseId ?? 0))
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [editing, salesSlipId])
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
|
||||
[items, serialNumbersEnabled]
|
||||
)
|
||||
|
||||
function updateLine(patch: Partial<Line>) {
|
||||
setLine((prev) => ({ ...prev, ...patch }))
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
|
||||
if (!line.itemId) return setError("Select an item.")
|
||||
if (!(Number(line.qty) > 0)) return setError("Buy qty must be more than 0.")
|
||||
if (!(Number(line.freeQty) > 0)) return setError("Free qty must be more than 0.")
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload: CreateSalesSlipRequest = {
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: [
|
||||
{
|
||||
itemId: Number(line.itemId),
|
||||
// The line follows the header warehouse — there's no per-line warehouse on this screen.
|
||||
warehouseId,
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null || line.unitPrice === undefined ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
discountValue: Number(line.discountValue),
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId ?? null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const latest = await salesApi.getFreeIssue(salesSlipId)
|
||||
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
|
||||
await salesApi.updateFreeIssue(salesSlipId, payload, latest.etag)
|
||||
toast.success("Free issue updated", slipNo ?? undefined)
|
||||
} else {
|
||||
const created = await salesApi.createFreeIssue(payload)
|
||||
toast.success("Free issue created", created.data.slipNo)
|
||||
}
|
||||
router.push(LIST_HREF)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error(editing ? "Could not update free issue" : "Could not create free issue", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLabel = itemOptions.find((o) => o.value === String(line.itemId))?.label
|
||||
const uom = baseUomLabel(items, uoms, line.itemId)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 sm:gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={LIST_HREF} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to free issues">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">{editing ? `Edit Free Issue${slipNo ? ` ${slipNo}` : ""}` : "New Free Issue"}</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">Pick a customer, warehouse and cashier, then the item and its buy / free quantities.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<section className={sectionClass}>
|
||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fi-customer" className="text-base" required>Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||
<SelectTrigger id="fi-customer" className="h-12! w-full text-base"><SelectValue placeholder="Select customer" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fi-warehouse" className="text-base" required>Warehouse</Label>
|
||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
|
||||
<SelectTrigger id="fi-warehouse" className="h-12! w-full text-base"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fi-cashier" className="text-base" required>Cashier</Label>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
||||
<SelectTrigger id="fi-cashier" className="h-12! w-full text-base"><SelectValue placeholder="Select cashier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.userId} value={String(u.userId)} className="text-base">{u.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item</h2>
|
||||
<p className="text-sm text-muted-foreground">Buy this many, get this many free.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base" required>Item</Label>
|
||||
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
|
||||
{line.itemId ? (
|
||||
<div className="flex h-12 items-center justify-between gap-2 rounded-lg border border-input px-3 text-base">
|
||||
<span className="truncate font-medium">{selectedLabel ?? `Item #${line.itemId}`}</span>
|
||||
<button type="button" onClick={() => updateLine({ itemId: 0 })} className="shrink-0 text-sm font-medium text-primary hover:underline">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => updateLine({ itemId: Number(v) })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
aria-label="Item"
|
||||
/>
|
||||
)}
|
||||
{uom && <p className="text-sm text-muted-foreground">Unit: {uom}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fi-qty" className="text-base" required>Buy qty</Label>
|
||||
<Input id="fi-qty" type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine({ qty: Number(e.target.value) })} className="h-12! font-mono text-base tabular-nums" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fi-free" className="text-base" required>Free qty</Label>
|
||||
<Input id="fi-free" type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine({ freeQty: Number(e.target.value) })} className="h-12! font-mono text-base tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
{line.itemId > 0 && Number(line.qty) > 0 && Number(line.freeQty) > 0 && (
|
||||
<p className="rounded-lg border border-dashed px-4 py-3 text-sm">
|
||||
Promotion: <span className="font-semibold">Buy {line.qty} Get {line.freeQty} free</span>
|
||||
{selectedLabel && <span className="text-muted-foreground"> — {selectedLabel}</span>}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{error}</div>}
|
||||
|
||||
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
|
||||
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Link href={LIST_HREF} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto sm:min-w-40">
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : editing ? "Update free issue" : "Create free issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user