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
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
public string TemplateCode { get; set; } = string.Empty; public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty; public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>
/// What the whole bundle sells for. On save it is split across the included lines' unit prices
/// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
/// 0 on templates created before bundle prices existed — their line prices stand as entered.
/// </summary>
public decimal BundlePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
+8 -2
View File
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
decimal UnitPrice, bool IncludeInBundle, int SortOrder); decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto( public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines); EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto( public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt); EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto( public sealed record BundleSalePostingIssueDto(
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
{ {
[Required] public string TemplateName { get; set; } = string.Empty; [Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>
/// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
/// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
/// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
/// </summary>
[Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
} }
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
builder.HasIndex(x => x.TemplateCode).IsUnique(); builder.HasIndex(x => x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200); builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000); builder.Property(x => x.Description).HasMaxLength(1000);
builder.Property(x => x.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
builder.Property(x => x.Status) builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active); .HasDefaultValue(EntityStatus.Active);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBundleTemplatePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BundlePrice",
table: "bundle_sale_templates",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BundlePrice",
table: "bundle_sale_templates");
}
}
}
@@ -794,6 +794,12 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BundleSaleTemplateId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<decimal>("BundlePrice")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)")
.HasDefaultValue(0m);
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
+37 -2
View File
@@ -67,7 +67,7 @@ public sealed class BundleSaleService : IBundleSaleService
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto( return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total); x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
} }
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default) public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
@@ -78,6 +78,7 @@ public sealed class BundleSaleService : IBundleSaleService
template.TemplateCode, template.TemplateCode,
template.TemplateName, template.TemplateName,
template.Description, template.Description,
template.BundlePrice,
template.Status, template.Status,
template.CreatedAt, template.CreatedAt,
template.UpdatedAt, template.UpdatedAt,
@@ -92,10 +93,18 @@ public sealed class BundleSaleService : IBundleSaleService
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct), TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
TemplateName = request.TemplateName, TemplateName = request.TemplateName,
Description = request.Description, Description = request.Description,
BundlePrice = request.BundlePrice ?? 0m,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
if (request.BundlePrice is { } bundlePrice)
{
if (!request.Lines.Any(l => l.IncludeInBundle))
throw new DomainException(ErrorCodes.Validation, "A bundle needs at least one included line to carry its price.", 422);
AllocateBundlePrice(request.Lines, bundlePrice);
}
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct)) if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
@@ -118,12 +127,38 @@ public sealed class BundleSaleService : IBundleSaleService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return new BundleSaleTemplateDto( return new BundleSaleTemplateDto(
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundlePrice,
template.Status, template.CreatedAt, template.UpdatedAt, template.Status, template.CreatedAt, template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
} }
/// <summary>
/// Splits the bundle price over the included lines' unit prices, in proportion to each line's
/// UnitPrice × Qty as sent (the items' normal value), or to Qty when no line carries a price.
/// Line totals are rounded to 2 dp with the last included line taking the rounding remainder, so the
/// totals add up to the bundle price; excluded lines are priced 0.
/// </summary>
private static void AllocateBundlePrice(List<CreateBundleSaleTemplateLineRequest> lines, decimal bundlePrice)
{
var included = lines.Where(l => l.IncludeInBundle).ToList();
var weights = included.Select(l => l.UnitPrice * l.Qty).ToList();
if (weights.Sum() <= 0) weights = included.Select(l => l.Qty).ToList();
var totalWeight = weights.Sum();
var allocated = 0m;
for (var i = 0; i < included.Count; i++)
{
var lineTotal = i == included.Count - 1
? bundlePrice - allocated
: Math.Round(bundlePrice * weights[i] / totalWeight, 2, MidpointRounding.AwayFromZero);
allocated += lineTotal;
included[i].UnitPrice = Math.Round(lineTotal / included[i].Qty, 4, MidpointRounding.AwayFromZero);
}
foreach (var line in lines.Where(l => !l.IncludeInBundle)) line.UnitPrice = 0m;
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{ {
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines); IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
@@ -0,0 +1,285 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Combobox } from "@/components/ui/combobox"
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 { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { bundleApi } from "@/lib/api/bundles"
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 { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
let keySeq = 0
const blankLine = (warehouseId: number): Line => ({
key: `line-${++keySeq}`,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
/** 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 bundle template (Sales → Bundle Templates → New) — a full page rather than a modal, so a bundle can carry
* as many component lines as it needs. The template code is generated by the server on save.
*/
export default function NewBundleTemplatePage() {
const router = useRouter()
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
// What the whole bundle sells for — the server splits it over the components on save.
const [bundlePrice, setBundlePrice] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine(defaultWarehouseId)])
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
const itemOptions = useMemo(() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })), [items])
// What the included items would sell for on their own (their flat prices) — shown beside the bundle price
// for reference, and sent as each line's weight when the server splits the bundle price.
const normalValue = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
const bundlePriceNum = bundlePrice.trim() === "" ? null : Number(bundlePrice)
const customerSaves = bundlePriceNum !== null && normalValue > 0 ? normalValue - bundlePriceNum : null
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (bundlePriceNum === null || Number.isNaN(bundlePriceNum) || bundlePriceNum <= 0) return setError("Enter a bundle price above 0.")
if (lines.length === 0) return setError("Add at least one component line.")
if (!lines.some((line) => line.includeInBundle)) return setError("Include at least one line in the bundle.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
if (lines.some((line) => !(Number(line.qty) > 0))) return setError("Every line needs a quantity above 0.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
bundlePrice: bundlePriceNum,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
router.push("/dashboard/sales/bundles/templates")
} catch (err) {
setError(errorMessage(err))
toast.error("Could not save bundle template", errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-5 sm:gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles/templates" className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to bundle templates">
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">New Bundle Template</h1>
<p className="text-sm text-muted-foreground sm:text-base">The template code is generated automatically when you save.</p>
</div>
</div>
{loading && <Skeleton className="h-64 w-full" />}
{!loading && (
<>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Template details</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="bt-name" className="text-base" required>Template name</Label>
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" className="h-12! text-base" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bt-warehouse" className="text-base">Warehouse</Label>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger id="bt-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.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Every component is sold from this warehouse.</p>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="bt-description" className="text-base">Description (optional)</Label>
<Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" className="h-12! text-base" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bt-price" className="text-base" required>Bundle price</Label>
<Input id="bt-price" type="number" min="0" step="0.01" inputMode="decimal" value={bundlePrice} onChange={(e) => setBundlePrice(e.target.value)} placeholder="0.00" className="h-12! font-mono text-base tabular-nums" />
<p className="text-sm text-muted-foreground">
What the whole bundle sells for.
{normalValue > 0 && <> Items normally sell for <span className="font-medium text-foreground tabular-nums">{formatAmount(normalValue)}</span>{customerSaves !== null && customerSaves > 0 && <> — customer saves <span className="font-medium text-success tabular-nums">{formatAmount(customerSaves)}</span></>}.</>}
</p>
</div>
</div>
</section>
<section className={sectionClass}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Components</h2>
<p className="text-sm text-muted-foreground">The items in this bundle. Excluded lines are kept on the template but not added when it&apos;s sold.</p>
</div>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, blankLine(warehouseId ?? 0)])}>
<Plus className="size-4" />
Add line
</Button>
</div>
{/* Phones: one card per line. */}
<ul className="flex flex-col gap-3 md:hidden">
{lines.map((line, idx) => (
<li key={line.key} className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Line {idx + 1}</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove line ${idx + 1}`} title="Remove line" onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</div>
<Combobox items={itemOptions} value={line.itemId || null} onValueChange={(v) => selectItem(line.key, v ?? 0)} placeholder="Search item…" />
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Qty ({baseUomLabel(items, uoms, line.itemId) || "UOM"})</span>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="font-mono tabular-nums" aria-label="Qty" />
</div>
<label className="flex items-center justify-between text-sm">
<span>Include in bundle</span>
<Switch checked={line.includeInBundle} onCheckedChange={(v) => updateLine(line.key, { includeInBundle: v })} aria-label="Include in bundle" />
</label>
</li>
))}
</ul>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-lg border md:block">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10">#</TableHead>
<TableHead className="min-w-72">Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="w-32 text-right">Qty</TableHead>
<TableHead className="w-24 text-center">Include</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className={cn(!line.includeInBundle && "opacity-60")}>
<TableCell className="text-sm text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell>
<Combobox items={itemOptions} value={line.itemId || null} onValueChange={(v) => selectItem(line.key, v ?? 0)} placeholder="Search item…" />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId) || "—"}</TableCell>
<TableCell>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right font-mono tabular-nums" aria-label={`Qty, line ${idx + 1}`} />
</TableCell>
<TableCell className="text-center">
<Switch checked={line.includeInBundle} onCheckedChange={(v) => updateLine(line.key, { includeInBundle: v })} aria-label={`Include line ${idx + 1} in bundle`} />
</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove line ${idx + 1}`} title="Remove line" onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Bundle price</span>
<span className="font-semibold tabular-nums">{bundlePriceNum !== null && !Number.isNaN(bundlePriceNum) ? formatAmount(bundlePriceNum) : "—"}</span>
</div>
</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="/dashboard/sales/bundles/templates" 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…" : "Save bundle template"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -1,78 +1,46 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react" import { ArrowLeft, Plus, ShoppingBag } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { bundleApi } from "@/lib/api/bundles" import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items" import { formatAmount } from "@/lib/format"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { BundleSaleTemplateSummary } from "@/types/bundles"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and // Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below. // re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10 const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({
key,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
export default function BundleTemplatesPage() { export default function BundleTemplatesPage() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([]) const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
async function refreshTemplates() { async function refreshTemplates() {
try {
const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined }) const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
setTemplates(res.items) setTemplates(res.items)
setPagination(res.pagination) setPagination(res.pagination)
setError(null)
} catch (err) {
setError(errorMessage(err))
}
} }
function handleShowMore() { function handleShowMore() {
@@ -81,26 +49,11 @@ export default function BundleTemplatesPage() {
} }
useEffect(() => { useEffect(() => {
Promise.all([ refreshTemplates().finally(() => {
itemsApi.list({ pageSize: 200 }), setLoading(false)
uomsApi.list({ pageSize: 200 }), setShowMoreLoading(false)
warehousesApi.list({ pageSize: 200 }),
])
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine("line-1", defaultWarehouseId)])
await refreshTemplates()
}) })
.catch((err) => setError(errorMessage(err))) // eslint-disable-next-line react-hooks/exhaustive-deps
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false))
}, [page, pageSize, query]) }, [page, pageSize, query])
useEffect(() => { useEffect(() => {
@@ -110,73 +63,8 @@ export default function BundleTemplatesPage() {
useEffect(() => setPage(1), [query]) useEffect(() => setPage(1), [query])
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items]
)
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
function resetForm() {
setTemplateName("")
setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)])
setError(null)
}
function openCreateForm() {
resetForm()
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) resetForm()
}
const componentSubtotal = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm()
setFormOpen(false)
await refreshTemplates()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) { if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div> return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading bundle templates...</div>
} }
return ( return (
@@ -191,127 +79,11 @@ export default function BundleTemplatesPage() {
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p> <p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div> </div>
</div> </div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}> <Link href="/dashboard/sales/bundles/templates/new" className={cn(buttonVariants({ size: "lg" }))}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" /> <Plus className="size-5" />
New Bundle Template New Bundle Template
</Button> </Link>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>New bundle template</DialogTitle>
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="bt-name">Template name</FieldLabel>
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</Field>
<Field>
<FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="bt-description">Description (optional)</FieldLabel>
<Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</Field>
</FieldGroup>
{lines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor={`bt-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`bt-qty-${line.key}`}
type="number"
min="0.0001"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
<Input
id={`bt-price-${line.key}`}
type="number"
min="0"
step="0.01"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel>Include in bundle</FieldLabel>
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"w-fit 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>
</Field>
</FieldGroup>
))}
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div> </div>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : "Save bundle template"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
@@ -357,7 +129,11 @@ export default function BundleTemplatesPage() {
{t.description && <p className="text-sm text-muted-foreground">{t.description}</p>} {t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bundle price</p>
<p className="font-medium text-foreground tabular-nums">{t.bundlePrice > 0 ? formatAmount(t.bundlePrice) : "—"}</p>
</div>
<div> <div>
<p className="text-muted-foreground">Lines</p> <p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{t.lineCount}</p> <p className="font-medium text-foreground">{t.lineCount}</p>
@@ -393,6 +169,7 @@ export default function BundleTemplatesPage() {
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="w-48">Template</TableHead> <TableHead className="w-48">Template</TableHead>
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="text-right">Bundle price</TableHead>
<TableHead className="text-right">Lines</TableHead> <TableHead className="text-right">Lines</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
@@ -408,6 +185,7 @@ export default function BundleTemplatesPage() {
<div className="text-xs text-muted-foreground">{t.templateCode}</div> <div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="text-right text-sm font-medium tabular-nums">{t.bundlePrice > 0 ? formatAmount(t.bundlePrice) : "—"}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -453,7 +231,7 @@ export default function BundleTemplatesPage() {
</> </>
) : ( ) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground"> <div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No bundle templates yet — create one above. No bundle templates yet — use New Bundle Template to create one.
</div> </div>
)} )}
</section> </section>
@@ -0,0 +1,10 @@
"use client"
import { useParams } from "next/navigation"
import { FreeIssueForm } from "@/components/sales/FreeIssueForm"
export default function EditFreeIssuePage() {
const { id } = useParams<{ id: string }>()
return <FreeIssueForm salesSlipId={Number(id)} />
}
@@ -1,580 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { FreeIssueForm } from "@/components/sales/FreeIssueForm"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { 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 { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateSalesSlipLineRequest & { key: string }
type FreeIssueRow = {
salesSlipId: number
slipNo: string
status: string
etag: string
warehouseName: string
itemName: string
itemSku: string
uomName: string
qty: number
freeQty: number
}
const blankLine = (key: string): Line => ({
key,
itemId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
export default function NewFreeIssuePage() { export default function NewFreeIssuePage() {
const [customers, setCustomers] = useState<Customer[]>([]) return <FreeIssueForm />
const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
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 [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [editingRowId, setEditingRowId] = useState<number | null>(null)
const [editingLines, setEditingLines] = useState<Line[]>([])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows() {
const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = items.find((x) => x.itemId === firstLine?.itemId)
const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
slipNo: detail.data.slipNo,
status: detail.data.status,
etag: detail.etag ?? "",
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: baseUomLabel(items, uoms, firstLine?.itemId),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
}),
)
setRows(details.filter((row) => row.status !== "Cancelled"))
}
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(async ([cust, itemRes, uomRes, whRes, userRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
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.
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
}, [page, pageSize])
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function updateEditingLine(key: string, patch: Partial<Line>) {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
[items, serialNumbersEnabled]
)
function selectItem(key: string, itemId: number) {
updateLine(key, { itemId })
}
function selectEditingItem(key: string, itemId: number) {
updateEditingLine(key, { itemId })
}
async function submit() {
const activeLines = editingRowId ? editingLines : lines
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
setSaving(true)
setError(null)
try {
const payload: CreateSalesSlipRequest = {
customerId,
warehouseId,
cashierUserId,
lines: activeLines.map((line) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? 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 (editingRowId) {
const latest = await salesApi.getFreeIssue(editingRowId)
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
toast.success("Free issue updated")
setEditingRowId(null)
setEditingLines([])
} else {
const created = await salesApi.createFreeIssue(payload)
toast.success("Free issue created", created.data.slipNo)
}
setLines([blankLine("line-1")])
setFormOpen(false)
await refreshRows()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
async function startEdit(row: FreeIssueRow) {
try {
const detail = await salesApi.getFreeIssue(row.salesSlipId)
const detailLine = detail.data.lines[0]
setEditingRowId(row.salesSlipId)
setCustomerId(detail.data.customerId)
setWarehouseId(detail.data.warehouseId)
setCashierUserId(detail.data.cashierUserId)
setEditingLines([
{
key: "edit-line-1",
itemId: detailLine?.itemId ?? 0,
warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId,
qty: detailLine?.qty ?? 1,
freeQty: detailLine?.freeQty ?? 0,
unitPrice: detailLine?.unitPrice ?? null,
allowManualPriceOverride: true,
discountMode: detailLine?.discountMode ?? "Percentage",
discountPct: detailLine?.discountPct ?? 0,
discountAmount: detailLine?.discountAmount ?? 0,
discountValue: 0,
taxPct: detailLine?.taxPct ?? 0,
isFreeIssue: detailLine?.isFreeIssue ?? false,
parentLineId: detailLine?.parentLineId ?? null,
},
])
setFormOpen(true)
} catch (err) {
setError(errorMessage(err))
}
}
function cancelEdit() {
setEditingRowId(null)
setEditingLines([])
}
function openCreateForm() {
cancelEdit()
setError(null)
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) cancelEdit()
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) {
cancelEdit()
setFormOpen(false)
}
await refreshRows()
} catch (err) {
setError(errorMessage(err))
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
const activeLines = editingRowId ? editingLines : lines
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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">Free Issues</h1>
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div>
</div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" />
New Free Issue
</Button>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>{editingRowId ? "Edit free issue" : "New free issue"}</DialogTitle>
<DialogDescription>Pick a customer, warehouse and cashier, then add the item lines.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="fi-customer">Customer</FieldLabel>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger id="fi-customer" className="w-full"><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-warehouse">Warehouse</FieldLabel>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger id="fi-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-cashier">Cashier</FieldLabel>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger id="fi-cashier" className="w-full"><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
{activeLines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
{line.itemId ? (
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
<span className="truncate font-medium">
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
</span>
<button
type="button"
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
Change
</button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
placeholder="Search item by SKU or name…"
emptyText="No items match."
aria-label="Item"
/>
)}
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor={`fi-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`fi-qty-${line.key}`}
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { qty: Number(e.target.value) }) : updateLine(line.key, { qty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`fi-free-${line.key}`}>Free</FieldLabel>
<Input
id={`fi-free-${line.key}`}
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { freeQty: Number(e.target.value) }) : updateLine(line.key, { freeQty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
</FieldGroup>
))}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
Refresh
</Button>
</div>
{rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section>
</div>
)
} }
@@ -1,3 +1,285 @@
"use client" "use client"
export { default } from "./new/page" import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
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 { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type FreeIssueRow = {
salesSlipId: number
slipNo: string
status: string
etag: string
warehouseName: string
itemName: string
itemSku: string
uomName: string
qty: number
freeQty: number
}
/** Free Issues list (Sales → Free Issues). New and Edit open full pages (components/sales/FreeIssueForm). */
export default function FreeIssuesPage() {
const router = useRouter()
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows(masters?: { items: ItemListItem[]; uoms: Uom[]; warehouses: Warehouse[] }) {
const m = masters ?? { items, uoms, warehouses }
const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = m.items.find((x) => x.itemId === firstLine?.itemId)
const warehouse = m.warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
slipNo: detail.data.slipNo,
status: detail.data.status,
etag: detail.etag ?? "",
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: baseUomLabel(m.items, m.uoms, firstLine?.itemId),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
}),
)
setRows(details.filter((row) => row.status !== "Cancelled"))
}
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
await refreshRows({ items: itemRes.items, uoms: uomRes.items, warehouses: whRes.items })
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize])
function startEdit(row: FreeIssueRow) {
router.push(`/dashboard/sales/free-issues/${row.salesSlipId}/edit`)
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
await refreshRows()
} catch (err) {
setError(errorMessage(err))
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading free issues...</div>
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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">Free Issues</h1>
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div>
</div>
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Free Issue
</Link>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={() => refreshRows()}>
Refresh
</Button>
</div>
{rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section>
</div>
)
}
@@ -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>
)
}
+6
View File
@@ -17,6 +17,8 @@ export interface BundleSaleTemplateSummary {
templateCode: string templateCode: string
templateName: string templateName: string
description: string | null description: string | null
/** The whole bundle's selling price; 0 on templates saved before bundle prices existed. */
bundlePrice: number
status: EntityStatus status: EntityStatus
lineCount: number lineCount: number
createdAt: string createdAt: string
@@ -28,6 +30,8 @@ export interface BundleSaleTemplate {
templateCode: string templateCode: string
templateName: string templateName: string
description: string | null description: string | null
/** The whole bundle's selling price; 0 on templates saved before bundle prices existed. */
bundlePrice: number
status: EntityStatus status: EntityStatus
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
@@ -46,6 +50,8 @@ export interface CreateBundleSaleTemplateLineRequest {
export interface CreateBundleSaleTemplateRequest { export interface CreateBundleSaleTemplateRequest {
templateName: string templateName: string
description?: string | null description?: string | null
/** When set, the server splits it over the included lines (weighted by each line's unitPrice × qty). */
bundlePrice?: number | null
lines: CreateBundleSaleTemplateLineRequest[] lines: CreateBundleSaleTemplateLineRequest[]
} }