sales fixers

This commit is contained in:
2026-10-21 10:45:19 +05:30
parent 47479fb4f2
commit a27bdaea64
14 changed files with 12580 additions and 832 deletions
@@ -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>
)
}