Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ec3b4c65 | |||
| 3d3dbbbb59 | |||
| cea433e320 | |||
| 9fd290e256 | |||
| 20ecfdc0b2 | |||
| ce55d647b5 |
@@ -23,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false,
|
||||
[FromQuery] int? branchId = null)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/grn-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnConfigService _config;
|
||||
|
||||
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
|
||||
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,70 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||
[HttpGet("stock-summary")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -31,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
@@ -94,6 +155,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||
/// </summary>
|
||||
[HttpPost("payments/bulk")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
|
||||
/// Controls which warehouse goods are received into.
|
||||
/// </summary>
|
||||
public class GrnConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
|
||||
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
|
||||
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
|
||||
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
|
||||
/// (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
|
||||
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
|
||||
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowVat { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
|
||||
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
|
||||
/// only reachable through the authenticated items API — never a static path. Only uploadable
|
||||
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
|
||||
/// after the flag is switched off (reads are never gated).
|
||||
/// </summary>
|
||||
public class ItemImage
|
||||
{
|
||||
public int ItemImageId { get; set; }
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Display order; the lowest is the item's main image.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
}
|
||||
@@ -31,6 +31,26 @@ public class ProductConfig
|
||||
public bool BatchListsEnabled { get; set; } = true;
|
||||
public bool WarrantyPeriodsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Lets the item create screen add a new item variant (item type) on the spot, instead
|
||||
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
|
||||
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
|
||||
/// </summary>
|
||||
public bool CustomVariantsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
|
||||
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
|
||||
/// </summary>
|
||||
public bool ItemImagesEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
|
||||
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
|
||||
/// themselves are managed under Products, not here.
|
||||
/// </summary>
|
||||
public bool CustomWarrantyPeriodsEnabled { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
|
||||
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
|
||||
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
|
||||
/// form defaults to it; when off, a real customer must be picked and the cash customer is
|
||||
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
|
||||
/// </summary>
|
||||
public class SalesConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
public bool AllowCashCustomer { get; set; }
|
||||
|
||||
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
|
||||
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
|
||||
/// off and on again reuses the same customer (and its sales history).</summary>
|
||||
public int? CashCustomerId { get; set; }
|
||||
public Customer? CashCustomer { get; set; }
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
|
||||
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowFreeItems { get; set; } = true;
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
|
||||
/// column); when off, the column is hidden and a discount above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
|
||||
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
|
||||
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
|
||||
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
|
||||
public bool AllowOverStockSale { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
|
||||
/// and every sales invoice (header and lines) is forced onto it server-side (see
|
||||
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
|
||||
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public class User
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const int SystemUserId = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Username prefix of a permanently deleted login whose row is kept only because history
|
||||
/// (audit log, documents) still references it. Such rows are hidden from user management.
|
||||
/// </summary>
|
||||
public const string DeletedUsernamePrefix = "deleted-user-";
|
||||
|
||||
public int UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — a coverage length offered to items alongside the fixed
|
||||
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
|
||||
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
|
||||
/// <para>
|
||||
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
|
||||
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
|
||||
/// deactivating a period only stops it being offered — items already using it keep it.
|
||||
/// The length is immutable (no PUT); a different length is a different row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WarrantyPeriod
|
||||
{
|
||||
public int WarrantyPeriodId { get; set; }
|
||||
public int Months { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>GRN configuration resource.</summary>
|
||||
public sealed record GrnConfigDto(
|
||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, bool AllowDiscounts, bool AllowVat,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||
/// (same reasoning as <see cref="UpdateSalesConfigRequest"/>).
|
||||
/// </summary>
|
||||
public sealed class UpdateGrnConfigRequest
|
||||
{
|
||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
[Required] public bool? AllowDiscounts { get; set; }
|
||||
[Required] public bool? AllowVat { get; set; }
|
||||
}
|
||||
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
|
||||
public sealed record ProductConfigDto(
|
||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||
bool CustomWarrantyPeriodsEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
|
||||
[Required] public bool? SerialNumbersEnabled { get; set; }
|
||||
[Required] public bool? BatchListsEnabled { get; set; }
|
||||
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
|
||||
[Required] public bool? CustomVariantsEnabled { get; set; }
|
||||
[Required] public bool? ItemImagesEnabled { get; set; }
|
||||
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
|
||||
/// the generic cash customer (null until the option has been switched on at least once).</summary>
|
||||
public sealed record SalesConfigDto(
|
||||
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
|
||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
|
||||
/// </summary>
|
||||
public sealed class UpdateSalesConfigRequest
|
||||
{
|
||||
[Required] public bool? AllowCashCustomer { get; set; }
|
||||
[Required] public bool? AllowFreeItems { get; set; }
|
||||
[Required] public bool? AllowDiscounts { get; set; }
|
||||
[Required] public bool? AllowOverStockSale { get; set; }
|
||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
}
|
||||
@@ -133,5 +133,6 @@ public sealed class ReorderSettingInput
|
||||
|
||||
public sealed class UpdateReorderRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
|
||||
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>An item photo's metadata. The bytes are served by
|
||||
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
|
||||
public sealed record ItemImageDto(
|
||||
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
|
||||
int SortOrder, DateTime UploadedAt);
|
||||
@@ -16,3 +16,12 @@ public sealed record ItemOverviewDto(
|
||||
decimal StockQty, decimal StockValue, bool IsLowStock,
|
||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
||||
decimal SalesQtyLast30Days);
|
||||
|
||||
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
|
||||
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
|
||||
public enum ItemStockFilter
|
||||
{
|
||||
InStock,
|
||||
OutOfStock,
|
||||
LowStock
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>A new custom length, always in months (the client converts years).</summary>
|
||||
public sealed class CreateWarrantyPeriodRequest
|
||||
{
|
||||
[Required] public int? Months { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateWarrantyPeriodStatusRequest
|
||||
{
|
||||
[Required] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
|
||||
public sealed record SalesInvoiceSummaryDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
@@ -34,6 +34,28 @@ public sealed class CreateSalesInvoicePaymentRequest
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
|
||||
public sealed class BulkSalesInvoicePaymentLine
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
|
||||
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
|
||||
/// </summary>
|
||||
public sealed class CreateBulkSalesInvoicePaymentRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
|
||||
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
@@ -44,6 +66,18 @@ public sealed record SalesInvoicePostingCheckDto(
|
||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
|
||||
|
||||
/// <summary>The unit price a new line would get if left blank — same rule as on save
|
||||
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
|
||||
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
|
||||
|
||||
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
|
||||
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
|
||||
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
|
||||
|
||||
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
|
||||
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
|
||||
public sealed record SalesItemStockDto(int ItemId, decimal Available);
|
||||
|
||||
public sealed class CreateSalesInvoiceLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton GRN-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
|
||||
/// </summary>
|
||||
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnConfig> builder)
|
||||
{
|
||||
builder.ToTable("grn_config", t =>
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||
// instead of being swallowed by the database default.
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class ItemImageConfiguration : IEntityTypeConfiguration<ItemImage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ItemImage> builder)
|
||||
{
|
||||
builder.ToTable("item_images");
|
||||
builder.HasKey(i => i.ItemImageId);
|
||||
|
||||
builder.Property(i => i.OriginalFileName).IsRequired().HasMaxLength(260);
|
||||
builder.Property(i => i.StoredFileName).IsRequired().HasMaxLength(260);
|
||||
builder.Property(i => i.RelativePath).IsRequired().HasMaxLength(500);
|
||||
builder.Property(i => i.ContentType).IsRequired().HasMaxLength(200);
|
||||
builder.Property(i => i.UploadedAt).IsRequired();
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany()
|
||||
.HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(i => new { i.ItemId, i.SortOrder });
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
|
||||
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
|
||||
|
||||
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton sales-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
|
||||
/// </summary>
|
||||
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesConfig> builder)
|
||||
{
|
||||
builder.ToTable("sales_config", t =>
|
||||
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.AllowCashCustomer).IsRequired().HasDefaultValue(false);
|
||||
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||
// instead of being swallowed by the database default.
|
||||
builder.Property(c => c.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.CashCustomer)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CashCustomerId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
|
||||
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
|
||||
{
|
||||
builder.ToTable("warranty_periods", t =>
|
||||
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
|
||||
builder.HasKey(p => p.WarrantyPeriodId);
|
||||
|
||||
builder.Property(p => p.Months).IsRequired();
|
||||
builder.HasIndex(p => p.Months).IsUnique();
|
||||
|
||||
builder.Property(p => p.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
builder.HasIndex(p => p.Status);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public static class DataSeeder
|
||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||
dirty |= await SeedSalesStockAsync(db, ct);
|
||||
@@ -106,7 +108,10 @@ public static class DataSeeder
|
||||
SellingPricesEnabled = true,
|
||||
SerialNumbersEnabled = true,
|
||||
BatchListsEnabled = true,
|
||||
WarrantyPeriodsEnabled = true
|
||||
WarrantyPeriodsEnabled = true,
|
||||
CustomVariantsEnabled = false,
|
||||
ItemImagesEnabled = false,
|
||||
CustomWarrantyPeriodsEnabled = false
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -128,6 +133,28 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
|
||||
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
|
||||
/// </summary>
|
||||
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
|
||||
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
|
||||
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
|
||||
public DbSet<Item> Items => Set<Item>();
|
||||
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
|
||||
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
|
||||
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
|
||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||
public DbSet<Bin> Bins => Set<Bin>();
|
||||
@@ -43,6 +45,8 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
|
||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllowCashCustomer = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
CashCustomerId = table.Column<int>(type: "integer", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_config_customers_CashCustomerId",
|
||||
column: x => x.CashCustomerId,
|
||||
principalTable: "customers",
|
||||
principalColumn: "CustomerId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_CashCustomerId",
|
||||
table: "sales_config",
|
||||
column: "CashCustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_UpdatedBy",
|
||||
table: "sales_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8804
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowFreeItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowOverStockSale : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowOverStockSale",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowOverStockSale",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8826
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigWarehouseSelection : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId",
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8831
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductConfigCustomVariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CustomVariantsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomVariantsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_images",
|
||||
columns: table => new
|
||||
{
|
||||
ItemImageId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
|
||||
table.ForeignKey(
|
||||
name: "FK_item_images_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_images_ItemId_SortOrder",
|
||||
table: "item_images",
|
||||
columns: new[] { "ItemId", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "item_images");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomWarrantyPeriods : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "warranty_periods",
|
||||
columns: table => new
|
||||
{
|
||||
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Months = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
|
||||
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Months",
|
||||
table: "warranty_periods",
|
||||
column: "Months",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Status",
|
||||
table: "warranty_periods",
|
||||
column: "Status");
|
||||
|
||||
// Carry over any periods already saved on the config (hand-added — EF would have
|
||||
// dropped the column first and lost them). Out-of-range values are skipped rather
|
||||
// than failing the check constraint.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
|
||||
SELECT DISTINCT m, 'Active', now()
|
||||
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
|
||||
WHERE m BETWEEN 1 AND 600
|
||||
ON CONFLICT ("Months") DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
|
||||
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
|
||||
'{}'::integer[]);
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "warranty_periods");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8944
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowDiscounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllowWarehouseSelection = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
DefaultWarehouseId = table.Column<int>(type: "integer", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_warehouses_DefaultWarehouseId",
|
||||
column: x => x.DefaultWarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_DefaultWarehouseId",
|
||||
table: "grn_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_UpdatedBy",
|
||||
table: "grn_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfigDiscountsVat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowVat",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowVat",
|
||||
table: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowVat")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("grn_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Property<int>("GrnLineId")
|
||||
@@ -1831,6 +1878,56 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
|
||||
{
|
||||
b.Property<int>("ItemImageId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("UploadedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ItemImageId");
|
||||
|
||||
b.HasIndex("ItemId", "SortOrder");
|
||||
|
||||
b.ToTable("item_images", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||
{
|
||||
b.Property<int>("ReorderId")
|
||||
@@ -3252,6 +3349,21 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("CustomVariantsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("CustomWarrantyPeriodsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("ItemImagesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("ItemTypesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -4337,6 +4449,68 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_salary_components", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowCashCustomer")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowFreeItems")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowOverStockSale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("CashCustomerId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("CashCustomerId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("sales_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Property<int>("SalesDayEndId")
|
||||
@@ -6593,6 +6767,43 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("warehouses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
|
||||
{
|
||||
b.Property<int>("WarrantyPeriodId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Months")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("WarrantyPeriodId");
|
||||
|
||||
b.HasIndex("Months")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("warranty_periods", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
|
||||
{
|
||||
b.Property<int>("WorkShiftId")
|
||||
@@ -7047,6 +7258,23 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
@@ -7145,6 +7373,17 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("TaxRate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -7835,6 +8074,30 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashCustomerId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CashCustomer");
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
|
||||
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
// Master-data services (docs/11 §2)
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IItemService, ItemService>();
|
||||
builder.Services.AddScoped<IItemImageService, ItemImageService>();
|
||||
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
|
||||
builder.Services.AddScoped<IUomService, UomService>();
|
||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||
@@ -95,6 +97,8 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
|
||||
@@ -16,19 +16,41 @@ public sealed class CustomerService : ICustomerService
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
|
||||
public CustomerService(
|
||||
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_salesConfig = salesConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _customers.Query().AsNoTracking();
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||
if (!includeCashCustomer)
|
||||
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
|
||||
// under. The cash customer is shared by every branch, so it is never filtered out here.
|
||||
if (branchId is not null)
|
||||
q = q.Where(c => c.BranchId == branchId
|
||||
|| c.CustomerCode == CashCustomerCode
|
||||
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>GRN configuration service over the singleton row seeded by DataSeeder.</summary>
|
||||
public sealed class GrnConfigService : IGrnConfigService
|
||||
{
|
||||
private readonly IRepository<GrnConfig> _config;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public GrnConfigService(
|
||||
IRepository<GrnConfig> config, IRepository<Warehouse> warehouses, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_warehouses = warehouses;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||
|
||||
return new ETagged<GrnConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(GrnConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default receiving warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowVat = request.AllowVat!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (config is null) return null;
|
||||
// A saved default receiving warehouse always wins — GRNs use only that warehouse.
|
||||
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||
if (config.AllowWarehouseSelection) return null;
|
||||
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in GRN Configuration but no default receiving warehouse is set.", 422);
|
||||
}
|
||||
|
||||
public async Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||
.Select(c => new { c.AllowDiscounts, c.AllowVat })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return config is null ? (true, true) : (config.AllowDiscounts, config.AllowVat);
|
||||
}
|
||||
|
||||
private static GrnConfigDto Map(GrnConfig c) => new(
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.AllowDiscounts, c.AllowVat, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public sealed class GrnService : IGrnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IGrnConfigService _grnConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly INotificationService _notifications;
|
||||
@@ -52,7 +53,7 @@ public sealed class GrnService : IGrnService
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
@@ -70,6 +71,7 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_grnConfig = grnConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_notifications = notifications;
|
||||
@@ -118,6 +120,10 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// GRN Configuration default receiving warehouse: whatever the client sent, the GRN uses it.
|
||||
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
|
||||
request.WarehouseId = forcedWarehouseId;
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
@@ -144,6 +150,12 @@ public sealed class GrnService : IGrnService
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
|
||||
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
|
||||
if (!allowVat && request.Lines.Any(l => l.VatPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "VAT is turned off in GRN Configuration.", 422);
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
@@ -187,7 +199,8 @@ public sealed class GrnService : IGrnService
|
||||
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
|
||||
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
|
||||
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
||||
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
|
||||
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
||||
: 0m);
|
||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Every save a user makes writes an AuditLog row against their UserId, so almost any
|
||||
// real user is still referenced by history and can't be hard-deleted. The login is
|
||||
// still permanently removed: the row is kept only as a tombstone so historical
|
||||
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
|
||||
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
|
||||
// Update() flips the entity back from Deleted, or the next save would retry the delete.
|
||||
_users.Update(user);
|
||||
user.Username = User.DeletedUsernamePrefix + user.UserId;
|
||||
user.Email = null;
|
||||
user.RoleId = null;
|
||||
user.Status = EntityStatus.Inactive;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
|
||||
}
|
||||
|
||||
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
||||
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
@@ -24,10 +24,16 @@ public interface IFifoCostingService
|
||||
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
|
||||
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
|
||||
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
|
||||
/// and ledger costing). Must run inside a UoW transaction.
|
||||
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
|
||||
/// earlier over-stock sale are settled against issuable stock first.
|
||||
/// </summary>
|
||||
/// <param name="allowShortfall">
|
||||
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
|
||||
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
|
||||
/// </param>
|
||||
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false);
|
||||
|
||||
/// <summary>
|
||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>GRN configuration business logic. Singleton.</summary>
|
||||
public interface IGrnConfigService
|
||||
{
|
||||
Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>GRN Configuration default receiving warehouse: when one is saved, every GRN must
|
||||
/// use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||
Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Whether GRN lines may carry a discount and VAT (both default to allowed).</summary>
|
||||
Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
|
||||
public interface IItemImageService
|
||||
{
|
||||
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
|
||||
|
||||
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
|
||||
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public interface IItemService
|
||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
@@ -9,5 +9,4 @@ public interface IProductConfigService
|
||||
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Sales configuration business logic. Singleton.</summary>
|
||||
public interface ISalesConfigService
|
||||
{
|
||||
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
|
||||
Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -12,6 +12,19 @@ public interface ISalesDomainService
|
||||
bool requireCashierUser,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
|
||||
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
|
||||
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
|
||||
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
|
||||
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
|
||||
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
|
||||
@@ -6,5 +6,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface ISalesInvoicePaymentService
|
||||
{
|
||||
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
/// <summary>One customer, several invoices, one GL journal entry; returns a payment per invoice/account split.</summary>
|
||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -12,6 +12,9 @@ public interface IStockService
|
||||
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
|
||||
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Custom warranty period master. Never hard-deleted (FR-MD-08).</summary>
|
||||
public interface IWarrantyPeriodService
|
||||
{
|
||||
Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>1–600 months, not a default length. Re-adding an inactive length reactivates it;
|
||||
/// an active duplicate is a 409.</summary>
|
||||
Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default);
|
||||
|
||||
Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
|
||||
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
|
||||
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
|
||||
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
|
||||
/// images added earlier remain manageable.
|
||||
/// </summary>
|
||||
public sealed class ItemImageService : IItemImageService
|
||||
{
|
||||
public const int MaxImagesPerItem = 10;
|
||||
public const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".jpg"] = "image/jpeg",
|
||||
[".jpeg"] = "image/jpeg",
|
||||
[".png"] = "image/png",
|
||||
[".webp"] = "image/webp"
|
||||
};
|
||||
|
||||
private readonly IRepository<ItemImage> _images;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IFileStorageService _storage;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemImageService(
|
||||
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
|
||||
IFileStorageService storage, IUnitOfWork uow)
|
||||
{
|
||||
_images = images;
|
||||
_items = items;
|
||||
_config = config;
|
||||
_storage = storage;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
return await _images.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
|
||||
throw new DomainException(ErrorCodes.ConfigDisabled,
|
||||
"Item images are disabled in the product configuration.", 422);
|
||||
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|
||||
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
|
||||
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
|
||||
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
|
||||
|
||||
if (content.Length > MaxSizeBytes)
|
||||
throw new DomainException(ErrorCodes.FileTooLarge,
|
||||
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
|
||||
|
||||
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
if (existing.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
|
||||
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
|
||||
|
||||
var image = new ItemImage
|
||||
{
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _images.AddAsync(image, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
|
||||
image.SizeBytes, image.SortOrder, image.UploadedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
_images.Remove(image);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
// File removed only after the row is gone, so a failed save never orphans a row.
|
||||
await _storage.DeleteAsync(image.RelativePath, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
@@ -127,6 +128,24 @@ public sealed class ItemService : IItemService
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
|
||||
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
|
||||
var layers = _stockLayers.Query();
|
||||
switch (stock)
|
||||
{
|
||||
case ItemStockFilter.InStock:
|
||||
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
|
||||
break;
|
||||
case ItemStockFilter.OutOfStock:
|
||||
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
|
||||
break;
|
||||
case ItemStockFilter.LowStock:
|
||||
q = q.Where(i => i.ReorderSettings.Any(rs =>
|
||||
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
|
||||
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
|
||||
break;
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
||||
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
||||
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
|
||||
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
|
||||
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
|
||||
/// just the shared presets, and an item may also carry a length of its own entered on the
|
||||
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
|
||||
/// is also accepted, so switching the feature off never blocks editing an item that already
|
||||
/// uses a custom length. A non-warranty item carries none — any value sent for one is
|
||||
/// silently dropped rather than trusted from the client.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
private static int? ValidateWarrantyPeriod(
|
||||
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
var valid = warrantyPeriodMonths is not null
|
||||
&& (warrantyPeriodMonths == currentMonths
|
||||
|| (config.CustomWarrantyPeriodsEnabled
|
||||
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
|
||||
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
|
||||
|
||||
if (!valid)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
config.CustomWarrantyPeriodsEnabled
|
||||
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
|
||||
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
|
||||
422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
// Custom variants live in the item-variants section - switching product variants
|
||||
// off switches this off too, so it can never be left on by itself.
|
||||
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
|
||||
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||
c.CustomVariantsEnabled, c.ItemImagesEnabled,
|
||||
c.CustomWarrantyPeriodsEnabled,
|
||||
c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
|
||||
/// generic "Cash Customer" record — created the first time cash sales are allowed.
|
||||
/// </summary>
|
||||
public sealed class SalesConfigService : ISalesConfigService
|
||||
{
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
private const string CashCustomerName = "Cash Customer";
|
||||
|
||||
private readonly IRepository<SalesConfig> _config;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesConfigService(
|
||||
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
|
||||
// later throw would otherwise leave the row half-updated.
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
|
||||
if (config.AllowCashCustomer && config.CashCustomerId is null)
|
||||
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
|
||||
config.AllowFreeItems = request.AllowFreeItems!.Value;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
|
||||
// creating a new one on the deployment's default branch.
|
||||
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
|
||||
{
|
||||
var existingId = await _customers.Query().AsNoTracking()
|
||||
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
|
||||
if (existingId != 0) return existingId;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CustomerCode = CashCustomerCode,
|
||||
CustomerType = CustomerType.WalkIn,
|
||||
Name = CashCustomerName,
|
||||
DisplayName = CashCustomerName,
|
||||
CreditLimit = 0m,
|
||||
CreditDays = 0,
|
||||
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customers.AddAsync(customer, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return customer.CustomerId;
|
||||
}
|
||||
|
||||
private static SalesConfigDto Map(SalesConfig c) => new(
|
||||
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
|
||||
public SalesDomainService(
|
||||
IRepository<Customer> customers,
|
||||
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<TaxRate> taxRates,
|
||||
ISalesPricingService pricing)
|
||||
ISalesPricingService pricing,
|
||||
IRepository<SalesConfig> salesConfig)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
_items = items;
|
||||
_taxRates = taxRates;
|
||||
_pricing = pricing;
|
||||
_salesConfig = salesConfig;
|
||||
}
|
||||
|
||||
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
// The generic cash customer is only usable while Sales Configuration allows it.
|
||||
var cashConfig = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (requireCashierUser)
|
||||
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
}
|
||||
}
|
||||
|
||||
// A missing config row means the option predates it — free items stay allowed.
|
||||
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowFreeItems)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
// A missing config row means the option predates it — discounts stay allowed.
|
||||
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowDiscounts)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (config is null) return null;
|
||||
// A saved default sales warehouse always wins — sales use only that warehouse.
|
||||
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||
if (config.AllowWarehouseSelection) return null;
|
||||
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
|
||||
}
|
||||
|
||||
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.AllowOverStockSale)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesInvoicePayment> _payments;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
|
||||
public SalesInvoicePaymentService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_salesConfig = salesConfig;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = invoice.InvoiceNo,
|
||||
Narration = $"Payment against invoice {invoice.InvoiceNo}",
|
||||
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ids = request.SalesInvoiceIds.Distinct().ToList();
|
||||
if (ids.Count == 0 || request.Lines.Count == 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
|
||||
|
||||
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
|
||||
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
|
||||
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
|
||||
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
|
||||
|
||||
// The generic cash customer's invoices are paid one at a time, never grouped.
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
|
||||
|
||||
foreach (var inv in invoices)
|
||||
{
|
||||
if (inv.Status != SalesInvoiceStatus.Posted)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
|
||||
if (inv.BalanceAmount <= 0)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
|
||||
}
|
||||
|
||||
var total = request.Lines.Sum(l => l.Amount);
|
||||
var totalBalance = invoices.Sum(i => i.BalanceAmount);
|
||||
if (total > totalBalance)
|
||||
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
|
||||
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var lines = request.Lines.Select(l => (
|
||||
Line: l,
|
||||
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
|
||||
.ToList();
|
||||
|
||||
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
||||
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
||||
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
|
||||
var invIdx = 0;
|
||||
var invRemaining = ordered[0].BalanceAmount;
|
||||
foreach (var (line, account) in lines)
|
||||
{
|
||||
var lineRemaining = line.Amount;
|
||||
while (lineRemaining > 0)
|
||||
{
|
||||
var take = Math.Min(lineRemaining, invRemaining);
|
||||
allocations.Add((ordered[invIdx], account, take));
|
||||
lineRemaining -= take;
|
||||
invRemaining -= take;
|
||||
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
||||
invRemaining = ordered[++invIdx].BalanceAmount;
|
||||
}
|
||||
}
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
|
||||
var narration = $"Payment against invoices {paidNos}";
|
||||
if (narration.Length > 250) narration = narration[..247] + "...";
|
||||
|
||||
var payments = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var glLines = lines
|
||||
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
||||
.ToList();
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = request.Reference ?? ordered[0].InvoiceNo,
|
||||
Narration = narration,
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
var entities = new List<SalesInvoicePayment>();
|
||||
foreach (var (invoice, account, amount) in allocations)
|
||||
{
|
||||
var entity = new SalesInvoicePayment
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = amount,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
invoice.PaidAmount += amount;
|
||||
invoice.BalanceAmount -= amount;
|
||||
entities.Add(entity);
|
||||
}
|
||||
return entities;
|
||||
}, ct);
|
||||
|
||||
return payments.Select(Map).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
||||
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
|
||||
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
|
||||
/// that day's start.
|
||||
/// </summary>
|
||||
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
var day = requested ?? today;
|
||||
if (day > today.AddDays(1))
|
||||
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
// Invoices have no CashierUserId — Day End matches them to a cashier by CreatedBy.
|
||||
if (createdBy is not null) q = q.Where(x => x.CreatedBy == createdBy);
|
||||
// paid=true: nothing left to pay; paid=false: a balance is still due.
|
||||
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
|
||||
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
@@ -86,6 +91,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
@@ -122,6 +128,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
{
|
||||
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
|
||||
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
invoice.CustomerId = request.CustomerId;
|
||||
invoice.WarehouseId = request.WarehouseId;
|
||||
@@ -157,9 +164,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
|
||||
// and every line use the configured default sales warehouse.
|
||||
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
|
||||
{
|
||||
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
|
||||
foreach (var line in lines) line.WarehouseId = forced;
|
||||
return forced;
|
||||
}
|
||||
|
||||
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesInvoiceLine>();
|
||||
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
|
||||
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
|
||||
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
@@ -209,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
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);
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
||||
throw new ConflictException(ensureDraftMessage(doc));
|
||||
|
||||
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
|
||||
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
|
||||
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var totalCogs = 0m;
|
||||
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
// Line quantities are already a count of the item's base UOM — sales documents
|
||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
totalCogs += cost * line.Qty;
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
|
||||
|
||||
@@ -21,8 +21,17 @@ public sealed class SalesPricingService : ISalesPricingService
|
||||
_fifo = fifo;
|
||||
}
|
||||
|
||||
// Sales unit prices are money — always 2dp, whichever source (flat, GRN weighted avg, FIFO avg,
|
||||
// manual) produced them, so the suggested-price endpoint and saved lines agree.
|
||||
public async Task<SalesPriceResolution> ResolveAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
||||
{
|
||||
var resolved = await ResolveUnroundedAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
|
||||
return resolved with { UnitPrice = Math.Round(resolved.UnitPrice, 2, MidpointRounding.AwayFromZero) };
|
||||
}
|
||||
|
||||
private async Task<SalesPriceResolution> ResolveUnroundedAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
|
||||
?? throw new InvalidOperationException($"Item {itemId} was not found.");
|
||||
|
||||
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
|
||||
}
|
||||
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
|
||||
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
|
||||
// sits quarantined until a Purchase Return consumes it directly (see
|
||||
// PurchaseReturnService), never through this general-issue path. No LINQ is
|
||||
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
|
||||
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
|
||||
// parameter — without it a null batch filter fails with 42P18.
|
||||
var layers = await _db.StockLayers
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
|
||||
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
|
||||
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
|
||||
// Return consumes it directly (see PurchaseReturnService), never through this
|
||||
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
|
||||
// reaches the database intact.
|
||||
var locked = await _db.StockLayers
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM stock_layers sl
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
|
||||
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
|
||||
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
|
||||
ORDER BY sl."ReceiptDate", sl."LayerId"
|
||||
FOR UPDATE
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
|
||||
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
|
||||
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
|
||||
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
|
||||
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
|
||||
foreach (var shortfall in owed)
|
||||
{
|
||||
foreach (var layer in open)
|
||||
{
|
||||
if (shortfall.QtyRemaining >= 0) break;
|
||||
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= settle;
|
||||
shortfall.QtyRemaining += settle;
|
||||
}
|
||||
}
|
||||
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase)
|
||||
if (available < qtyBase && !allowShortfall)
|
||||
{
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
|
||||
}
|
||||
|
||||
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
|
||||
// below zero), costed at the item's last known unit cost — the next issue from this
|
||||
// warehouse settles it against the stock received in the meantime (see above).
|
||||
if (remaining > 0)
|
||||
{
|
||||
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
|
||||
var shortfall = new StockLayer
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = warehouseId,
|
||||
QtyReceived = 0m,
|
||||
QtyRemaining = -remaining,
|
||||
UnitCost = unitCost,
|
||||
ReceiptDate = now
|
||||
};
|
||||
await _layers.AddAsync(shortfall, ct);
|
||||
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
|
||||
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
|
||||
return await layers.Where(l => l.WarehouseId == warehouseId)
|
||||
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? 0m;
|
||||
}
|
||||
|
||||
public async Task<StockLedger> PostLedgerAsync(
|
||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var open = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
|
||||
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -132,6 +132,18 @@ public sealed class StockService : IStockService
|
||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same figure as <see cref="GetOnHandAsync"/>'s Available (all layers, negative ones included,
|
||||
/// less held OnHold/Rejected GRN stock), for every item at the warehouse in one grouped query.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.WarehouseId == warehouseId
|
||||
&& !(l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected)))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
|
||||
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — shared presets offered on every item's form on top of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
|
||||
/// item may also carry a one-off length of its own. Nothing references these rows (items
|
||||
/// store months), so deactivating is always safe and there is no in-use check.
|
||||
/// </summary>
|
||||
public sealed class WarrantyPeriodService : IWarrantyPeriodService
|
||||
{
|
||||
public const int MaxMonths = 600;
|
||||
|
||||
private readonly IRepository<WarrantyPeriod> _periods;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
|
||||
{
|
||||
_periods = periods;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _periods.Query().AsNoTracking();
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
return await q.OrderBy(p => p.Months)
|
||||
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
|
||||
{
|
||||
if (months < 1 || months > MaxMonths)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
|
||||
if (WarrantyPeriods.AllowedMonths.Contains(months))
|
||||
throw new ConflictException($"{months} months is already a default warranty period.");
|
||||
|
||||
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status == EntityStatus.Active)
|
||||
throw new ConflictException($"A {months}-month warranty period already exists.");
|
||||
|
||||
// Unique by length: re-adding a deactivated one brings it back rather than failing.
|
||||
existing.Status = EntityStatus.Active;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(existing);
|
||||
}
|
||||
|
||||
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
await _periods.AddAsync(period, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(period);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
|
||||
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
|
||||
|
||||
period.Status = status;
|
||||
period.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
@@ -81,7 +81,11 @@ export default function CustomersPage() {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType])
|
||||
// Branch-wise list, same as Sales / GRN: the header's active branch (a non-admin's own branch);
|
||||
// an Admin on "All Branches" sees every branch's customers.
|
||||
const listBranchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType, listBranchId])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
@@ -90,6 +94,7 @@ export default function CustomersPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
customerType: customerType === "All" ? undefined : customerType,
|
||||
branchId: listBranchId,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
@@ -106,10 +111,11 @@ export default function CustomersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
|
||||
@@ -519,7 +519,7 @@ export default function EmployeesPage() {
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="This employee has login access"
|
||||
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
|
||||
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login credentials? This cannot be undone — they will no longer be able to sign in, and the user will be removed from Settings > Users. Choose "Keep, but Deactivate" to only disable the login.`}
|
||||
confirmLabel="Delete User Access"
|
||||
cancelLabel="Keep, but Deactivate"
|
||||
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Shown inside the dashboard shell (sidebar and header stay put) while the next page's code
|
||||
* loads — including programmatic navigations the top progress bar can't see start.
|
||||
*/
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="ml-auto mt-4 w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
|
||||
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -35,6 +36,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
|
||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||
@@ -115,6 +118,8 @@ export default function ItemDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [imagesEnabled, setImagesEnabled] = useState(false)
|
||||
const [imagesVersion, setImagesVersion] = useState(0)
|
||||
|
||||
function applyItem(data: Item) {
|
||||
setItem(data)
|
||||
@@ -162,6 +167,7 @@ export default function ItemDetailPage() {
|
||||
setTaxRates(tr.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
@@ -254,6 +260,7 @@ export default function ItemDetailPage() {
|
||||
function closeEdit() {
|
||||
if (item) applyItem(item)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -282,6 +289,7 @@ export default function ItemDetailPage() {
|
||||
applyItem(result.data)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
@@ -408,6 +416,20 @@ export default function ItemDetailPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
{/* View-only here; images are added/removed from the Edit dialog. Re-keyed when that
|
||||
dialog closes so this copy picks up what changed there. */}
|
||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||
|
||||
<ItemReorderPanel
|
||||
key={item.itemId}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -828,6 +850,7 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||
Cancel
|
||||
|
||||
@@ -1,375 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Plus, Ruler, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brand, Uom } from "@/types/master-data"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Tab = "brand" | "uom"
|
||||
|
||||
// Large enough to show "all brands" in one grid — the chip layout replaces the
|
||||
// old table's pagination, so we just ask for everything in one page.
|
||||
const ALL_ITEMS_PAGE_SIZE = 500
|
||||
|
||||
export default function BrandsPage() {
|
||||
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
|
||||
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
|
||||
export default function BrandsRedirectPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<BrandsPageContent />
|
||||
<BrandsRedirect />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandsPageContent() {
|
||||
function BrandsRedirect() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
|
||||
function selectTab(next: Tab) {
|
||||
setTab(next)
|
||||
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("brand")}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("uom")}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
UOM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Brand | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
brandsApi
|
||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setBrands(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateBrandName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await brandsApi.create({ name })
|
||||
toast.success("Brand created", result.data.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not create brand", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(brand: Brand) {
|
||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(brand.brandId)
|
||||
try {
|
||||
await brandsApi.updateStatus(brand.brandId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update brand status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(brand: Brand) {
|
||||
setEditing(brand)
|
||||
setEditName(brand.name)
|
||||
setEditErrors({})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (!editing) return
|
||||
const nextErrors = validateBrandName(editName)
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
||||
const current = await brandsApi.get(editing.brandId)
|
||||
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
|
||||
toast.success("Brand updated", result.data.name)
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setEditErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not update brand", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New brand name, e.g. Bosch"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New brand name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add Brand
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && brands === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Tag className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No brands yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{brands.map((b) => (
|
||||
<label
|
||||
key={b.brandId}
|
||||
onDoubleClick={() => openEdit(b)}
|
||||
title="Tick to activate/deactivate — double-click to rename"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={b.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(b)}
|
||||
disabled={togglingId === b.brandId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{b.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit brand</DialogTitle>
|
||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="brand-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UomPanel() {
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
uomsApi
|
||||
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setUoms(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(uom: Uom) {
|
||||
const next = uom.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(uom.uomId)
|
||||
try {
|
||||
await uomsApi.updateStatus(uom.uomId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update UOM status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateUomName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const uom = await uomsApi.create({ name })
|
||||
toast.success("UOM created", uom.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.name) setErrors({ name: fe.name })
|
||||
toast.error("Could not create UOM", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New UOM name, e.g. Box-12"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New UOM name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add UOM
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && uoms === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Ruler className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{uoms.map((u) => (
|
||||
<label
|
||||
key={u.uomId}
|
||||
title="Tick to activate/deactivate"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={u.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(u)}
|
||||
disabled={togglingId === u.uomId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/products/categories?tab=${tab}`)
|
||||
}, [router, tab])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,484 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
import { Suspense, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category, ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
import { BrandPanel } from "@/components/products/BrandPanel"
|
||||
import { CategoryPanel } from "@/components/products/CategoryPanel"
|
||||
import { UomPanel } from "@/components/products/UomPanel"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type Tab = "category" | "brand" | "uom"
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "brand", label: "Brand" },
|
||||
{ value: "uom", label: "UOM" },
|
||||
]
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const BASE_HREF = "/dashboard/products/categories"
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
productConfig().then(setConfig).catch(() => setConfig(null))
|
||||
}, [])
|
||||
|
||||
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
||||
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
||||
// is needed) once the feature is on and the page's categories are known.
|
||||
useEffect(() => {
|
||||
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
||||
setSubCategoryCounts({})
|
||||
return
|
||||
function parseTab(value: string | null): Tab {
|
||||
return value === "brand" || value === "uom" ? value : "category"
|
||||
}
|
||||
let cancelled = false
|
||||
Promise.all(
|
||||
categories.map((c) =>
|
||||
categoriesApi
|
||||
.listSubCategories(c.categoryId, { pageSize: 1 })
|
||||
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
|
||||
.catch(() => [c.categoryId, 0] as const)
|
||||
|
||||
/**
|
||||
* Category, Brand and UOM masters on one page as tabs. The old /brands and /uoms
|
||||
* routes redirect here with `?tab=` so existing links/bookmarks keep working.
|
||||
*/
|
||||
export default function ProductMastersPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ProductMastersPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
).then((entries) => {
|
||||
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [config?.subcategoriesEnabled, categories])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
categoriesApi
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setCategories(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, status, page])
|
||||
function ProductMastersPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [tab, setTab] = useState<Tab>(parseTab(searchParams.get("tab")))
|
||||
|
||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||
|
||||
const sortedCategories = categories
|
||||
? [...categories].sort((a, b) => {
|
||||
const cmp =
|
||||
sortKey === "categoryId"
|
||||
? a.categoryId - b.categoryId
|
||||
: sortKey === "createdAt"
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: a[sortKey].localeCompare(b[sortKey])
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
: null
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(category: Category) {
|
||||
setEditing(category)
|
||||
setName(category.name)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateCategoryName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let result
|
||||
if (editing) {
|
||||
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
|
||||
const current = await categoriesApi.get(editing.categoryId)
|
||||
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
|
||||
} else {
|
||||
result = await categoriesApi.create({ name })
|
||||
}
|
||||
toast.success(editing ? "Category updated" : "Category created", result.data.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(category: Category) {
|
||||
const next = category.status === "Active" ? "Inactive" : "Active"
|
||||
setDeletingId(category.categoryId)
|
||||
try {
|
||||
await categoriesApi.updateStatus(category.categoryId, next)
|
||||
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update category status", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
function selectTab(next: Tab) {
|
||||
setTab(next)
|
||||
router.replace(next === "category" ? BASE_HREF : `${BASE_HREF}?tab=${next}`, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">Category/Brand/UOM</h1>
|
||||
<p className="text-base text-muted-foreground">Manage item categories, brands and units of measure.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
|
||||
<DialogDescription>Give the category a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
|
||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<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 categories…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search categories"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && categories === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ListTree className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{sortedCategories!.map((c) => (
|
||||
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{c.name}</p>
|
||||
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Subcategories</p>
|
||||
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedCategories!.map((c) => (
|
||||
<TableRow key={c.categoryId}>
|
||||
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Subcategories are their own resource now, not a nested tree.
|
||||
Hidden while the feature is switched off in Product Configuration. */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
onClick={() => openEditDialog(c)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
onClick={() => selectTab(t.value)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "category" && <CategoryPanel />}
|
||||
{tab === "brand" && <BrandPanel />}
|
||||
{tab === "uom" && <UomPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function ItemTypesPage() {
|
||||
} else {
|
||||
await itemTypesApi.create({ name, isMeasurable })
|
||||
}
|
||||
toast.success(editing ? "Item type updated" : "Item type created", name)
|
||||
toast.success(editing ? "Item variant updated" : "Item variant created", name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setIsMeasurable(false)
|
||||
@@ -90,7 +90,7 @@ export default function ItemTypesPage() {
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
|
||||
toast.error(editing ? "Could not update item variant" : "Could not create item variant", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default function ItemTypesPage() {
|
||||
setTogglingId(itemType.itemTypeId)
|
||||
try {
|
||||
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
|
||||
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
|
||||
toast.success(next === "Inactive" ? "Item variant deactivated" : "Item variant activated", itemType.name)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
@@ -116,7 +116,7 @@ export default function ItemTypesPage() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Variants</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
|
||||
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item's content size.
|
||||
@@ -125,11 +125,11 @@ export default function ItemTypesPage() {
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Variant</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
|
||||
<DialogDescription>Give the item type a name.</DialogDescription>
|
||||
<DialogTitle>{editing ? "Edit item variant" : "New item variant"}</DialogTitle>
|
||||
<DialogDescription>Give the item variant a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
@@ -188,7 +188,7 @@ export default function ItemTypesPage() {
|
||||
{!error && itemTypes !== null && itemTypes.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<SwatchBook className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No item types yet.</p>
|
||||
<p className="text-base text-muted-foreground">No item variants yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { ImagePlus, Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { itemTypesApi } from "@/lib/api/item-types"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
contentPairErrors,
|
||||
validateVariantContent,
|
||||
validateVariantItemForm,
|
||||
validateItemTypeName,
|
||||
validateVariantPrices,
|
||||
} from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
|
||||
import {
|
||||
Brand,
|
||||
Category,
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
ProductConfig,
|
||||
StockNature,
|
||||
SubCategory,
|
||||
WARRANTY_PERIOD_MONTHS_OPTIONS,
|
||||
Warranty,
|
||||
WarrantyPeriodMonths,
|
||||
} from "@/types/master-data"
|
||||
@@ -43,10 +45,24 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import {
|
||||
ITEM_IMAGE_ACCEPT,
|
||||
ITEM_IMAGE_MAX_COUNT,
|
||||
itemImageProblem,
|
||||
} from "@/components/products/ItemImagesPanel"
|
||||
|
||||
function skuSegment(text: string, maxLen: number): string {
|
||||
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||
@@ -105,6 +121,8 @@ export default function NewItemPage() {
|
||||
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||
/** Form-level unit for size variants — the default unit of every size value added below. */
|
||||
const [measurementUnit, setMeasurementUnit] = useState<MeasureUnit | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -130,6 +148,23 @@ export default function NewItemPage() {
|
||||
// submit, without having to remove and re-add the whole value that produced it.
|
||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
// Inline "custom variant" creation — only offered when config.customVariantsEnabled.
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customName, setCustomName] = useState("")
|
||||
// Ticked ⇒ the custom variant is a size (measurement) dimension, valued in `measurementUnit`.
|
||||
const [customIsSize, setCustomIsSize] = useState(false)
|
||||
const [customError, setCustomError] = useState<string | null>(null)
|
||||
|
||||
// Item images (config.itemImagesEnabled): the items don't exist until submit, so picks are
|
||||
// held locally with object-URL previews and uploaded to every created variant afterwards.
|
||||
const [pendingImages, setPendingImages] = useState<{ file: File; url: string }[]>([])
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const pendingImagesRef = useRef(pendingImages)
|
||||
useEffect(() => {
|
||||
pendingImagesRef.current = pendingImages
|
||||
}, [pendingImages])
|
||||
useEffect(() => () => pendingImagesRef.current.forEach((p) => URL.revokeObjectURL(p.url)), [])
|
||||
|
||||
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
|
||||
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
|
||||
// each row keeps its own flat price in `pricesByKey`.
|
||||
@@ -141,6 +176,15 @@ export default function NewItemPage() {
|
||||
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
||||
// Active rows of the Warranty Periods master (months), offered when config.customWarrantyPeriodsEnabled.
|
||||
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
|
||||
// Inline "custom period" — adds to that master without leaving the form.
|
||||
const [customPeriodOpen, setCustomPeriodOpen] = useState(false)
|
||||
const [customPeriodAmount, setCustomPeriodAmount] = useState("")
|
||||
const [customPeriodUnit, setCustomPeriodUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [customPeriodError, setCustomPeriodError] = useState<string | null>(null)
|
||||
// A length entered via "Custom period" — for this item only, never saved to the master.
|
||||
const [itemOnlyWarrantyMonths, setItemOnlyWarrantyMonths] = useState<number | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
@@ -169,6 +213,11 @@ export default function NewItemPage() {
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
// Optional extra: a failure here just means only the default periods are offered.
|
||||
warrantyPeriodsApi
|
||||
.list({ status: "Active" })
|
||||
.then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months)))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Subcategories are their own resource now — fetched per category rather than filtered
|
||||
@@ -213,6 +262,22 @@ export default function NewItemPage() {
|
||||
config?.sellingPricesEnabled,
|
||||
])
|
||||
|
||||
const warrantyGroups = warrantyPeriodGroups(config, customWarrantyMonths)
|
||||
// Dropdown sections: defaults, the shared presets, then this item's own length (if any and
|
||||
// not already one of the others).
|
||||
const warrantySelectGroups = [
|
||||
{ label: "Default periods", months: warrantyGroups.defaults },
|
||||
{ label: "Custom periods", months: warrantyGroups.custom },
|
||||
{
|
||||
label: "This item only",
|
||||
months:
|
||||
config?.customWarrantyPeriodsEnabled &&
|
||||
itemOnlyWarrantyMonths !== null &&
|
||||
![...warrantyGroups.defaults, ...warrantyGroups.custom].includes(itemOnlyWarrantyMonths)
|
||||
? [itemOnlyWarrantyMonths]
|
||||
: [],
|
||||
},
|
||||
].filter((g) => g.months.length > 0)
|
||||
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||
/**
|
||||
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
|
||||
@@ -234,6 +299,98 @@ export default function NewItemPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function addPendingImages(files: FileList | null) {
|
||||
if (!files) return
|
||||
const room = ITEM_IMAGE_MAX_COUNT - pendingImages.length
|
||||
const picked = Array.from(files)
|
||||
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
|
||||
const accepted: { file: File; url: string }[] = []
|
||||
for (const file of picked.slice(0, Math.max(room, 0))) {
|
||||
const problem = itemImageProblem(file)
|
||||
if (problem) toast.error("Image skipped", problem)
|
||||
else accepted.push({ file, url: URL.createObjectURL(file) })
|
||||
}
|
||||
setPendingImages((prev) => [...prev, ...accepted])
|
||||
}
|
||||
|
||||
function removePendingImage(url: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
setPendingImages((prev) => prev.filter((p) => p.url !== url))
|
||||
}
|
||||
|
||||
function closeCustomPeriod() {
|
||||
setCustomPeriodOpen(false)
|
||||
setCustomPeriodAmount("")
|
||||
setCustomPeriodUnit("months")
|
||||
setCustomPeriodError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a warranty length for THIS item only. Nothing is saved to the Warranty Periods
|
||||
* master — the item just stores the months (the server accepts any 1–600 while custom
|
||||
* periods are on), so it isn't offered the next time the form opens.
|
||||
*/
|
||||
function addCustomWarrantyPeriod() {
|
||||
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setCustomPeriodError(parsed.error)
|
||||
return
|
||||
}
|
||||
setItemOnlyWarrantyMonths(parsed.months)
|
||||
setWarrantyPeriodMonths(parsed.months)
|
||||
closeCustomPeriod()
|
||||
}
|
||||
|
||||
function closeCustomVariant() {
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomIsSize(false)
|
||||
setCustomError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a variant dimension for THIS item only. Nothing is saved to the Item Variants master:
|
||||
* items never reference an item type (the chosen values are baked into each generated
|
||||
* item's name and SKU), so a form-local entry with a negative id is all the builder needs,
|
||||
* and it is gone the next time the form opens.
|
||||
*
|
||||
* Ticked as a size variant it becomes a measurement dimension: values are entered as numbers
|
||||
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
|
||||
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
|
||||
*/
|
||||
function createCustomVariant() {
|
||||
const name = customName.trim() || (customIsSize ? "Size" : "")
|
||||
const invalid = validateItemTypeName(name).name
|
||||
if (invalid) {
|
||||
setCustomError(invalid)
|
||||
return
|
||||
}
|
||||
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
||||
setCustomError(`"${name}" already exists — tick it above instead.`)
|
||||
return
|
||||
}
|
||||
if (customIsSize && !measurementUnit) {
|
||||
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
|
||||
return
|
||||
}
|
||||
// Only one dimension may supply the content size, or a variant would have two.
|
||||
if (customIsSize && measurableCheckedCount > 0) {
|
||||
setCustomError("Only one size variant can be used at a time — untick the other one first.")
|
||||
return
|
||||
}
|
||||
const local: ItemType = {
|
||||
itemTypeId: -Date.now(),
|
||||
name,
|
||||
isMeasurable: customIsSize,
|
||||
status: "Active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
setItemTypes((prev) => [...(prev ?? []), local])
|
||||
setCheckedItemTypeIds((prev) => [...prev, local.itemTypeId])
|
||||
closeCustomVariant()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add is the real validation gate for a measurement — rejecting here means a half or
|
||||
* duplicate pair never reaches state, so nothing downstream has to defend against one.
|
||||
@@ -255,7 +412,9 @@ export default function NewItemPage() {
|
||||
}
|
||||
|
||||
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
|
||||
const unit = unitByCategory[itemTypeId] ?? null
|
||||
// The form-level Measurement unit, when chosen, is fixed for every size; otherwise the
|
||||
// per-dimension pick.
|
||||
const unit = measurementUnit ?? unitByCategory[itemTypeId] ?? null
|
||||
const qty = Number(raw)
|
||||
|
||||
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
||||
@@ -416,6 +575,7 @@ export default function NewItemPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
let created = 0
|
||||
const createdIds: number[] = []
|
||||
try {
|
||||
const effectiveTrackingMode =
|
||||
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
@@ -430,7 +590,7 @@ export default function NewItemPage() {
|
||||
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
|
||||
for (const variant of variants) {
|
||||
const content = contentForVariant(variant)
|
||||
await itemsApi.create({
|
||||
const { data: createdItem } = await itemsApi.create({
|
||||
sku: variant.sku,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
|
||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||
@@ -463,8 +623,24 @@ export default function NewItemPage() {
|
||||
contentUnit: content?.unit ?? null,
|
||||
})
|
||||
created += 1
|
||||
createdIds.push(createdItem.itemId)
|
||||
}
|
||||
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
||||
|
||||
// Images are best-effort: the items already exist, so a failed upload is reported
|
||||
// (and can be retried from the item page) rather than undoing the creation.
|
||||
if (config?.itemImagesEnabled && pendingImages.length > 0) {
|
||||
let failed = 0
|
||||
for (const itemId of createdIds) {
|
||||
for (const { file } of pendingImages) {
|
||||
await itemsApi.uploadImage(itemId, file).catch(() => {
|
||||
failed += 1
|
||||
})
|
||||
}
|
||||
}
|
||||
if (failed > 0)
|
||||
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
|
||||
}
|
||||
router.push("/dashboard/products")
|
||||
} catch (err) {
|
||||
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
||||
@@ -489,7 +665,7 @@ export default function NewItemPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,7 +678,7 @@ export default function NewItemPage() {
|
||||
{!loading && baseUomId === null && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
||||
No unit of measure exists yet. Items need a base UOM —{" "}
|
||||
<Link href="/dashboard/products/uoms" className="font-semibold underline">
|
||||
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
|
||||
create one first
|
||||
</Link>
|
||||
.
|
||||
@@ -631,6 +807,32 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* The unit size variants are measured in. Picked once here; every size value added
|
||||
below (custom size variant or a measurement Item Variant) starts in this unit. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Measurement unit</Label>
|
||||
<Select<MeasureUnit | "none">
|
||||
value={measurementUnit ?? "none"}
|
||||
onValueChange={(v) => setMeasurementUnit(v && v !== "none" ? v : null)}
|
||||
items={[
|
||||
{ label: "None", value: "none" as const },
|
||||
...CONTENT_UNITS.map((u) => ({ label: UNIT_LABEL[u], value: u })),
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-base">None</SelectItem>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{UNIT_LABEL[u]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Used for size variants (e.g. 500 ml, 1 L).</p>
|
||||
</div>
|
||||
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
|
||||
or a label has none. Litres/kilograms are normalised to ml/g by the server.
|
||||
Hidden once a measurement dimension is in play, because that dimension then
|
||||
@@ -822,19 +1024,116 @@ export default function NewItemPage() {
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={warrantyPeriodMonths}
|
||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
|
||||
label: formatWarrantyPeriod(m),
|
||||
value: m,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||
{warrantySelectGroups.length === 1
|
||||
? warrantySelectGroups[0].months.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{months} months
|
||||
{formatWarrantyPeriod(months)}
|
||||
</SelectItem>
|
||||
))
|
||||
: warrantySelectGroups.map((group, i) => (
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator />}
|
||||
<SelectGroup>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.months.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{formatWarrantyPeriod(months)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||
|
||||
{config?.customWarrantyPeriodsEnabled &&
|
||||
(customPeriodOpen ? (
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Custom warranty period</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For this item only — it isn't added to Warranty Periods or offered on other items.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={customPeriodAmount}
|
||||
onChange={(e) => {
|
||||
setCustomPeriodAmount(e.target.value)
|
||||
setCustomPeriodError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addCustomWarrantyPeriod()
|
||||
} else if (e.key === "Escape") {
|
||||
closeCustomPeriod()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-label="Warranty length"
|
||||
aria-invalid={!!customPeriodError}
|
||||
className="h-10 w-24 text-base"
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={customPeriodUnit}
|
||||
onValueChange={(v) => v && setCustomPeriodUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months" className="text-base">Months</SelectItem>
|
||||
<SelectItem value="years" className="text-base">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" size="sm" onClick={addCustomWarrantyPeriod}>
|
||||
Use
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={closeCustomPeriod}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{customPeriodUnit === "years" &&
|
||||
Number.isInteger(Number(customPeriodAmount)) &&
|
||||
Number(customPeriodAmount) > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
= {Number(customPeriodAmount) * 12} months
|
||||
</span>
|
||||
)}
|
||||
{customPeriodError && <p className="text-sm text-destructive">{customPeriodError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto w-fit px-0"
|
||||
onClick={() => setCustomPeriodOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Custom period
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -842,14 +1141,68 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.itemImagesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Not required — you can skip this and add images later from the item page. JPG, PNG or WebP, up to 5 MB each and {ITEM_IMAGE_MAX_COUNT} per item. They are added to every
|
||||
variant created below; the first image is the main one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5 lg:grid-cols-8">
|
||||
{pendingImages.map((p, index) => (
|
||||
<div key={p.url} className="relative aspect-square overflow-hidden rounded-lg border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local object-URL preview */}
|
||||
<img src={p.url} alt={p.file.name} className="size-full object-cover" />
|
||||
{index === 0 && (
|
||||
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingImage(p.url)}
|
||||
aria-label={`Remove ${p.file.name}`}
|
||||
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingImages.length < ITEM_IMAGE_MAX_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<ImagePlus className="size-6" />
|
||||
<span className="text-xs font-medium">Add images</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept={ITEM_IMAGE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addPendingImages(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||
item-type reference), so this section IS the enforcement. */}
|
||||
{config?.itemTypesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
|
||||
<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 variants that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
|
||||
measurement out of the box, so the builder silently falls back to one shared
|
||||
@@ -858,7 +1211,7 @@ export default function NewItemPage() {
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Selling the same product in several sizes (500 ml, 1 L)?{" "}
|
||||
<Link href="/dashboard/products/item-types" className="font-medium underline">
|
||||
Mark that item type as a measurement
|
||||
Mark that item variant as a measurement
|
||||
</Link>{" "}
|
||||
and each size will capture its own number and unit, giving every variant its own content size.
|
||||
</p>
|
||||
@@ -886,13 +1239,83 @@ export default function NewItemPage() {
|
||||
/>
|
||||
<span className="text-base font-medium">{t.name}</span>
|
||||
{t.isMeasurable && (
|
||||
<Badge variant="outline" className="text-xs">measurement</Badge>
|
||||
<Badge variant="outline" className="text-xs">size</Badge>
|
||||
)}
|
||||
{t.itemTypeId < 0 && (
|
||||
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-xs text-primary">
|
||||
this item only
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
|
||||
{config?.customVariantsEnabled && !customOpen && (
|
||||
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Custom variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config?.customVariantsEnabled && customOpen && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-4">
|
||||
<div>
|
||||
<Label htmlFor="custom-variant-name" className="text-base">Custom variant</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Used for this item only — it isn't added to Item Variants or offered on other items. Tick
|
||||
Size variant to enter sizes in the Measurement unit above (500 ml, 1 L), each with its own
|
||||
content amount.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
id="custom-variant-name"
|
||||
autoFocus
|
||||
value={customName}
|
||||
onChange={(e) => {
|
||||
setCustomName(e.target.value)
|
||||
setCustomError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
createCustomVariant()
|
||||
} else if (e.key === "Escape") {
|
||||
closeCustomVariant()
|
||||
}
|
||||
}}
|
||||
placeholder={customIsSize ? "Size" : "e.g. Colour"}
|
||||
aria-invalid={!!customError}
|
||||
className="h-11 text-base sm:max-w-64"
|
||||
/>
|
||||
<label
|
||||
className={cn("flex items-center gap-2 text-sm", !measurementUnit && "text-muted-foreground")}
|
||||
title={measurementUnit ? undefined : "Pick a Measurement unit at the top first"}
|
||||
>
|
||||
<Checkbox
|
||||
checked={customIsSize}
|
||||
disabled={!measurementUnit}
|
||||
onCheckedChange={(c) => {
|
||||
setCustomIsSize(c === true)
|
||||
setCustomError(null)
|
||||
}}
|
||||
/>
|
||||
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
|
||||
</label>
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={createCustomVariant}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{customError && <p className="text-sm text-destructive">{customError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.variants ? { message: errors.variants } : undefined,
|
||||
@@ -930,6 +1353,15 @@ export default function NewItemPage() {
|
||||
aria-invalid={!!valueErrors[t.itemTypeId]}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{measurementUnit ? (
|
||||
// Fixed by the form-level Measurement unit — every size is in that unit.
|
||||
<div
|
||||
className="flex h-11 w-24 shrink-0 items-center justify-center rounded-md border bg-muted text-base text-muted-foreground"
|
||||
title="Set by Measurement unit above"
|
||||
>
|
||||
{UNIT_LABEL[measurementUnit]}
|
||||
</div>
|
||||
) : (
|
||||
<Select<MeasureUnit | null>
|
||||
value={unitByCategory[t.itemTypeId] ?? null}
|
||||
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
|
||||
@@ -946,6 +1378,7 @@ export default function NewItemPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
|
||||
@@ -8,17 +8,18 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
ListFilter,
|
||||
ListTree,
|
||||
Package,
|
||||
PackageX,
|
||||
Pencil,
|
||||
Plus,
|
||||
Ruler,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { ItemStockFilter, itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
@@ -26,17 +27,19 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
|
||||
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ColumnsMenu } from "@/components/ui/columns-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
@@ -45,12 +48,23 @@ type SortOrder = "asc" | "desc"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
const COLUMN_OPTIONS = [
|
||||
{ key: "categoryBrand", label: "Category / Brand" },
|
||||
{ key: "stockValue", label: "Stock Value" },
|
||||
{ key: "lastPurchase", label: "Last Purchase" },
|
||||
{ key: "sales30d", label: "Sales (30d)" },
|
||||
{ key: "status", label: "Status" },
|
||||
const STOCK_OPTIONS: { label: string; value: ItemStockFilter | "All" }[] = [
|
||||
{ label: "Any stock level", value: "All" },
|
||||
{ label: "In stock", value: "InStock" },
|
||||
{ label: "Out of stock", value: "OutOfStock" },
|
||||
{ label: "Low stock", value: "LowStock" },
|
||||
]
|
||||
const TRACKING_OPTIONS: { label: string; value: TrackingMode | "All" }[] = [
|
||||
{ label: "Any tracking", value: "All" },
|
||||
{ label: "No tracking", value: "None" },
|
||||
{ label: "Batch", value: "Batch" },
|
||||
{ label: "Serial", value: "Serial" },
|
||||
]
|
||||
const NATURE_OPTIONS: { label: string; value: StockNature | "All" }[] = [
|
||||
{ label: "Any stock nature", value: "All" },
|
||||
{ label: "Stocked", value: "Stocked" },
|
||||
{ label: "Non-stocked", value: "NonStocked" },
|
||||
{ label: "Service", value: "Service" },
|
||||
]
|
||||
|
||||
function formatMoney(n: number) {
|
||||
@@ -70,6 +84,40 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect<T extends string | number>({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { label: string; value: T }[]
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select<T> value={value} onValueChange={(v) => v !== null && onChange(v)} items={options} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={String(o.value)} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemsPage() {
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -79,11 +127,19 @@ export default function ItemsPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
|
||||
const dashboardStats = useDashboardStats()
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | "All">("All")
|
||||
const [brandId, setBrandId] = useState<number | "All">("All")
|
||||
const [stock, setStock] = useState<ItemStockFilter | "All">("All")
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode | "All">("All")
|
||||
const [stockNature, setStockNature] = useState<StockNature | "All">("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -91,7 +147,6 @@ export default function ItemsPage() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [sortKey, setSortKey] = useState<SortKey>("sku")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -100,7 +155,7 @@ export default function ItemsPage() {
|
||||
|
||||
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
|
||||
// pages) — restarts at page 1. Reset during render rather than in an effect.
|
||||
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
|
||||
const resetKey = `${query}|${status}|${categoryId}|${subCategoryId}|${brandId}|${stock}|${trackingMode}|${stockNature}|${isMobile}`
|
||||
const [prevResetKey, setPrevResetKey] = useState(resetKey)
|
||||
if (prevResetKey !== resetKey) {
|
||||
setPrevResetKey(resetKey)
|
||||
@@ -118,6 +173,11 @@ export default function ItemsPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
categoryId: categoryId === "All" ? undefined : categoryId,
|
||||
subCategoryId: subCategoryId === "All" ? undefined : subCategoryId,
|
||||
brandId: brandId === "All" ? undefined : brandId,
|
||||
stock: stock === "All" ? undefined : stock,
|
||||
trackingMode: trackingMode === "All" ? undefined : trackingMode,
|
||||
stockNature: stockNature === "All" ? undefined : stockNature,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
@@ -135,12 +195,51 @@ export default function ItemsPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [page, pageSize, query, status, categoryId, isMobile])
|
||||
}, [page, pageSize, query, status, categoryId, subCategoryId, brandId, stock, trackingMode, stockNature, isMobile])
|
||||
|
||||
// Subcategories only make sense under a chosen category.
|
||||
const subcategoriesOn = !!config?.subcategoriesEnabled && categoryId !== "All"
|
||||
useEffect(() => {
|
||||
if (!subcategoriesOn) return
|
||||
let cancelled = false
|
||||
categoriesApi
|
||||
.listSubCategories(categoryId, { pageSize: 200 })
|
||||
.then((res) => {
|
||||
if (!cancelled) setSubCategories(res.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [subcategoriesOn, categoryId])
|
||||
|
||||
function showMore() {
|
||||
setLoadingMore(true)
|
||||
setPage((p) => p + 1)
|
||||
}
|
||||
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
|
||||
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
itemsApi.overview({ page: 1, pageSize: 1 }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
|
||||
])
|
||||
.then(([total, low, out]) => {
|
||||
if (cancelled) return
|
||||
setItemStats({
|
||||
total: total.pagination.totalItems,
|
||||
lowStock: low.pagination.totalItems,
|
||||
outOfStock: out.pagination.totalItems,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
@@ -156,11 +255,21 @@ export default function ItemsPage() {
|
||||
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
|
||||
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
|
||||
|
||||
const showCol = (key: string) => visibleColumns[key] ?? true
|
||||
function toggleColumn(key: string, next: boolean) {
|
||||
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
|
||||
function changeCategory(next: number | "All") {
|
||||
setCategoryId(next)
|
||||
setSubCategoryId("All")
|
||||
setSubCategories([])
|
||||
}
|
||||
|
||||
function clearAdvanced() {
|
||||
setSubCategoryId("All")
|
||||
setBrandId("All")
|
||||
setStock("All")
|
||||
setTrackingMode("All")
|
||||
setStockNature("All")
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
@@ -236,14 +345,14 @@ export default function ItemsPage() {
|
||||
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Ruler className="size-5" />
|
||||
UOMs
|
||||
</Link>
|
||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<ListTree className="size-5" />
|
||||
Categories
|
||||
Category/Brand/UOM
|
||||
</Link>
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
||||
<Download className="size-5" />
|
||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Item
|
||||
@@ -251,50 +360,103 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 SKU or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search items"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{itemStats ? (
|
||||
<>
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} />
|
||||
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
|
||||
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
{/* Passed as a string so StatCard shows the full 2dp amount, not its compact "1.2M" form. */}
|
||||
{dashboardStats ? (
|
||||
<StatCard label="Stock value" value={formatMoney(dashboardStats.stockValuationTotal)} icon={CircleDollarSign} />
|
||||
) : (
|
||||
<Skeleton className="h-22.5 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same filter bar as the Sales list, but search on the left and every filter
|
||||
(category, status, "Advanced" popover) grouped on the right. */}
|
||||
<FilterBar
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Search SKU or name…"
|
||||
searchAriaLabel="Search items"
|
||||
advancedActive={advancedCount > 0}
|
||||
onClearAdvanced={clearAdvanced}
|
||||
advanced={
|
||||
<>
|
||||
<FilterSelect label="Stock level" value={stock} onChange={setStock} options={STOCK_OPTIONS} />
|
||||
{config?.brandsEnabled && (
|
||||
<FilterSelect
|
||||
label="Brand"
|
||||
value={brandId}
|
||||
onChange={setBrandId}
|
||||
options={[{ label: "All brands", value: "All" as const }, ...brands.map((b) => ({ label: b.name, value: b.brandId }))]}
|
||||
/>
|
||||
)}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<FilterSelect
|
||||
label="Subcategory"
|
||||
value={subCategoryId}
|
||||
onChange={setSubCategoryId}
|
||||
disabled={categoryId === "All"}
|
||||
placeholder={categoryId === "All" ? "Pick a category first" : undefined}
|
||||
options={[
|
||||
{ label: "All subcategories", value: "All" as const },
|
||||
...subCategories.map((s) => ({ label: s.name, value: s.subCategoryId })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<number | "All">
|
||||
value={categoryId}
|
||||
onValueChange={(v) => setCategoryId(v ?? "All")}
|
||||
onValueChange={(v) => changeCategory(v ?? "All")}
|
||||
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Category">
|
||||
<ListTree className="size-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All categories</SelectItem>
|
||||
<SelectItem value="All">All categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
<SelectItem key={c.categoryId} value={c.categoryId}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
||||
<Select<StatusFilter>
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v ?? "All")}
|
||||
items={[
|
||||
{ label: "All statuses", value: "All" as const },
|
||||
{ label: "Active", value: "Active" as const },
|
||||
{ label: "Inactive", value: "Inactive" as const },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
||||
<ListFilter className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
||||
<Download className="size-4" />
|
||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
@@ -355,7 +517,6 @@ export default function ItemsPage() {
|
||||
<p className="text-sm text-muted-foreground">{item.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showCol("status") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
@@ -365,22 +526,18 @@ export default function ItemsPage() {
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCol("categoryBrand") && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{categoryName(item.categoryId)}
|
||||
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
{showCol("stockValue") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Stock Value</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
|
||||
<span className="font-medium text-foreground">{item.stockQty.toLocaleString()} on hand</span>
|
||||
{item.isLowStock && (
|
||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="size-3" />
|
||||
@@ -388,16 +545,12 @@ export default function ItemsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||
</div>
|
||||
)}
|
||||
{showCol("sales30d") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Sales (30d)</p>
|
||||
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{showCol("lastPurchase") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last Purchase</p>
|
||||
{item.lastPurchaseDate ? (
|
||||
@@ -409,7 +562,6 @@ export default function ItemsPage() {
|
||||
<p className="text-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
@@ -440,11 +592,11 @@ export default function ItemsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
|
||||
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
|
||||
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
|
||||
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
|
||||
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
|
||||
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -474,16 +626,13 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||
{showCol("categoryBrand") && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
<p className="text-foreground">{categoryName(item.categoryId)}</p>
|
||||
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("stockValue") && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatMoney(item.stockValue)}</span>
|
||||
<span className="font-medium">{item.stockQty.toLocaleString()} on hand</span>
|
||||
{item.isLowStock && (
|
||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="size-3" />
|
||||
@@ -491,10 +640,8 @@ export default function ItemsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("lastPurchase") && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{item.lastPurchaseDate ? (
|
||||
<>
|
||||
@@ -505,9 +652,7 @@ export default function ItemsPage() {
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
|
||||
{showCol("status") && (
|
||||
<TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -519,7 +664,6 @@ export default function ItemsPage() {
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ReactNode, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Package } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductConfig } from "@/types/master-data"
|
||||
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
@@ -37,18 +38,10 @@ export default function ProductSettingsPage() {
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function toggle(flag: keyof Pick<
|
||||
ProductConfig,
|
||||
| "subcategoriesEnabled"
|
||||
| "brandsEnabled"
|
||||
| "itemTypesEnabled"
|
||||
| "sellingPricesEnabled"
|
||||
| "serialNumbersEnabled"
|
||||
| "batchListsEnabled"
|
||||
| "warrantyPeriodsEnabled"
|
||||
>, next: boolean) {
|
||||
if (!config) return
|
||||
setSaving(flag)
|
||||
/** PUT is a full replacement, so every save sends the whole current config plus `patch`. */
|
||||
async function save(key: keyof ProductConfig, patch: Partial<UpdateProductConfigRequest>, message: string): Promise<boolean> {
|
||||
if (!config) return false
|
||||
setSaving(key)
|
||||
try {
|
||||
const res = await productConfigApi.update(
|
||||
{
|
||||
@@ -59,21 +52,30 @@ export default function ProductSettingsPage() {
|
||||
serialNumbersEnabled: config.serialNumbersEnabled,
|
||||
batchListsEnabled: config.batchListsEnabled,
|
||||
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
|
||||
[flag]: next,
|
||||
customVariantsEnabled: config.customVariantsEnabled,
|
||||
itemImagesEnabled: config.itemImagesEnabled,
|
||||
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
||||
toast.success("Configuration saved", message)
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
load()
|
||||
return false
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(flag: FlagKey, next: boolean) {
|
||||
return save(flag, { [flag]: next }, `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -102,10 +104,42 @@ export default function ProductSettingsPage() {
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||
<ToggleRow
|
||||
label="Product variants"
|
||||
description="Controls the variant builder on item create. Off hides the item-type section."
|
||||
description="Controls the variant builder on item create. Off hides the item-variants section."
|
||||
checked={config.itemTypesEnabled}
|
||||
busy={saving === "itemTypesEnabled"}
|
||||
onChange={(v) => toggle("itemTypesEnabled", v)}
|
||||
onChange={(v) =>
|
||||
v
|
||||
? toggle("itemTypesEnabled", true)
|
||||
: // Custom variants need this on, so switching it off turns that off too.
|
||||
save(
|
||||
"itemTypesEnabled",
|
||||
{ itemTypesEnabled: false, customVariantsEnabled: false },
|
||||
config.customVariantsEnabled
|
||||
? "Product variants and custom variants disabled."
|
||||
: "Product variants disabled.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Allow custom variants"
|
||||
description={
|
||||
config.itemTypesEnabled
|
||||
? "Lets users create a new item variant (e.g. Flavour) right from the item create screen, without visiting Item Variants first."
|
||||
: "Needs Product variants switched on — the custom variant option lives in the item-variants section."
|
||||
}
|
||||
checked={config.customVariantsEnabled}
|
||||
busy={saving === "customVariantsEnabled"}
|
||||
disabled={!config.itemTypesEnabled}
|
||||
onChange={(v) => toggle("customVariantsEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Item images"
|
||||
description="Adds an images area to item create and the item page (JPG, PNG or WebP, up to 10 per item). Off hides it; images already added stay visible."
|
||||
checked={config.itemImagesEnabled}
|
||||
busy={saving === "itemImagesEnabled"}
|
||||
onChange={(v) => toggle("itemImagesEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
@@ -137,7 +171,39 @@ export default function ProductSettingsPage() {
|
||||
description="Allows warranty-tracked items to pick a coverage period."
|
||||
checked={config.warrantyPeriodsEnabled}
|
||||
busy={saving === "warrantyPeriodsEnabled"}
|
||||
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
|
||||
onChange={(v) =>
|
||||
v
|
||||
? toggle("warrantyPeriodsEnabled", true)
|
||||
: // Custom warranty periods need this on, so switching it off turns that off too.
|
||||
save(
|
||||
"warrantyPeriodsEnabled",
|
||||
{ warrantyPeriodsEnabled: false, customWarrantyPeriodsEnabled: false },
|
||||
config.customWarrantyPeriodsEnabled
|
||||
? "Warranty periods and custom warranty periods disabled."
|
||||
: "Warranty periods disabled.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Custom warranty periods"
|
||||
description={
|
||||
config.warrantyPeriodsEnabled ? (
|
||||
<>
|
||||
Offers the periods from{" "}
|
||||
<Link href="/dashboard/products/warranty-periods" className="font-medium underline">
|
||||
Products → Warranty Periods
|
||||
</Link>{" "}
|
||||
alongside the default 3, 6, 12 and 18 months.
|
||||
</>
|
||||
) : (
|
||||
"Needs Warranty periods switched on."
|
||||
)
|
||||
}
|
||||
checked={config.customWarrantyPeriodsEnabled}
|
||||
busy={saving === "customWarrantyPeriodsEnabled"}
|
||||
disabled={!config.warrantyPeriodsEnabled}
|
||||
onChange={(v) => toggle("customWarrantyPeriodsEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
@@ -177,21 +243,40 @@ const LABELS: Record<keyof ProductConfig, string> = {
|
||||
serialNumbersEnabled: "Serial numbers",
|
||||
batchListsEnabled: "Batch lists",
|
||||
warrantyPeriodsEnabled: "Warranty periods",
|
||||
customVariantsEnabled: "Custom variants",
|
||||
itemImagesEnabled: "Item images",
|
||||
customWarrantyPeriodsEnabled: "Custom warranty periods",
|
||||
updatedAt: "Updated at",
|
||||
updatedBy: "Updated by",
|
||||
}
|
||||
|
||||
type FlagKey = keyof Pick<
|
||||
ProductConfig,
|
||||
| "subcategoriesEnabled"
|
||||
| "brandsEnabled"
|
||||
| "itemTypesEnabled"
|
||||
| "sellingPricesEnabled"
|
||||
| "serialNumbersEnabled"
|
||||
| "batchListsEnabled"
|
||||
| "warrantyPeriodsEnabled"
|
||||
| "customVariantsEnabled"
|
||||
| "itemImagesEnabled"
|
||||
| "customWarrantyPeriodsEnabled"
|
||||
>
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
busy,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
description: ReactNode
|
||||
checked: boolean
|
||||
busy: boolean
|
||||
disabled?: boolean
|
||||
onChange: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -199,7 +284,7 @@ function ToggleRow({
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={busy}
|
||||
disabled={busy || disabled}
|
||||
aria-label={label}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
|
||||
// to keep old links/bookmarks working.
|
||||
// UOM now lives as a tab on the merged Category/Brand/UOM page — this route stays
|
||||
// only to keep old links/bookmarks working.
|
||||
export default function UomsRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/dashboard/products/brands?tab=uom")
|
||||
router.replace("/dashboard/products/categories?tab=uom")
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Ban, CheckCircle2, Plus, ShieldCheck } from "lucide-react"
|
||||
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit } from "@/lib/warranty"
|
||||
import { WARRANTY_PERIOD_MONTHS_OPTIONS, WarrantyPeriod } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
|
||||
/**
|
||||
* Warranty Periods — custom coverage lengths offered to items on top of the fixed defaults
|
||||
* (3/6/12/18 months), while Product Configuration → "Custom warranty periods" is on.
|
||||
*
|
||||
* Entered in months or years, always stored as months. Items store the months themselves,
|
||||
* so a length can't be edited (add a new one instead) and deactivating one only stops it
|
||||
* being offered — items already using it keep it.
|
||||
*/
|
||||
export default function WarrantyPeriodsPage() {
|
||||
const [periods, setPeriods] = useState<WarrantyPeriod[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [customEnabled, setCustomEnabled] = useState<boolean | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
warrantyPeriodsApi
|
||||
.list()
|
||||
.then(setPeriods)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
// Initial fetch sets state only in callbacks (load() also clears the error, for reloads).
|
||||
useEffect(() => {
|
||||
warrantyPeriodsApi
|
||||
.list()
|
||||
.then(setPeriods)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
productConfig()
|
||||
.then((cfg) => setCustomEnabled(cfg.warrantyPeriodsEnabled && cfg.customWarrantyPeriodsEnabled))
|
||||
.catch(() => setCustomEnabled(null))
|
||||
}, [])
|
||||
|
||||
function openCreateDialog() {
|
||||
setAmount("")
|
||||
setUnit("months")
|
||||
setFormError(null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// Only an ACTIVE duplicate is an error — re-adding an inactive length reactivates it.
|
||||
const activeMonths = (periods ?? []).filter((p) => p.status === "Active").map((p) => p.months)
|
||||
const parsed = parseWarrantyInput(amount, unit, activeMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setFormError(parsed.error)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await warrantyPeriodsApi.create(parsed.months)
|
||||
toast.success("Warranty period added", formatWarrantyPeriod(parsed.months))
|
||||
setOpen(false)
|
||||
load()
|
||||
} catch (err) {
|
||||
setFormError(errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(period: WarrantyPeriod) {
|
||||
const next = period.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(period.warrantyPeriodId)
|
||||
try {
|
||||
await warrantyPeriodsApi.updateStatus(period.warrantyPeriodId, next)
|
||||
toast.success(
|
||||
next === "Inactive" ? "Warranty period deactivated" : "Warranty period activated",
|
||||
formatWarrantyPeriod(period.months),
|
||||
)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const years = Number(amount)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Warranty Periods</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Your own warranty lengths, offered on items alongside the defaults. Enter them in months or years — they are
|
||||
saved as months.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg" onClick={openCreateDialog}>
|
||||
<Plus className="size-5" />
|
||||
Add Warranty Period
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New warranty period</DialogTitle>
|
||||
<DialogDescription>How long the warranty lasts.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!formError}>
|
||||
<FieldLabel htmlFor="warranty-period-amount">Length</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="warranty-period-amount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
setFormError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-invalid={!!formError}
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={unit}
|
||||
onValueChange={(v) => v && setUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="w-32 shrink-0" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months">Months</SelectItem>
|
||||
<SelectItem value="years">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{unit === "years" && Number.isInteger(years) && years > 0 && (
|
||||
<span className="text-sm text-muted-foreground">= {years * 12} months</span>
|
||||
)}
|
||||
<FieldError errors={[formError ? { message: formError } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{customEnabled === false && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
|
||||
Custom warranty periods are switched off, so items only offer the defaults. Turn on{" "}
|
||||
<span className="font-medium">Custom warranty periods</span> in{" "}
|
||||
<Link href="/dashboard/products/settings" className="font-medium underline">
|
||||
Product Configuration
|
||||
</Link>{" "}
|
||||
to use these.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Default periods (always offered):</span>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((m) => (
|
||||
<Badge key={m} variant="outline" className="text-muted-foreground">
|
||||
{formatWarrantyPeriod(m)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && periods === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && periods !== null && periods.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No custom warranty periods yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && periods !== null && periods.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{periods.map((p) => (
|
||||
<TableRow key={p.warrantyPeriodId}>
|
||||
<TableCell className="px-3 py-3.5">{p.warrantyPeriodId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{formatWarrantyPeriod(p.months)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={p.status === "Active" ? "default" : "secondary"}>{p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(p.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={p.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}`}
|
||||
disabled={togglingId === p.warrantyPeriodId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{p.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={p.status === "Active" ? "destructive" : "success"}
|
||||
title={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}?`}
|
||||
description={
|
||||
p.status === "Active"
|
||||
? "It stops being offered on items. Items already using it keep their warranty period."
|
||||
: "It is offered on items again."
|
||||
}
|
||||
confirmLabel={p.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(p)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
|
||||
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
|
||||
// replaced by a locked field, and the server forces it too.
|
||||
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
|
||||
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
|
||||
// treated (and sent) as 0% — the server rejects anything else.
|
||||
const allowDiscounts = grnConfig?.allowDiscounts ?? true
|
||||
const allowVat = grnConfig?.allowVat ?? true
|
||||
function effectiveLine(l: DraftLine): DraftLine {
|
||||
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
|
||||
}
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
// Missing/failed config just means "pick a warehouse" — never block the form on it.
|
||||
grnConfigApi.get().then((r) => r.data).catch(() => null),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
.then(([wh, it, uo, ve, po, config]) => {
|
||||
setGrnConfig(config)
|
||||
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
setHeaderDiscountPct("")
|
||||
setHeaderVatPct("")
|
||||
setHeaderError(null)
|
||||
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
if (!nextPoId) {
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
|
||||
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map((l): DraftLine => {
|
||||
const draft: DraftLine = {
|
||||
@@ -357,16 +375,31 @@ export default function NewGrnPage() {
|
||||
try {
|
||||
const result = await itemsApi.scan(code)
|
||||
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
||||
addItemLine(result.itemId, scannedBatchNo)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setScanError(`No item or lot found for "${code}"`)
|
||||
} else {
|
||||
setScanError(errorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the scan box and the "Add item" search: bumps qty on an existing line for the same
|
||||
// item + batch, else fills the first blank row or appends a new line for it.
|
||||
function addItemLine(itemId: number, batchNo = "") {
|
||||
setLines((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
|
||||
(l) => l.itemId === itemId && l.batchNo.trim() === batchNo.trim()
|
||||
)
|
||||
if (existingIdx >= 0) {
|
||||
const next = [...prev]
|
||||
const merged: DraftLine = {
|
||||
...next[existingIdx],
|
||||
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
||||
batchNo: scannedBatchNo || next[existingIdx].batchNo,
|
||||
batchNo: batchNo || next[existingIdx].batchNo,
|
||||
}
|
||||
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||
return next
|
||||
@@ -377,9 +410,9 @@ export default function NewGrnPage() {
|
||||
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
||||
const draft: DraftLine = {
|
||||
...emptyLine(),
|
||||
itemId: result.itemId,
|
||||
itemId,
|
||||
qty: "1",
|
||||
batchNo: scannedBatchNo,
|
||||
batchNo,
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
}
|
||||
@@ -391,15 +424,6 @@ export default function NewGrnPage() {
|
||||
}
|
||||
return [...prev, filled]
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setScanError(`No item or lot found for "${code}"`)
|
||||
} else {
|
||||
setScanError(errorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
const itemOptions = useMemo<SuggestOption[]>(
|
||||
() =>
|
||||
(items ?? []).map((i) => {
|
||||
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return {
|
||||
value: String(i.itemId),
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
detail: detail || undefined,
|
||||
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
|
||||
badgeTone: "muted",
|
||||
}
|
||||
}),
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||
vatPct: allowVat ? line.vatPct : "0",
|
||||
warranty: item?.warranty ?? null,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||
holdStatus: l.holdStatus,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
@@ -690,6 +732,7 @@ export default function NewGrnPage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
{allowWarehouseSelection ? (
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
@@ -702,6 +745,15 @@ export default function NewGrnPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{(() => {
|
||||
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
|
||||
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -709,7 +761,9 @@ export default function NewGrnPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
{(allowDiscounts || allowVat) && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
{allowDiscounts && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
@@ -723,6 +777,8 @@ export default function NewGrnPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allowVat && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||
<Input
|
||||
@@ -736,10 +792,12 @@ export default function NewGrnPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
Setting a rate here applies it to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
|
||||
<ScanInput
|
||||
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
||||
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...emptyLine(),
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||
<Button
|
||||
type="button"
|
||||
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "lines" && (
|
||||
<>
|
||||
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
|
||||
the last row of the table, under the Item column (same layout as Sales). */}
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="w-full md:hidden"
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
{lines.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
|
||||
No items yet — start typing in "Add item" above, or scan a barcode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked editable cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -949,6 +997,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allowDiscounts && (
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
@@ -965,6 +1014,9 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
@@ -981,6 +1033,8 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -1131,6 +1173,7 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{allowDiscounts && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -1146,6 +1189,9 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -1161,9 +1207,11 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { salesDayEndApi } from "@/lib/api/sales-day-end"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
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"
|
||||
@@ -23,6 +32,51 @@ function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
type SalesDateRange = "All" | "Today" | "7d" | "30d"
|
||||
|
||||
const salesDateRanges: { value: SalesDateRange; label: string; days: number | null }[] = [
|
||||
{ value: "All", label: "All time", days: null },
|
||||
{ value: "Today", label: "Today", days: 1 },
|
||||
{ value: "7d", label: "7 days", days: 7 },
|
||||
{ value: "30d", label: "30 days", days: 30 },
|
||||
]
|
||||
|
||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||
function toLocalIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive range ending today ("7 days" is today plus the 6 days before it) — same as the Sales page. */
|
||||
function salesDateBounds(range: SalesDateRange): { from?: string; to?: string } {
|
||||
const days = salesDateRanges.find((r) => r.value === range)?.days
|
||||
if (!days) return {}
|
||||
const today = new Date()
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toLocalIsoDate(from), to: toLocalIsoDate(today) }
|
||||
}
|
||||
|
||||
/** Past its due date (`invoiceDate`) with money still owed — compared by calendar day, so an
|
||||
* invoice due today is not overdue. Same rule as the Sales page. */
|
||||
function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
if (row.status !== "Posted" || row.totals.balanceAmount <= 0) return false
|
||||
const due = new Date(row.invoiceDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
due.setHours(0, 0, 0, 0)
|
||||
return due < today
|
||||
}
|
||||
|
||||
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
type PaymentFilter = "All" | "Unpaid" | "Paid"
|
||||
|
||||
const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
||||
{ value: "All", label: "All payments" },
|
||||
{ value: "Unpaid", label: "Balance due" },
|
||||
{ value: "Paid", label: "Fully paid" },
|
||||
]
|
||||
|
||||
export default function SalesDayEndPage() {
|
||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
@@ -35,6 +89,49 @@ export default function SalesDayEndPage() {
|
||||
const [report, setReport] = useState<SalesDayEnd | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
|
||||
// business date above): a cashier (null follows the cashier picked above, "All" shows every
|
||||
// cashier's sales) and a date range.
|
||||
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
|
||||
const effectiveSalesCashier = salesCashier ?? cashierUserId
|
||||
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
|
||||
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
||||
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
||||
const [salesSearchInput, setSalesSearchInput] = useState("")
|
||||
const [salesQuery, setSalesQuery] = useState("")
|
||||
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
|
||||
const [salesPage, setSalesPage] = useState(1)
|
||||
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
|
||||
const [salesReload, setSalesReload] = useState(0)
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const salesKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize, salesReload].join("|")
|
||||
const [salesResult, setSalesResult] = useState<{
|
||||
key: string
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
pagination: PaginationMeta | null
|
||||
error: string | null
|
||||
} | null>(null)
|
||||
const current = salesResult?.key === salesKey ? salesResult : null
|
||||
const sales = current?.rows ?? null
|
||||
const salesPagination = current?.pagination ?? null
|
||||
const salesError = current?.error ?? null
|
||||
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
// Read through the loaded rows so the pop-up's Pay button follows payments.
|
||||
const viewRow = viewInvoice ? (sales?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||
|
||||
// Invoice payments are taken here, not on the Sales screens. One invoice at a time via Pay, or
|
||||
// several of one customer ticked together — the generic cash customer's are paid one at a time.
|
||||
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.list({ pageSize: 200 })
|
||||
@@ -62,6 +159,71 @@ export default function SalesDayEndPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cashierUserId, businessDate])
|
||||
|
||||
// Debounced search; a new term starts again from page 1.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setSalesQuery(salesSearchInput.trim())
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [salesSearchInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSalesCashier === null) return
|
||||
salesApi
|
||||
.listInvoices({
|
||||
page: salesPage,
|
||||
pageSize: salesPageSize,
|
||||
status: "Posted",
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
|
||||
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
|
||||
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
|
||||
|
||||
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
|
||||
function reloadSales() {
|
||||
setSelected(new Map())
|
||||
setSalesReload((n) => n + 1)
|
||||
}
|
||||
|
||||
// Any change to what the table lists goes back to page 1 and clears the ticks.
|
||||
function resetSalesView() {
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
function changeCashier(userId: number | null) {
|
||||
setCashierUserId(userId)
|
||||
setSalesCashier(null)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesCashier(value: number | "All") {
|
||||
setSalesCashier(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesDateRange(value: SalesDateRange) {
|
||||
setSalesDateRange(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPayment(value: PaymentFilter) {
|
||||
setSalesPayment(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPageSize(size: number) {
|
||||
setSalesPageSize(size)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
if (!cashierUserId) return
|
||||
setClosing(true)
|
||||
@@ -79,6 +241,26 @@ export default function SalesDayEndPage() {
|
||||
|
||||
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
|
||||
|
||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||
|
||||
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
|
||||
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||
if (selected.has(row.salesInvoiceId)) return null
|
||||
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleSelected(row: SalesInvoiceSummary) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||
else next.set(row.salesInvoiceId, row)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -96,7 +278,7 @@ export default function SalesDayEndPage() {
|
||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Cashier</Label>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
@@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
|
||||
<InvoiceSummaryPanel invoices={report.invoices} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SalesTable
|
||||
rows={sales}
|
||||
error={salesError}
|
||||
cashiers={cashiers}
|
||||
cashierFilter={effectiveSalesCashier}
|
||||
onCashierFilterChange={changeSalesCashier}
|
||||
dateRange={salesDateRange}
|
||||
onDateRangeChange={changeSalesDateRange}
|
||||
paymentFilter={salesPayment}
|
||||
onPaymentFilterChange={changeSalesPayment}
|
||||
search={salesSearchInput}
|
||||
onSearchChange={setSalesSearchInput}
|
||||
pagination={salesPagination}
|
||||
onPageChange={setSalesPage}
|
||||
onPageSizeChange={changeSalesPageSize}
|
||||
onView={setViewInvoice}
|
||||
onPay={(row) => {
|
||||
setPayInvoice(row)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
selected={selected}
|
||||
notSelectableReason={notSelectableReason}
|
||||
onToggle={toggleSelected}
|
||||
onClearSelection={() => setSelected(new Map())}
|
||||
onPaySelected={() => setBulkPayOpen(true)}
|
||||
/>
|
||||
|
||||
{/* The invoice pop-up (View action or row click). Its Pay button follows the reloaded row,
|
||||
and the sheet itself re-reads after a payment via refreshKey. */}
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== null}
|
||||
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||
refreshKey={salesReload}
|
||||
actions={
|
||||
viewRow && viewRow.totals.balanceAmount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayInvoice(viewRow)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* After a payment the table is re-read, so Paid / Remaining come from the server. */}
|
||||
<SalesInvoicePaymentDialog invoice={payInvoice} open={payOpen} onOpenChange={setPayOpen} onPaid={reloadSales} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={() => {}}
|
||||
onPaid={reloadSales}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One page of Approved (Posted) sales invoices. Every filter (cashier or all cashiers, date range,
|
||||
// payment status, search) is applied server-side, so the pager and counts cover all matches.
|
||||
function SalesTable({
|
||||
rows,
|
||||
error,
|
||||
cashiers,
|
||||
cashierFilter,
|
||||
onCashierFilterChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
paymentFilter,
|
||||
onPaymentFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onView,
|
||||
onPay,
|
||||
selected,
|
||||
notSelectableReason,
|
||||
onToggle,
|
||||
onClearSelection,
|
||||
onPaySelected,
|
||||
}: {
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
error: string | null
|
||||
cashiers: ManagedUser[]
|
||||
cashierFilter: number | "All" | null
|
||||
onCashierFilterChange: (value: number | "All") => void
|
||||
dateRange: SalesDateRange
|
||||
onDateRangeChange: (value: SalesDateRange) => void
|
||||
paymentFilter: PaymentFilter
|
||||
onPaymentFilterChange: (value: PaymentFilter) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
pagination: PaginationMeta | null
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onView: (row: SalesInvoiceSummary) => void
|
||||
onPay: (row: SalesInvoiceSummary) => void
|
||||
selected: Map<number, SalesInvoiceSummary>
|
||||
notSelectableReason: (row: SalesInvoiceSummary) => string | null
|
||||
onToggle: (row: SalesInvoiceSummary) => void
|
||||
onClearSelection: () => void
|
||||
onPaySelected: () => void
|
||||
}) {
|
||||
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
const reason = notSelectableReason(row)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={reason !== null}
|
||||
title={reason ?? "Select to pay together"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => onToggle(row)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// View opens the invoice pop-up; Pay shows while a balance is still due.
|
||||
function renderActions(row: SalesInvoiceSummary) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onView(row)}
|
||||
aria-label={`View ${row.invoiceNo}`}
|
||||
title="View invoice"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.totals.balanceAmount > 0 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPay(row)} aria-label={`Pay ${row.invoiceNo}`}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showCashier = cashierFilter === "All"
|
||||
const cashierNameById = new Map(cashiers.map((c) => [c.userId, c.displayName]))
|
||||
const visible = rows ?? []
|
||||
const filtered = paymentFilter !== "All" || search.trim().length > 0
|
||||
|
||||
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
|
||||
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
|
||||
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
|
||||
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Select<number | "All" | null>
|
||||
value={cashierFilter}
|
||||
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
|
||||
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
|
||||
<SelectValue placeholder="Cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All cashiers</SelectItem>
|
||||
{cashiers.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>{c.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<SalesDateRange>
|
||||
value={dateRange}
|
||||
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
|
||||
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salesDateRanges.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<PaymentFilter>
|
||||
value={paymentFilter}
|
||||
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
|
||||
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentFilters.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search invoice or customer"
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && <Skeleton className="h-32 w-full" />}
|
||||
|
||||
{!error && rows !== null && visible.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{filtered ? "No sales match these filters." : "No approved sales in this period."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onPaySelected}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visible.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked rows. */}
|
||||
<div className="flex flex-col gap-1.5 md:hidden">
|
||||
{visible.map((row) => (
|
||||
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
|
||||
{renderSelectBox(row)}
|
||||
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{row.invoiceNo}</span>
|
||||
<span className="block truncate text-muted-foreground">
|
||||
{row.customerSnapshotName}
|
||||
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
|
||||
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
</span>
|
||||
</button>
|
||||
{renderActions(row)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((row) => (
|
||||
<TableRow
|
||||
key={row.salesInvoiceId}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
onView(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
{showCashier && (
|
||||
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"px-3 py-3.5 text-right tabular-nums",
|
||||
row.status !== "Cancelled" && row.totals.balanceAmount > 0 && "font-semibold text-destructive"
|
||||
)}
|
||||
>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderActions(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
pageSizeOptions={SALES_PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoices counts every match; the amounts are for the rows on this page. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
|
||||
<Stat label="Net (this page)" value={netTotal} emphasize />
|
||||
<Stat label="Paid (this page)" value={paidTotal} />
|
||||
<Stat label="Remaining (this page)" value={balanceTotal} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -141,13 +141,8 @@ export default function NewFreeIssuePage() {
|
||||
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,
|
||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||
},
|
||||
])
|
||||
// No item pre-selected — the user searches for one.
|
||||
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
|
||||
await refreshRows()
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
@@ -167,7 +162,7 @@ export default function NewFreeIssuePage() {
|
||||
}
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
|
||||
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
|
||||
[items]
|
||||
)
|
||||
|
||||
@@ -369,12 +364,29 @@ export default function NewFreeIssuePage() {
|
||||
<FieldGroup key={line.key}>
|
||||
<Field>
|
||||
<FieldLabel>Item</FieldLabel>
|
||||
<Combobox
|
||||
items={itemOptions}
|
||||
value={line.itemId || null}
|
||||
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
|
||||
placeholder="Search item…"
|
||||
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
|
||||
{line.itemId ? (
|
||||
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
aria-label="Item"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>UOM</FieldLabel>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Printer, Save, Send, Undo2, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -13,9 +13,10 @@ import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
@@ -24,6 +25,7 @@ import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, Sales
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
@@ -56,6 +58,22 @@ const blankLine = (key: string): Line => ({
|
||||
parentLineId: null,
|
||||
})
|
||||
|
||||
/** Comparable form of the draft's editable fields (line keys excluded) — for "unsaved changes". */
|
||||
function draftSnapshot(customerId: number | null, warehouseId: number | null, invoiceType: SalesInvoiceType, lines: Line[]): string {
|
||||
return JSON.stringify({
|
||||
customerId,
|
||||
warehouseId,
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice ?? null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
|
||||
function statusWord(status: SalesInvoiceStatus): string {
|
||||
return status === "Posted" ? "approved" : status.toLowerCase()
|
||||
@@ -75,6 +93,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
// The draft as last loaded/saved — Save as Draft is enabled only when the form differs from it.
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null)
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
||||
@@ -85,6 +105,16 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
// Sales Configuration "Allow free items": off hides the draft editor's Free column.
|
||||
const [allowFreeItems, setAllowFreeItems] = useState(true)
|
||||
// "Allow discounts": off saves the draft's lines without a line discount (the server rejects one).
|
||||
const [allowDiscounts, setAllowDiscounts] = useState(true)
|
||||
// A saved default sales warehouse (or "Allow warehouse selection" off) replaces the draft
|
||||
// editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
|
||||
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
|
||||
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null)
|
||||
// The shared cash customer — offered on every branch's Bill To picker.
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) {
|
||||
@@ -93,15 +123,22 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
// Includes the cash customer: a cash sale's draft must still show (and keep) it in the picker.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
salesApi.getInvoice(invoiceId),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
salesConfigApi.get().then((r) => r.data).catch(() => null),
|
||||
])
|
||||
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes]) => {
|
||||
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes, config]) => {
|
||||
setAllowFreeItems(config?.allowFreeItems ?? true)
|
||||
setAllowDiscounts(config?.allowDiscounts ?? true)
|
||||
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
|
||||
setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
|
||||
setCashCustomerId(config?.cashCustomerId ?? null)
|
||||
setCustomers(customerRes.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
@@ -113,8 +150,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setInvoiceType(doc.data.invoiceType)
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
const loadedLines: Line[] = doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
@@ -130,7 +166,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId,
|
||||
}))
|
||||
)
|
||||
setLines(loadedLines)
|
||||
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [invoiceId, resolvedParams.id])
|
||||
@@ -168,8 +205,26 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
const isDraft = invoice?.status === "Draft"
|
||||
// Branch-wise Bill To picker: customers of the invoice's own branch, plus the shared cash customer
|
||||
// and whoever the draft is already billed to (so an older cross-branch draft still shows its customer).
|
||||
const branchCustomers = useMemo(
|
||||
() =>
|
||||
customers.filter(
|
||||
(c) =>
|
||||
!invoice ||
|
||||
c.branchId === invoice.branchId ||
|
||||
c.customerId === cashCustomerId ||
|
||||
c.customerCode === "CUST-CASH" ||
|
||||
c.customerId === invoice.customerId,
|
||||
),
|
||||
[customers, invoice, cashCustomerId],
|
||||
)
|
||||
const isDirty = savedSnapshot !== null && draftSnapshot(customerId, warehouseId, invoiceType, lines) !== savedSnapshot
|
||||
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
||||
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
|
||||
// With selection off, saving moves the draft onto the default sales warehouse (server-forced),
|
||||
// so that's the one the editor shows.
|
||||
const pinnedWarehouse = useMemo(() => warehouses.find((w) => w.warehouseId === defaultWarehouseId), [warehouses, defaultWarehouseId])
|
||||
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
||||
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
|
||||
|
||||
@@ -182,32 +237,52 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
// Type-ahead "Add item" options — nothing is listed until the user types.
|
||||
const itemOptions = useMemo(
|
||||
() =>
|
||||
items.map((i) => {
|
||||
const price = getSuggestedUnitPrice(items, i.itemId)
|
||||
return { value: String(i.itemId), label: `${i.sku} — ${i.name}`, hint: price !== null ? money.format(price) : undefined }
|
||||
}),
|
||||
[items],
|
||||
)
|
||||
|
||||
function itemLabel(itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
|
||||
}
|
||||
|
||||
// Picking an item already on the invoice bumps its qty instead of adding a duplicate line.
|
||||
function addItem(itemId: number) {
|
||||
setLines((prev) => {
|
||||
const idx = prev.findIndex((l) => l.itemId === itemId)
|
||||
if (idx >= 0) {
|
||||
const next = [...prev]
|
||||
next[idx] = { ...next[idx], qty: Number(next[idx].qty || 0) + 1 }
|
||||
return next
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ ...blankLine(`line-${Date.now()}`), itemId, warehouseId: warehouseId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId) },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return roundPrice(Number(line.unitPrice))
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!customerId || !warehouseId || !etag || !invoice) return
|
||||
/** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */
|
||||
async function save(quiet = false): Promise<boolean> {
|
||||
if (!customerId || !warehouseId || !etag || !invoice) return false
|
||||
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
|
||||
setError("Select item, UOM and warehouse for every line.")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
@@ -224,15 +299,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
itemId: Number(line.itemId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
discountValue: Number(line.discountValue),
|
||||
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
||||
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
|
||||
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
isFreeIssue: allowFreeItems && line.isFreeIssue,
|
||||
parentLineId: line.parentLineId || null,
|
||||
})),
|
||||
},
|
||||
@@ -240,25 +315,37 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
)
|
||||
setInvoice(updated.data)
|
||||
setEtag(updated.etag)
|
||||
toast.success("Invoice saved", updated.data.invoiceNo)
|
||||
setSavedSnapshot(draftSnapshot(customerId, warehouseId, invoiceType, lines))
|
||||
if (!quiet) {
|
||||
// Save as Draft is done with the invoice — back to the Sales list (drafts table).
|
||||
toast.success("Draft saved", updated.data.invoiceNo)
|
||||
router.push("/dashboard/sales/invoices")
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Approve = save the draft's current edits, then post it. Only an approved invoice takes payments.
|
||||
async function post() {
|
||||
if (!postingCheck?.canPost) {
|
||||
setError("Resolve stock shortages before posting this invoice.")
|
||||
setError("Resolve stock shortages before approving this invoice.")
|
||||
return
|
||||
}
|
||||
setBusy("post")
|
||||
if (isDirty && !(await save(true))) {
|
||||
setBusy(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
const posted = await salesApi.postInvoice(invoiceId)
|
||||
setInvoice(posted)
|
||||
toast.success("Invoice posted", posted.invoiceNo)
|
||||
toast.success("Invoice approved", posted.invoiceNo)
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
@@ -349,11 +436,67 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
|
||||
{/* A draft reopens in edit mode right here in the invoice body: header fields and lines are
|
||||
editable in place; Save as Draft / Approve invoice sit under Actions. */}
|
||||
{isDraft ? (
|
||||
<div className="grid gap-4 py-5 md:grid-cols-3 print:hidden">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</Label>
|
||||
<select
|
||||
value={customerId ? String(customerId) : ""}
|
||||
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select customer</option>
|
||||
{branchCustomers.map((c) => (
|
||||
<option key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</Label>
|
||||
{allowWarehouseSelection ? (
|
||||
<select
|
||||
value={warehouseId ? String(warehouseId) : ""}
|
||||
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select warehouse</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// Pinned to the default sales warehouse — shown, not editable.
|
||||
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invoice type</Label>
|
||||
<select
|
||||
value={invoiceType}
|
||||
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
|
||||
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Credit">Credit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("grid gap-6 py-5 md:grid-cols-2", isDraft && "hidden print:grid")}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||
<div className="text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
|
||||
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
@@ -362,26 +505,93 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="text-muted-foreground">Net payable</div>
|
||||
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDraft ? (
|
||||
<div className="print:hidden">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItem(Number(v))}
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="mb-3 w-full sm:max-w-md"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
<div className="overflow-x-auto border-y">
|
||||
<table className="w-full text-sm">
|
||||
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
|
||||
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.key} className="border-t border-border align-middle">
|
||||
<td className="min-w-64 px-4 py-3">
|
||||
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
|
||||
</td>
|
||||
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
|
||||
<td className="w-28 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||
aria-label="Qty"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
{allowFreeItems && (
|
||||
<td className="w-28 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
aria-label="Free qty"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="w-32 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
aria-label="Unit price"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-24 px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={lines.length === 1}
|
||||
aria-label="Remove line"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Totals below update when the draft is saved.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("overflow-x-auto border-y", isDraft && "hidden print:block")}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
|
||||
<tr>
|
||||
@@ -415,6 +625,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the lines, right-aligned — where an invoice's totals are read. The
|
||||
"highlight" layout shows Net payable as a banner up top instead. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="flex justify-end py-5">
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Net payable</div>
|
||||
<div className="border-t pt-2 text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||
<div className="py-5">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||
@@ -429,13 +661,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
{/* canPost with issues = Sales Configuration allows selling beyond stock: shown as a
|
||||
warning, posting still goes ahead and drives on-hand negative. */}
|
||||
{invoice.status === "Draft" && postingCheck && postingCheck.issues.length > 0 ? (
|
||||
<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="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 className="mt-1 text-sm">
|
||||
{postingCheck.canPost
|
||||
? "Selling beyond stock is allowed — posting will take these items' stock below zero."
|
||||
: "The invoice cannot be posted until every line has enough available stock in the selected warehouse."}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
@@ -481,18 +719,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="mt-6 border-t pt-5 print:hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Actions</h3>
|
||||
<span className="text-sm text-muted-foreground">{isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isDraft ? "Edit the draft above, then save as draft or approve. Payments open once approved." : "Only draft invoices are editable."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDraft ? (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={addLine} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted">
|
||||
<Plus className="size-4" /> Add line
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
|
||||
<button type="button" onClick={() => save()} disabled={!isDirty || saving || busy !== null} title={isDirty ? undefined : "No changes to save"} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving..." : "Save invoice"}
|
||||
{saving && busy === null ? "Saving..." : "Save as Draft"}
|
||||
</button>
|
||||
{postingCheck && !postingCheck.canPost ? (
|
||||
<Link
|
||||
@@ -505,153 +742,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
) : 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"}
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve invoice"}
|
||||
</button>
|
||||
<button type="button" onClick={cancel} disabled={busy !== null} className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-300 bg-rose-50 px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 disabled:opacity-50">
|
||||
<X className="size-4" />
|
||||
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Free</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.key} className="border-t border-border align-middle">
|
||||
<td className="px-4 py-3 min-w-64">
|
||||
<select
|
||||
value={line.itemId ? String(line.itemId) : ""}
|
||||
onChange={(e) => selectItem(line.key, Number(e.target.value))}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select item</option>
|
||||
{items.map((i) => (
|
||||
<option key={i.itemId} value={String(i.itemId)}>
|
||||
{i.sku} - {i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: 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-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>
|
||||
<td className="px-4 py-3 w-32">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={lines.length === 1}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Customer</Label>
|
||||
<select
|
||||
value={customerId ? String(customerId) : ""}
|
||||
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select customer</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Warehouse</Label>
|
||||
<select
|
||||
value={warehouseId ? String(warehouseId) : ""}
|
||||
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select warehouse</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Invoice type</Label>
|
||||
<select
|
||||
value={invoiceType}
|
||||
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="B2B">B2B</option>
|
||||
<option value="B2C">B2C</option>
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Credit">Credit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={post}
|
||||
disabled={!canPost}
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-black px-5 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"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user