sales fixers
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+11572
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)");
|
||||||
|
|
||||||
|
|||||||
@@ -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'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's ready to sell from Create Sale.</p>
|
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user