feat(grn): add update functionality for draft GRNs and support document-level discounts
- Implemented Update method in GrnsController to allow editing of draft GRNs. - Enhanced CreateGrnRequest to include an optional totalDiscount property. - Updated GrnService to handle GRN updates, including validation and line item processing. - Modified NewGrnPage to support editing existing GRNs and applying document-level discounts. - Improved UI in NewItemPage and GrnDetailPage for better user experience. - Added search functionality to Select component for improved item selection.
This commit is contained in:
@@ -42,6 +42,18 @@ public sealed class GrnsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Update a Draft GRN's header/lines.</summary>
|
||||||
|
[HttpPut("{grnId:int}")]
|
||||||
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<GrnDto>> Update(int grnId, [FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _grns.UpdateAsync(grnId, request, ct);
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||||
[HttpPost("{grnId:int}/confirm")]
|
[HttpPost("{grnId:int}/confirm")]
|
||||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public sealed class CreateGrnRequest
|
|||||||
public int? VendorId { get; set; }
|
public int? VendorId { get; set; }
|
||||||
[Required] public int WarehouseId { get; set; }
|
[Required] public int WarehouseId { get; set; }
|
||||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||||
|
/// <summary>Optional document-level discount percentage (0–100). When supplied, per-line discounts are ignored.</summary>
|
||||||
|
[Range(0, 100)] public decimal? TotalDiscountPct { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReleaseLineRequest
|
public sealed class ReleaseLineRequest
|
||||||
|
|||||||
@@ -322,6 +322,110 @@ public sealed class GrnService : IGrnService
|
|||||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var grn = await _grns.Query()
|
||||||
|
.Include(g => g.Lines)
|
||||||
|
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||||
|
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||||
|
|
||||||
|
if (grn.Status != GrnStatus.Draft)
|
||||||
|
throw new ConflictException($"GRN {grnId} is {grn.Status} and can no longer be edited.");
|
||||||
|
|
||||||
|
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||||
|
|
||||||
|
PurchaseOrder? po = null;
|
||||||
|
int vendorId;
|
||||||
|
if (request.PoId is not null)
|
||||||
|
{
|
||||||
|
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||||
|
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||||
|
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||||
|
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||||
|
vendorId = po.VendorId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (request.VendorId is null)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||||
|
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||||
|
vendorId = request.VendorId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<GrnLine>();
|
||||||
|
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||||
|
|
||||||
|
foreach (var input in request.Lines)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||||
|
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||||
|
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||||
|
|
||||||
|
decimal unitCost;
|
||||||
|
decimal? poUnitPrice = null;
|
||||||
|
if (input.PoLineId is not null)
|
||||||
|
{
|
||||||
|
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
|
||||||
|
if (poLine.ItemId != input.ItemId)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||||
|
|
||||||
|
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||||
|
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||||
|
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||||
|
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||||
|
|
||||||
|
poUnitPrice = poLine.UnitPrice;
|
||||||
|
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unitCost = input.UnitCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
|
||||||
|
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
|
||||||
|
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||||
|
|
||||||
|
lines.Add(new GrnLine
|
||||||
|
{
|
||||||
|
PoLineId = input.PoLineId,
|
||||||
|
ItemId = input.ItemId,
|
||||||
|
UomId = input.UomId,
|
||||||
|
BinId = input.BinId,
|
||||||
|
Batch = batch,
|
||||||
|
Qty = input.Qty,
|
||||||
|
UnitCost = unitCost,
|
||||||
|
PoUnitPrice = poUnitPrice,
|
||||||
|
DiscountPct = input.DiscountPct,
|
||||||
|
NetUnitCost = netUnitCost,
|
||||||
|
VatPct = input.VatPct,
|
||||||
|
VatAmount = vatAmount,
|
||||||
|
ReceivedValue = receivedValue,
|
||||||
|
LineTotal = receivedValue + vatAmount,
|
||||||
|
HoldStatus = input.HoldStatus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
grn.PoId = request.PoId;
|
||||||
|
grn.VendorId = vendorId;
|
||||||
|
grn.WarehouseId = request.WarehouseId;
|
||||||
|
grn.Lines.Clear();
|
||||||
|
foreach (var line in lines) grn.Lines.Add(line);
|
||||||
|
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(grn);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Batch?> ResolveBatchAsync(
|
private async Task<Batch?> ResolveBatchAsync(
|
||||||
Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
|
Item item, BatchInput? batch, Dictionary<(int, string), Batch> cache, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Common.Http;
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ public interface IBundleSaleService
|
|||||||
{
|
{
|
||||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, 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<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public interface IGrnService
|
|||||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Update a Draft GRN's header/lines; rejects if the GRN is no longer Draft.</summary>
|
||||||
|
Task<GrnDto> UpdateAsync(int grnId, CreateGrnRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
||||||
Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default);
|
Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus, X } from "lucide-react"
|
import { ExternalLink, Plus, X } from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
@@ -44,6 +44,11 @@ function isBuilderItemType(name: string): boolean {
|
|||||||
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a master-data management page in a new tab so the in-progress form isn't lost. */
|
||||||
|
function openInNewTab(path: string) {
|
||||||
|
window.open(path, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewItemPage() {
|
export default function NewItemPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -280,7 +285,20 @@ export default function NewItemPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-base">Category</Label>
|
<Label className="text-base">Category</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full"
|
||||||
|
onClick={() => openInNewTab("/dashboard/products/categories")}
|
||||||
|
aria-label="Add category"
|
||||||
|
title="Add category"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onValueChange={handleCategoryChange}
|
onValueChange={handleCategoryChange}
|
||||||
@@ -303,7 +321,22 @@ export default function NewItemPage() {
|
|||||||
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
|
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
|
||||||
{config?.subcategoriesEnabled && (
|
{config?.subcategoriesEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-base">Subcategory (optional)</Label>
|
<Label className="text-base">Subcategory (optional)</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full"
|
||||||
|
onClick={() =>
|
||||||
|
openInNewTab(categoryId ? `/dashboard/products/categories/${categoryId}` : "/dashboard/products/categories")
|
||||||
|
}
|
||||||
|
aria-label="Add subcategory"
|
||||||
|
title="Add subcategory"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={subCategoryId}
|
value={subCategoryId}
|
||||||
onValueChange={setSubCategoryId}
|
onValueChange={setSubCategoryId}
|
||||||
@@ -325,7 +358,20 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
{config?.brandsEnabled && (
|
{config?.brandsEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-base">Brand (optional)</Label>
|
<Label className="text-base">Brand (optional)</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full"
|
||||||
|
onClick={() => openInNewTab("/dashboard/products/brands")}
|
||||||
|
aria-label="Add brand"
|
||||||
|
title="Add brand"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={brandId}
|
value={brandId}
|
||||||
onValueChange={setBrandId}
|
onValueChange={setBrandId}
|
||||||
@@ -345,7 +391,20 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-base">Warehouse (optional)</Label>
|
<Label className="text-base">Warehouse (optional)</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full"
|
||||||
|
onClick={() => openInNewTab("/dashboard/warehouse")}
|
||||||
|
aria-label="Add warehouse"
|
||||||
|
title="Add warehouse"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={warehouseId}
|
value={warehouseId}
|
||||||
onValueChange={setWarehouseId}
|
onValueChange={setWarehouseId}
|
||||||
@@ -364,7 +423,20 @@ export default function NewItemPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-base">Base UOM</Label>
|
<Label className="text-base">Base UOM</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full"
|
||||||
|
onClick={() => openInNewTab("/dashboard/products/uoms")}
|
||||||
|
aria-label="Add UOM"
|
||||||
|
title="Add UOM"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={baseUomId}
|
value={baseUomId}
|
||||||
onValueChange={setBaseUomId}
|
onValueChange={setBaseUomId}
|
||||||
@@ -454,12 +526,23 @@ export default function NewItemPage() {
|
|||||||
item-type reference), so this section IS the enforcement. */}
|
item-type reference), so this section IS the enforcement. */}
|
||||||
{config?.itemTypesEnabled && (
|
{config?.itemTypesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openInNewTab("/dashboard/products/item-types")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
Manage item types
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{(itemTypes ?? [])
|
{(itemTypes ?? [])
|
||||||
|
|||||||
@@ -130,10 +130,15 @@ export default function GrnDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{grn.status === "Draft" && (
|
{grn.status === "Draft" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href={`/dashboard/receiving/grn/new?grnId=${grn.grnId}`} className={cn(buttonVariants({ size: "lg", variant: "outline" }))}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||||
<PackageCheck className="size-5" />
|
<PackageCheck className="size-5" />
|
||||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
@@ -86,6 +86,8 @@ function emptyLine(): DraftLine {
|
|||||||
|
|
||||||
export default function NewGrnPage() {
|
export default function NewGrnPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const search = useSearchParams()
|
||||||
|
const editingGrnId = Number(search?.get("grnId")) || null
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>("po")
|
const [mode, setMode] = useState<Mode>("po")
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ export default function NewGrnPage() {
|
|||||||
const [poId, setPoId] = useState<number | null>(null)
|
const [poId, setPoId] = useState<number | null>(null)
|
||||||
const [poLoading, setPoLoading] = useState(false)
|
const [poLoading, setPoLoading] = useState(false)
|
||||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||||
|
const [totalDiscount, setTotalDiscount] = useState<string>("")
|
||||||
|
|
||||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
@@ -127,6 +130,37 @@ export default function NewGrnPage() {
|
|||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// If editing an existing draft GRN, load and populate the form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingGrnId) return
|
||||||
|
grnsApi
|
||||||
|
.get(editingGrnId)
|
||||||
|
.then((g) => {
|
||||||
|
setPoId(g.poId ?? null)
|
||||||
|
setVendorId(g.vendorId ?? null)
|
||||||
|
setWarehouseId(g.warehouseId)
|
||||||
|
setLines(
|
||||||
|
g.lines.map((ln) => ({
|
||||||
|
key: newKey(),
|
||||||
|
poLineId: ln.poLineId,
|
||||||
|
itemId: ln.itemId,
|
||||||
|
uomId: ln.uomId,
|
||||||
|
binId: ln.binId,
|
||||||
|
qty: String(ln.qty),
|
||||||
|
unitCost: String(ln.unitCost),
|
||||||
|
poUnitPrice: ln.poUnitPrice,
|
||||||
|
discountPct: String(ln.discountPct),
|
||||||
|
vatPct: String(ln.vatPct),
|
||||||
|
holdStatus: ln.holdStatus,
|
||||||
|
batchNo: "",
|
||||||
|
expiryDate: "",
|
||||||
|
serialNumbersText: "",
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [editingGrnId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!warehouseId) {
|
if (!warehouseId) {
|
||||||
setBins([])
|
setBins([])
|
||||||
@@ -208,6 +242,15 @@ export default function NewGrnPage() {
|
|||||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a document-level total discount is entered, clear any per-line discounts.
|
||||||
|
function updateTotalDiscount(next: string) {
|
||||||
|
setTotalDiscount(next)
|
||||||
|
const pct = Number(next) || 0
|
||||||
|
if (pct > 0) {
|
||||||
|
setLines((prev) => prev.map((l) => ({ ...l, discountPct: "0" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeLine(key: string) {
|
function removeLine(key: string) {
|
||||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||||
}
|
}
|
||||||
@@ -272,7 +315,8 @@ export default function NewGrnPage() {
|
|||||||
binId: l.binId,
|
binId: l.binId,
|
||||||
qty: Number(l.qty),
|
qty: Number(l.qty),
|
||||||
unitCost: Number(l.unitCost),
|
unitCost: Number(l.unitCost),
|
||||||
discountPct: Number(l.discountPct) || 0,
|
// Use document-level discount if supplied, otherwise per-line discount.
|
||||||
|
discountPct: (Number(totalDiscount) || 0) > 0 ? (Number(totalDiscount) || 0) : Number(l.discountPct) || 0,
|
||||||
vatPct: Number(l.vatPct) || 0,
|
vatPct: Number(l.vatPct) || 0,
|
||||||
holdStatus: l.holdStatus,
|
holdStatus: l.holdStatus,
|
||||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||||
@@ -282,13 +326,26 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const grn = await grnsApi.create({
|
let grn
|
||||||
|
if (editingGrnId) {
|
||||||
|
grn = await grnsApi.update(editingGrnId, {
|
||||||
poId: mode === "po" ? poId : null,
|
poId: mode === "po" ? poId : null,
|
||||||
vendorId: mode === "direct" ? vendorId : null,
|
vendorId: mode === "direct" ? vendorId : null,
|
||||||
warehouseId: warehouseId as number,
|
warehouseId: warehouseId as number,
|
||||||
lines: payloadLines,
|
lines: payloadLines,
|
||||||
|
totalDiscount: Number(totalDiscount) || undefined,
|
||||||
|
})
|
||||||
|
toast.success("GRN updated", `${grn.docNo} is ready to confirm.`)
|
||||||
|
} else {
|
||||||
|
grn = await grnsApi.create({
|
||||||
|
poId: mode === "po" ? poId : null,
|
||||||
|
vendorId: mode === "direct" ? vendorId : null,
|
||||||
|
warehouseId: warehouseId as number,
|
||||||
|
lines: payloadLines,
|
||||||
|
totalDiscount: Number(totalDiscount) || undefined,
|
||||||
})
|
})
|
||||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||||
|
}
|
||||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(errorMessage(err))
|
setSubmitError(errorMessage(err))
|
||||||
@@ -473,7 +530,7 @@ export default function NewGrnPage() {
|
|||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="w-md max-w-[80vw]" align="start">
|
||||||
{(items ?? []).map((i) => (
|
{(items ?? []).map((i) => (
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||||
{i.sku} — {i.name}
|
{i.sku} — {i.name}
|
||||||
@@ -557,10 +614,11 @@ export default function NewGrnPage() {
|
|||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="any"
|
step="any"
|
||||||
value={line.discountPct}
|
value={(Number(totalDiscount) || 0) > 0 ? totalDiscount : line.discountPct}
|
||||||
aria-invalid={!!errors.discountPct}
|
aria-invalid={!!errors.discountPct}
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
|
disabled={(Number(totalDiscount) || 0) > 0}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -653,12 +711,37 @@ export default function NewGrnPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!poLoading && lines.length > 0 && (
|
{!poLoading && lines.length > 0 && (
|
||||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
<div className="flex flex-col gap-3 border-t border-border pt-4 text-base">
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
|
<label className="text-sm text-muted-foreground">Total discount %</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="any"
|
||||||
|
value={totalDiscount}
|
||||||
|
onChange={(e) => updateTotalDiscount(e.target.value)}
|
||||||
|
className="w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
{(() => {
|
||||||
|
const totalReceived = lines.reduce((s, l) => s + computeLine(l).receivedValue, 0)
|
||||||
|
const totalVat = lines.reduce((s, l) => s + computeLine(l).vatAmount, 0)
|
||||||
|
const pct = Number(totalDiscount) || 0
|
||||||
|
if (pct > 0) {
|
||||||
|
const discountedBase = totalReceived * (1 - pct / 100)
|
||||||
|
const discountedVat = lines.reduce((s, l) => s + computeLine(l).vatAmount * (1 - pct / 100), 0)
|
||||||
|
return (discountedBase + discountedVat).toFixed(2)
|
||||||
|
}
|
||||||
|
return (totalReceived + totalVat).toFixed(2)
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,7 +754,7 @@ export default function NewGrnPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||||
{submitting ? "Creating…" : "Create GRN"}
|
{submitting ? (editingGrnId ? "Saving…" : "Creating…") : editingGrnId ? "Save Changes" : "Create GRN"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -129,47 +129,45 @@
|
|||||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
--sidebar-ring: oklch(0.68 0.186 265.215);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
/* Vibrant — the "System" toggle option. Light gray background and sidebar
|
||||||
cards, header, table panels) paired with a dark sidebar — the same split
|
throughout (no light content split), with one violet accent driving every
|
||||||
Linear/Vercel/Notion use in their light themes. One violet accent drives
|
interactive state. */
|
||||||
every interactive state; the sidebar keeps its own dark token family
|
|
||||||
(applied via .sidebar-surface below) so it stays dark regardless. */
|
|
||||||
.vibrant {
|
.vibrant {
|
||||||
--background: oklch(0.97 0.004 265);
|
--background: oklch(0.82 0 0);
|
||||||
--foreground: oklch(0.2 0.02 265);
|
--foreground: oklch(0.2 0 0);
|
||||||
--card: oklch(0.995 0.002 265);
|
--card: oklch(0.99 0 0);
|
||||||
--card-foreground: oklch(0.2 0.02 265);
|
--card-foreground: oklch(0.2 0 0);
|
||||||
--popover: oklch(0.995 0.002 265);
|
--popover: oklch(0.99 0 0);
|
||||||
--popover-foreground: oklch(0.2 0.02 265);
|
--popover-foreground: oklch(0.2 0 0);
|
||||||
--primary: oklch(0.55 0.2 275);
|
--primary: oklch(0.5 0.2 275);
|
||||||
--primary-foreground: oklch(0.98 0 0);
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
--secondary: oklch(0.93 0.02 275);
|
--secondary: oklch(0.82 0.02 275);
|
||||||
--secondary-foreground: oklch(0.32 0.15 275);
|
--secondary-foreground: oklch(0.35 0.15 275);
|
||||||
--muted: oklch(0.94 0.006 265);
|
--muted: oklch(0.85 0 0);
|
||||||
--muted-foreground: oklch(0.48 0.02 265);
|
--muted-foreground: oklch(0.45 0 0);
|
||||||
--accent: oklch(0.55 0.14 210);
|
--accent: oklch(0.55 0.13 210);
|
||||||
--accent-foreground: oklch(0.98 0 0);
|
--accent-foreground: oklch(0.98 0 0);
|
||||||
--destructive: oklch(0.58 0.22 25);
|
--destructive: oklch(0.55 0.22 25);
|
||||||
--border: oklch(0.88 0.012 265);
|
--border: oklch(0.78 0 0);
|
||||||
--input: oklch(0.92 0.01 265);
|
--input: oklch(0.8 0 0);
|
||||||
--ring: oklch(0.55 0.2 275);
|
--ring: oklch(0.5 0.2 275);
|
||||||
--success: oklch(0.55 0.15 150);
|
--success: oklch(0.5 0.15 142.495);
|
||||||
--warning: oklch(0.72 0.15 80);
|
--warning: oklch(0.65 0.15 72.031);
|
||||||
--error: oklch(0.58 0.22 25);
|
--error: oklch(0.55 0.22 25);
|
||||||
--info: oklch(0.55 0.14 210);
|
--info: oklch(0.55 0.13 210);
|
||||||
--chart-1: oklch(0.55 0.2 275);
|
--chart-1: oklch(0.5 0.2 275);
|
||||||
--chart-2: oklch(0.55 0.14 210);
|
--chart-2: oklch(0.55 0.13 210);
|
||||||
--chart-3: oklch(0.55 0.15 150);
|
--chart-3: oklch(0.5 0.15 142.495);
|
||||||
--chart-4: oklch(0.72 0.15 80);
|
--chart-4: oklch(0.65 0.15 72.031);
|
||||||
--chart-5: oklch(0.58 0.22 25);
|
--chart-5: oklch(0.55 0.22 25);
|
||||||
--sidebar: oklch(0.18 0.02 265);
|
--sidebar: oklch(0.82 0 0);
|
||||||
--sidebar-foreground: oklch(0.96 0.005 265);
|
--sidebar-foreground: oklch(0.2 0 0);
|
||||||
--sidebar-primary: oklch(0.64 0.19 275);
|
--sidebar-primary: oklch(0.5 0.19 275);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
--sidebar-accent: oklch(0.28 0.03 265);
|
--sidebar-accent: oklch(0.75 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
--sidebar-accent-foreground: oklch(0.2 0 0);
|
||||||
--sidebar-border: oklch(0.26 0.025 265);
|
--sidebar-border: oklch(0.7 0 0);
|
||||||
--sidebar-ring: oklch(0.64 0.19 275);
|
--sidebar-ring: oklch(0.5 0.19 275);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||||
@@ -186,7 +184,7 @@
|
|||||||
--popover-foreground: var(--sidebar-foreground);
|
--popover-foreground: var(--sidebar-foreground);
|
||||||
--foreground: var(--sidebar-foreground);
|
--foreground: var(--sidebar-foreground);
|
||||||
--muted: var(--sidebar-accent);
|
--muted: var(--sidebar-accent);
|
||||||
--muted-foreground: oklch(0.72 0.015 265);
|
--muted-foreground: oklch(0.4 0.015 265);
|
||||||
--primary: var(--sidebar-primary);
|
--primary: var(--sidebar-primary);
|
||||||
--primary-foreground: var(--sidebar-primary-foreground);
|
--primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--border: var(--sidebar-border);
|
--border: var(--sidebar-border);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const navItems: {
|
|||||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{
|
{
|
||||||
title: "Stock",
|
title: "Stock",
|
||||||
code: "stock",
|
code: "stock",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon, SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
|
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
|
||||||
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
|
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
|
||||||
@@ -61,6 +61,50 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flattens a node's rendered text so a SelectItem can be matched against a search query. */
|
||||||
|
function nodeToText(node: React.ReactNode): string {
|
||||||
|
if (node === null || node === undefined || typeof node === "boolean") return ""
|
||||||
|
if (typeof node === "string" || typeof node === "number") return String(node)
|
||||||
|
if (Array.isArray(node)) return node.map(nodeToText).join(" ")
|
||||||
|
if (React.isValidElement(node)) {
|
||||||
|
return nodeToText((node.props as { children?: React.ReactNode }).children)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks the popup's children, dropping any SelectItem whose text doesn't match the query. */
|
||||||
|
function filterSelectChildren(children: React.ReactNode, query: string): React.ReactNode {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return children
|
||||||
|
|
||||||
|
return React.Children.map(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) return child
|
||||||
|
if (child.type === SelectItem) {
|
||||||
|
const text = nodeToText((child.props as { children?: React.ReactNode }).children).toLowerCase()
|
||||||
|
return text.includes(q) ? child : null
|
||||||
|
}
|
||||||
|
const nested = (child.props as { children?: React.ReactNode }).children
|
||||||
|
if (nested !== undefined) {
|
||||||
|
return React.cloneElement(child, undefined, filterSelectChildren(nested, query))
|
||||||
|
}
|
||||||
|
return child
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSelectItems(node: React.ReactNode): number {
|
||||||
|
let count = 0
|
||||||
|
React.Children.forEach(node, (child) => {
|
||||||
|
if (!React.isValidElement(child)) return
|
||||||
|
if (child.type === SelectItem) {
|
||||||
|
count += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nested = (child.props as { children?: React.ReactNode }).children
|
||||||
|
if (nested !== undefined) count += countSelectItems(nested)
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
@@ -96,13 +140,27 @@ function SelectContent({
|
|||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
align = "center",
|
||||||
alignOffset = 0,
|
alignOffset = 0,
|
||||||
alignItemWithTrigger = true,
|
// Aligning the popup to the selected item lets it float above the trigger (and above the
|
||||||
|
// search box). A plain dropdown that always opens fully below the trigger is what the
|
||||||
|
// search box needs to stay pinned to the top, so this defaults to off now.
|
||||||
|
alignItemWithTrigger = false,
|
||||||
...props
|
...props
|
||||||
}: SelectPrimitive.Popup.Props &
|
}: SelectPrimitive.Popup.Props &
|
||||||
Pick<
|
Pick<
|
||||||
SelectPrimitive.Positioner.Props,
|
SelectPrimitive.Positioner.Props,
|
||||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
>) {
|
>) {
|
||||||
|
const [query, setQuery] = React.useState("")
|
||||||
|
const searchRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// The popup remounts each time it opens, so this only ever fires once per open.
|
||||||
|
React.useEffect(() => {
|
||||||
|
searchRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredChildren = React.useMemo(() => filterSelectChildren(children, query), [children, query])
|
||||||
|
const noResults = query.trim().length > 0 && countSelectItems(filteredChildren) === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Positioner
|
<SelectPrimitive.Positioner
|
||||||
@@ -119,8 +177,27 @@ function SelectContent({
|
|||||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<div data-slot="select-search" className="sticky top-0 z-10 bg-popover p-1.5 pb-1">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation()
|
||||||
|
}}
|
||||||
|
placeholder="Search…"
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-transparent pr-2 pl-7 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
<SelectPrimitive.List>{filteredChildren}</SelectPrimitive.List>
|
||||||
|
{noResults && (
|
||||||
|
<div className="px-2 py-6 text-center text-sm text-muted-foreground">No results found.</div>
|
||||||
|
)}
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Popup>
|
</SelectPrimitive.Popup>
|
||||||
</SelectPrimitive.Positioner>
|
</SelectPrimitive.Positioner>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const grnsApi = {
|
|||||||
return apiRequest<Grn>("/grns", { method: "POST", body: request })
|
return apiRequest<Grn>("/grns", { method: "POST", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
|
||||||
|
return apiRequest<Grn>(`/grns/${grnId}`, { method: "PUT", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts the receipt. Pass a stable idempotencyKey per detail-page session so a retry
|
* Posts the receipt. Pass a stable idempotencyKey per detail-page session so a retry
|
||||||
* cannot double-post stock — unlike the old mock, the server genuinely dedupes on it.
|
* cannot double-post stock — unlike the old mock, the server genuinely dedupes on it.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface CreateGrnRequest {
|
|||||||
vendorId?: number | null
|
vendorId?: number | null
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
lines: CreateGrnLineInput[]
|
lines: CreateGrnLineInput[]
|
||||||
|
/** Optional total document-level discount % (0–100). When set, per-line discounts are cleared. */
|
||||||
|
totalDiscount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrnLine {
|
export interface GrnLine {
|
||||||
|
|||||||
Reference in New Issue
Block a user