Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5423cd7138 | |||
| ff1eeecf58 | |||
| 5ffcd237e3 | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec |
@@ -33,10 +33,11 @@ public sealed class BundleSalesController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
@@ -37,6 +38,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ISalesPostingService posting,
|
||||
ICurrentUser currentUser,
|
||||
INumberSequenceService numbers,
|
||||
@@ -50,6 +52,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_posting = posting;
|
||||
_currentUser = currentUser;
|
||||
_numbers = numbers;
|
||||
@@ -85,7 +88,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, 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);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -93,6 +96,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
var total = await q.CountAsync(ct);
|
||||
@@ -204,21 +208,23 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
if (r.Qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
||||
if (r.WarehouseId != warehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
||||
|
||||
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
|
||||
// Keep any per-line warehouse input from drifting away from the header.
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = resolved.UnitPrice,
|
||||
Qty = qtyBase,
|
||||
UomId = item.BaseUomId,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = unitCostBase,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
@@ -8,7 +9,7 @@ public interface IBundleSaleService
|
||||
{
|
||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
@@ -30,6 +31,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -119,15 +122,14 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
issues.Add(new BundleSalePostingIssueDto(
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
@@ -139,10 +141,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesInvoice),
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
getDocId: x => x.SalesInvoiceId,
|
||||
ct: ct);
|
||||
|
||||
@@ -152,10 +154,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesSlip),
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
getDocId: x => x.SalesSlipId,
|
||||
ct: ct);
|
||||
|
||||
@@ -165,10 +167,12 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
||||
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||
// stored base quantity directly instead of converting again.
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(BundleSale),
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
getDocId: x => x.BundleSaleId,
|
||||
ct: ct);
|
||||
|
||||
@@ -210,5 +214,5 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||
{
|
||||
private const decimal FreeIssueThreshold = 10m;
|
||||
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
@@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
|
||||
if (slip is null) return null;
|
||||
|
||||
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var freeIssueLines = slip.Lines
|
||||
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
|
||||
.ToList();
|
||||
|
||||
if (freeIssueLines.Count == 0)
|
||||
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
|
||||
|
||||
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var candidateItems = await _items.Query().AsNoTracking()
|
||||
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
@@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||
|
||||
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
||||
foreach (var line in freeIssueLines)
|
||||
{
|
||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||
|
||||
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
||||
if (freeQty <= 0m) continue;
|
||||
|
||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||
{
|
||||
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||
@@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.Qty,
|
||||
freeQty,
|
||||
FreeIssueThreshold,
|
||||
line.FreeQty,
|
||||
line.Qty,
|
||||
rewardOptions));
|
||||
}
|
||||
|
||||
@@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=187.127.102.190;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<>
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving…" : "Approve"}
|
||||
{submitting ? "Approving�" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -15,7 +15,9 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatUomName } from "@/lib/format-uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { validateBundleSale } from "@/lib/sales-validation"
|
||||
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -26,6 +28,18 @@ import { ManagedUser } from "@/types/users"
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
uomId: templateLine?.uomId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
function statusClass(status: BundleSale["status"]) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
@@ -94,7 +108,7 @@ export default function BundleSaleDetailPage() {
|
||||
key: `${line.bundleSaleLineId}`,
|
||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -104,7 +118,7 @@ export default function BundleSaleDetailPage() {
|
||||
)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [bundleSaleId, params.id])
|
||||
}, [bundleSaleId, params.id, items])
|
||||
|
||||
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
@@ -116,9 +130,9 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1]
|
||||
const source = template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
||||
setLines((prev) => [...prev, createBlankLine(source)])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -126,7 +140,20 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
|
||||
async function saveBundle() {
|
||||
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
|
||||
if (!bundle) return
|
||||
const validationError = validateBundleSale({
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
templateId,
|
||||
bundleName,
|
||||
bundlePrice,
|
||||
lines,
|
||||
})
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -170,6 +197,20 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const shortageIssues = bundle && bundle.status === "Draft"
|
||||
? bundle.lines
|
||||
.filter((line) => line.includeInBundle)
|
||||
.filter((line) => Number(line.qty || 0) > 0)
|
||||
.map((line) => ({
|
||||
bundleSaleLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
|
||||
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
|
||||
warehouseId: line.warehouseId,
|
||||
requestedQty: line.qty,
|
||||
}))
|
||||
: []
|
||||
|
||||
async function cancelBundle() {
|
||||
if (!bundle) return
|
||||
setBusy(true)
|
||||
@@ -236,6 +277,47 @@ export default function BundleSaleDetailPage() {
|
||||
|
||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||
|
||||
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Item</th>
|
||||
<th className="py-1 pr-3">Warehouse</th>
|
||||
<th className="py-1 pr-3 text-right">Requested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortageIssues.map((issue) => (
|
||||
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{issue.itemSku}</div>
|
||||
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -304,7 +386,7 @@ export default function BundleSaleDetailPage() {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||
}} disabled={!editing || !isDraft}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
@@ -319,14 +401,14 @@ export default function BundleSaleDetailPage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft}>
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
{formatUomName(uom.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -334,15 +416,78 @@ export default function BundleSaleDetailPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{editing && isDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"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>
|
||||
) : (
|
||||
line.includeInBundle ? "Included" : "Excluded"
|
||||
)}
|
||||
</TableCell>
|
||||
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add line to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Item</th>
|
||||
<th className="py-1 pr-3">Warehouse</th>
|
||||
<th className="py-1 pr-3 text-right">Requested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortageIssues.map((issue) => (
|
||||
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{issue.itemSku}</div>
|
||||
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -18,8 +18,10 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatUomName } from "@/lib/format-uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { validateBundleSale } from "@/lib/sales-validation"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
@@ -27,7 +29,17 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary,
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
uomId: templateLine?.uomId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
function NewBundleSaleContent() {
|
||||
const router = useRouter()
|
||||
@@ -44,7 +56,7 @@ function NewBundleSaleContent() {
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
|
||||
const [bundleName, setBundleName] = useState("Demo Bundle")
|
||||
const [bundleName, setBundleName] = useState("")
|
||||
const [bundlePrice, setBundlePrice] = useState<number>(0)
|
||||
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
|
||||
const [lines, setLines] = useState<EditableLine[]>([])
|
||||
@@ -68,10 +80,10 @@ function NewBundleSaleContent() {
|
||||
setWarehouses(whRes.items)
|
||||
setUsers(userRes.items)
|
||||
setTemplates(templateRes.items)
|
||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setCashierUserId(null)
|
||||
setTemplateId((current) => current ?? null)
|
||||
})
|
||||
.catch((err) => setSubmitError(errorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -81,10 +93,20 @@ function NewBundleSaleContent() {
|
||||
if (!templateId) return
|
||||
bundleApi.getTemplate(templateId).then((res) => {
|
||||
setTemplate(res)
|
||||
setLines(res.lines.map(blankLine))
|
||||
setLines(
|
||||
res.lines.length > 0
|
||||
? res.lines.map((line) => {
|
||||
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
||||
return createBlankLine({
|
||||
...line,
|
||||
uomId: item?.baseUomId ?? line.uomId,
|
||||
})
|
||||
})
|
||||
: [createBlankLine()]
|
||||
)
|
||||
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||
}, [templateId])
|
||||
}, [items, templateId])
|
||||
|
||||
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
@@ -94,9 +116,7 @@ function NewBundleSaleContent() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1] ?? template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, blankLine(source)])
|
||||
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -104,12 +124,21 @@ function NewBundleSaleContent() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
|
||||
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
|
||||
const validationError = validateBundleSale({
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
templateId,
|
||||
bundleName,
|
||||
bundlePrice,
|
||||
lines,
|
||||
})
|
||||
if (validationError) {
|
||||
setSubmitError(validationError)
|
||||
return
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
setSubmitError("Add at least one bundle component line.")
|
||||
if (!template) {
|
||||
setSubmitError("Load a bundle template before saving.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
@@ -151,32 +180,48 @@ function NewBundleSaleContent() {
|
||||
|
||||
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Need stock before saving this bundle?</div>
|
||||
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
|
||||
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Warehouse</Label>
|
||||
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
|
||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
|
||||
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
|
||||
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Cashier</Label>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
||||
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
|
||||
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Template</Label>
|
||||
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
|
||||
<Select value={templateId ? String(templateId) : ""} onValueChange={(v) => setTemplateId(v ? Number(v) : null)}>
|
||||
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
|
||||
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
@@ -222,13 +267,14 @@ function NewBundleSaleContent() {
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="min-w-72">
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? line.uomId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
@@ -243,14 +289,14 @@ function NewBundleSaleContent() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })}>
|
||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: v ? Number(v) : 0 })} disabled>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
{formatUomName(uom.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -258,12 +304,30 @@ function NewBundleSaleContent() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"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>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add component to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, FileText, Plus, Printer, Search } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -10,6 +10,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -23,7 +24,6 @@ import { cn } from "@/lib/utils"
|
||||
type StatusFilter = BundleSaleStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||
|
||||
function statusClass(status: BundleSaleStatus) {
|
||||
switch (status) {
|
||||
@@ -46,7 +46,6 @@ export default function BundleSalesPage() {
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
|
||||
@@ -84,7 +83,18 @@ export default function BundleSalesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, status, query, customerId, warehouseId])
|
||||
|
||||
const visibleRows = useMemo(() => rows ?? [], [rows])
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery =
|
||||
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
}) ?? [],
|
||||
[rows, query, status]
|
||||
)
|
||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
||||
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
||||
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
||||
@@ -108,83 +118,74 @@ export default function BundleSalesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setStatus(t)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="relative sm:col-span-2 xl:col-span-2">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search bundle, code, or customer"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search bundles"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full text-base">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
|
||||
<SelectTrigger className="h-14! w-full text-base">
|
||||
<SelectValue placeholder="All customers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-base">All customers</SelectItem>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
|
||||
<SelectTrigger className="h-14! w-full text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-base">All warehouses</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||
{w.code} - {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setStatus("All")
|
||||
setSearchInput("")
|
||||
setQuery("")
|
||||
}}
|
||||
>
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">Customer</div>
|
||||
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="All customers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All customers</SelectItem>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
|
||||
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All warehouses</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setStatus("All")
|
||||
setSearchInput("")
|
||||
setQuery("")
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
@@ -205,6 +206,7 @@ export default function BundleSalesPage() {
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<CardContent className="px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
@@ -250,6 +252,7 @@ export default function BundleSalesPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
@@ -286,7 +289,7 @@ export default function BundleSalesPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2, X } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatUomName } from "@/lib/format-uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { validateSalesDocument } from "@/lib/sales-validation"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
|
||||
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
|
||||
itemName: item?.name ?? firstLine?.description ?? "—",
|
||||
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
|
||||
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
|
||||
uomName: uom?.name ? formatUomName(uom.name) : `UOM ${firstLine?.uomId ?? 0}`,
|
||||
qty: firstLine?.qty ?? 0,
|
||||
freeQty: firstLine?.freeQty ?? 0,
|
||||
} satisfies FreeIssueRow
|
||||
@@ -111,17 +114,10 @@ export default function NewFreeIssuePage() {
|
||||
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)
|
||||
setLines([
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||
},
|
||||
])
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setCashierUserId(null)
|
||||
setLines([blankLine("line-1")])
|
||||
await refreshRows()
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
@@ -132,12 +128,18 @@ export default function NewFreeIssuePage() {
|
||||
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
function updateHeaderWarehouse(nextWarehouseId: number | null) {
|
||||
setWarehouseId(nextWarehouseId)
|
||||
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||
setEditingLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||
}
|
||||
|
||||
function updateEditingLine(key: string, patch: Partial<Line>) {
|
||||
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -146,20 +148,25 @@ export default function NewFreeIssuePage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0, warehouseId: warehouseId ?? 0 })
|
||||
}
|
||||
|
||||
function selectEditingItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0, warehouseId: warehouseId ?? 0 })
|
||||
}
|
||||
|
||||
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.uomId)) return setError("Select a valid UOM for every line.")
|
||||
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
|
||||
const validationError = validateSalesDocument({
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
requireCashierUser: true,
|
||||
lines: activeLines,
|
||||
lineLabel: "free issue line",
|
||||
})
|
||||
if (validationError) return setError(validationError)
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
@@ -198,6 +205,11 @@ export default function NewFreeIssuePage() {
|
||||
toast.success("Free issue created", created.data.slipNo)
|
||||
}
|
||||
|
||||
if (!editingRowId) {
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setCashierUserId(null)
|
||||
}
|
||||
setLines([blankLine("line-1")])
|
||||
await refreshRows()
|
||||
} catch (err) {
|
||||
@@ -281,6 +293,57 @@ export default function NewFreeIssuePage() {
|
||||
|
||||
{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)]">
|
||||
<h2 className="text-sm font-semibold">{editingRowId ? "Free issue header" : "Create free issue header"}</h2>
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Customer</Label>
|
||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Warehouse</Label>
|
||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cashier</Label>
|
||||
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.userId} value={String(u.userId)}>
|
||||
{u.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editingRowId ? (
|
||||
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3">
|
||||
@@ -327,7 +390,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
{formatUomName(u.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -392,7 +455,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
{formatUomName(u.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -11,8 +11,10 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { findUomLabel, formatUomName } from "@/lib/format-uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { validateSalesDocument } from "@/lib/sales-validation"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||
@@ -157,6 +159,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||
}
|
||||
@@ -166,9 +173,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!customerId || !warehouseId || !etag) return
|
||||
if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) {
|
||||
setError("Select item, UOM and warehouse for every line.")
|
||||
if (!etag) return
|
||||
const validationError = validateSalesDocument({
|
||||
customerId,
|
||||
warehouseId,
|
||||
lines,
|
||||
lineLabel: "invoice line",
|
||||
})
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,14 +194,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
customerId,
|
||||
warehouseId,
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
@@ -348,7 +361,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
|
||||
<td className="px-4 py-3">{findUomLabel(uoms, line.uomId)}</td>
|
||||
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
|
||||
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
|
||||
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
|
||||
@@ -378,8 +391,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="border-t pt-5">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
@@ -432,6 +456,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving..." : "Save invoice"}
|
||||
</button>
|
||||
{postingCheck && !postingCheck.canPost ? (
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Resolve stock in GRN
|
||||
</Link>
|
||||
) : null}
|
||||
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<Send className="size-4" />
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||
@@ -480,7 +513,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<option value="">UOM</option>
|
||||
{uoms.map((u) => (
|
||||
<option key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
{formatUomName(u.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -496,15 +529,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-32">
|
||||
<input
|
||||
type="number"
|
||||
@@ -514,6 +547,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-24 text-right">
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { findUomLabel } from "@/lib/format-uom"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</TableCell>
|
||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell>{findUomLabel(uoms, line.uomId)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatUomName } from "@/lib/format-uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { validateSalesDocument } from "@/lib/sales-validation"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -85,18 +87,9 @@ export default function NewSalesInvoicePage() {
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
|
||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||
setWarehouseId(defaultWarehouseId)
|
||||
setLines([
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||
warehouseId: defaultWarehouseId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
||||
},
|
||||
])
|
||||
setCustomerId(null)
|
||||
setWarehouseId(null)
|
||||
setLines([blankLine("line-1")])
|
||||
setActiveFocSchemes(
|
||||
freeIssueRes.items.flatMap((issue) => {
|
||||
if (!issue.itemId) return []
|
||||
@@ -132,6 +125,7 @@ export default function NewSalesInvoicePage() {
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
warehouseId: warehouseId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
@@ -178,10 +172,13 @@ export default function NewSalesInvoicePage() {
|
||||
const payableTotal = netTotal + taxTotal
|
||||
|
||||
async function submit() {
|
||||
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
||||
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
||||
if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.")
|
||||
const validationError = validateSalesDocument({
|
||||
customerId,
|
||||
warehouseId,
|
||||
lines,
|
||||
lineLabel: "invoice line",
|
||||
})
|
||||
if (validationError) return setSubmitError(validationError)
|
||||
|
||||
const payload: CreateSalesInvoiceRequest = {
|
||||
customerId,
|
||||
@@ -365,7 +362,7 @@ export default function NewSalesInvoicePage() {
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
{formatUomName(u.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -400,6 +397,11 @@ export default function NewSalesInvoicePage() {
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, FileText, Plus, Printer, Search } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
@@ -18,7 +20,6 @@ import { cn } from "@/lib/utils"
|
||||
type StatusFilter = SalesInvoiceStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||
|
||||
function statusClass(status: SalesInvoiceStatus) {
|
||||
switch (status) {
|
||||
@@ -95,36 +96,32 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setStatus(t)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search customer or invoice number"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search invoices"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by customer or invoice number"
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
@@ -145,6 +142,7 @@ export default function SalesInvoicesPage() {
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<CardContent className="px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
@@ -195,6 +193,7 @@ export default function SalesInvoicesPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
@@ -233,7 +232,7 @@ export default function SalesInvoicesPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link"
|
||||
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
import { FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const sections = [
|
||||
@@ -34,36 +35,38 @@ const sections = [
|
||||
export default function SalesHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
|
||||
<ReceiptText className="size-4" />
|
||||
Sales
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Invoices, slips, and free issues in one place.
|
||||
</p>
|
||||
<p className="text-base text-muted-foreground">Invoices, slips, and free issues in one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<Link
|
||||
key={section.href}
|
||||
href={section.href}
|
||||
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
className="block focus-visible:outline-none"
|
||||
>
|
||||
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
|
||||
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>
|
||||
Open
|
||||
</div>
|
||||
<Card className="h-full transition-all duration-200 hover:-translate-y-0.5 hover:ring-primary/30 hover:shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-semibold">{section.title}</CardTitle>
|
||||
<CardDescription className="text-base">{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "px-0 text-primary")}>Open</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -155,6 +155,11 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
@@ -172,14 +177,14 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
@@ -335,10 +340,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{line.description}</div>
|
||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
||||
</TableRow>
|
||||
@@ -350,8 +360,19 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
|
||||
@@ -111,6 +111,11 @@ export default function NewSalesSlipPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||
}
|
||||
@@ -162,7 +167,7 @@ export default function NewSalesSlipPage() {
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
@@ -366,6 +371,11 @@ export default function NewSalesSlipPage() {
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Eye, Package2, Plus, Printer, Search } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -18,7 +20,6 @@ import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales"
|
||||
type StatusFilter = SalesSlipStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||
|
||||
function statusClass(status: SalesSlipStatus) {
|
||||
switch (status) {
|
||||
@@ -95,36 +96,32 @@ export default function SalesSlipsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setStatus(t)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search customer or slip number"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search slips"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Posted" className="text-base">Posted</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by customer or slip number"
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
@@ -145,6 +142,7 @@ export default function SalesSlipsPage() {
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<CardContent className="px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
@@ -191,6 +189,7 @@ export default function SalesSlipsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
@@ -229,7 +228,7 @@ export default function SalesSlipsPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
||||
@@ -165,9 +165,12 @@ export function Header() {
|
||||
const [notifications, setNotifications] = useState(initialNotifications)
|
||||
const unreadCount = notifications.filter((n) => n.unread).length
|
||||
|
||||
// Read after mount, not during render: localStorage doesn't exist on the server, and
|
||||
// reading it while rendering would desync the hydration pass.
|
||||
const [user] = useState<AuthUser | null>(() => getStoredUser())
|
||||
// Read after mount so the first client render matches the server render.
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setUser(getStoredUser())
|
||||
}, [])
|
||||
|
||||
const markAllAsRead = () =>
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||
@@ -278,16 +281,18 @@ export function Header() {
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||
{initials(displayName(user))}
|
||||
{user ? initials(displayName(user)) : "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
||||
{displayName(user)}
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||
<div className="px-2 py-2.5">
|
||||
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</p>
|
||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Lightbulb, PackageCheck } from "lucide-react"
|
||||
@@ -11,7 +11,7 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
if (!suggestion || suggestion.lines.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||
No free-issue promotion suggestions were generated for this slip yet.
|
||||
No free-issue suggestions were generated for this slip yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,10 +22,10 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||
<Lightbulb className="size-3.5" />
|
||||
Backend suggestion
|
||||
Suggestion only
|
||||
</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
|
||||
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
|
||||
<p className="text-sm text-muted-foreground">These are suggestions only. You can review them before creating a free issue.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
Create free issue
|
||||
@@ -40,14 +40,12 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
<div className="font-medium text-foreground">{line.itemName}</div>
|
||||
<div className="text-sm text-muted-foreground">{line.itemSku} • Qty {line.qty}</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
Buy {line.triggerQty} get {line.suggestedFreeQty} free
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Free issue</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-sm">
|
||||
{line.rewardOptions.map((option, index) => (
|
||||
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
|
||||
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary")}>
|
||||
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
|
||||
{option.name}
|
||||
</span>
|
||||
@@ -55,11 +53,11 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||
Free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const UOM_LABELS: Record<string, string> = {
|
||||
BAG: "Bag",
|
||||
BOX: "Box",
|
||||
BTL: "Bottle",
|
||||
CAN: "Can",
|
||||
CM: "Centimeter",
|
||||
CTN: "Carton",
|
||||
DOZ: "Dozen",
|
||||
EA: "Each",
|
||||
G: "Gram",
|
||||
KG: "Kilogram",
|
||||
L: "Litre",
|
||||
M: "Meter",
|
||||
ML: "Millilitre",
|
||||
MM: "Millimeter",
|
||||
PACK: "Pack",
|
||||
PCS: "Pieces",
|
||||
PK: "Pack",
|
||||
PKT: "Packet",
|
||||
ROLL: "Roll",
|
||||
SET: "Set",
|
||||
}
|
||||
|
||||
export function formatUomName(name: string | null | undefined): string {
|
||||
const normalized = name?.trim()
|
||||
if (!normalized) return ""
|
||||
|
||||
const mapped = UOM_LABELS[normalized.toUpperCase()]
|
||||
if (mapped) return mapped
|
||||
|
||||
if (/^[A-Z0-9/_-]+$/.test(normalized)) {
|
||||
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function findUomLabel(uoms: Array<{ uomId: number; name: string }>, uomId: number): string {
|
||||
const name = uoms.find((uom) => uom.uomId === uomId)?.name
|
||||
return name ? formatUomName(name) : `#${uomId}`
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
type SalesEditableLine = {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
freeQty: number
|
||||
unitPrice?: number | null
|
||||
discountPct?: number
|
||||
taxPct?: number
|
||||
}
|
||||
|
||||
type SalesDocumentValidationInput = {
|
||||
customerId: number | null
|
||||
warehouseId: number | null
|
||||
cashierUserId?: number | null
|
||||
requireCashierUser?: boolean
|
||||
lines: SalesEditableLine[]
|
||||
lineLabel?: string
|
||||
}
|
||||
|
||||
type BundleEditableLine = {
|
||||
itemId: number
|
||||
uomId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
||||
type BundleValidationInput = {
|
||||
customerId: number | null
|
||||
warehouseId: number | null
|
||||
cashierUserId: number | null
|
||||
templateId: number | null
|
||||
bundleName: string
|
||||
bundlePrice: number
|
||||
lines: BundleEditableLine[]
|
||||
}
|
||||
|
||||
function invalidNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined || !Number.isFinite(value)
|
||||
}
|
||||
|
||||
export function validateSalesDocument(input: SalesDocumentValidationInput): string | null {
|
||||
const {
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
requireCashierUser = false,
|
||||
lines,
|
||||
lineLabel = "line",
|
||||
} = input
|
||||
|
||||
if (!customerId) return "Select a customer."
|
||||
if (!warehouseId) return "Select a warehouse."
|
||||
if (requireCashierUser && !cashierUserId) return "Select a cashier."
|
||||
if (lines.length === 0) return `Add at least one ${lineLabel}.`
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const row = index + 1
|
||||
if (!line.itemId) return `Select an item for ${lineLabel} ${row}.`
|
||||
if (!line.uomId) return `Select a valid UOM for ${lineLabel} ${row}.`
|
||||
if (!line.warehouseId) return `Select a warehouse for ${lineLabel} ${row}.`
|
||||
if (warehouseId && line.warehouseId !== warehouseId) {
|
||||
return `${lineLabel[0]?.toUpperCase() ?? "L"}${lineLabel.slice(1)} ${row} warehouse must match the selected header warehouse.`
|
||||
}
|
||||
if (invalidNumber(line.qty) || line.qty <= 0) return `Enter a quantity greater than zero for ${lineLabel} ${row}.`
|
||||
if (invalidNumber(line.freeQty) || line.freeQty < 0) return `Enter a valid free quantity for ${lineLabel} ${row}.`
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined && (invalidNumber(line.unitPrice) || line.unitPrice < 0)) {
|
||||
return `Enter a valid unit price for ${lineLabel} ${row}.`
|
||||
}
|
||||
if (line.discountPct !== undefined && (invalidNumber(line.discountPct) || line.discountPct < 0 || line.discountPct > 100)) {
|
||||
return `Enter a discount percentage between 0 and 100 for ${lineLabel} ${row}.`
|
||||
}
|
||||
if (line.taxPct !== undefined && (invalidNumber(line.taxPct) || line.taxPct < 0 || line.taxPct > 100)) {
|
||||
return `Enter a tax percentage between 0 and 100 for ${lineLabel} ${row}.`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateBundleSale(input: BundleValidationInput): string | null {
|
||||
const { customerId, warehouseId, cashierUserId, templateId, bundleName, bundlePrice, lines } = input
|
||||
|
||||
if (!customerId) return "Select a customer."
|
||||
if (!warehouseId) return "Select a warehouse."
|
||||
if (!cashierUserId) return "Select a cashier."
|
||||
if (!templateId) return "Select a bundle template."
|
||||
if (!bundleName.trim()) return "Enter a bundle name."
|
||||
if (invalidNumber(bundlePrice) || bundlePrice < 0) return "Enter a valid bundle price."
|
||||
if (lines.length === 0) return "Add at least one bundle component line."
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const row = index + 1
|
||||
if (!line.itemId) return `Select an item for component line ${row}.`
|
||||
if (!line.uomId) return `Select a valid UOM for component line ${row}.`
|
||||
if (invalidNumber(line.qty) || line.qty <= 0) return `Enter a quantity greater than zero for component line ${row}.`
|
||||
if (invalidNumber(line.unitPrice) || line.unitPrice < 0) return `Enter a valid unit price for component line ${row}.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user