Merge pull request 'product and sales fixers' (#93) from product-and-sales-fixers into production
Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
@@ -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? categoryId,
|
||||||
[FromQuery] int? subCategoryId,
|
[FromQuery] int? subCategoryId,
|
||||||
[FromQuery] int? brandId,
|
[FromQuery] int? brandId,
|
||||||
|
[FromQuery] TrackingMode? trackingMode,
|
||||||
|
[FromQuery] StockNature? stockNature,
|
||||||
|
[FromQuery] ItemStockFilter? stock,
|
||||||
CancellationToken ct)
|
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
|
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
/// 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);
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
SetETag(result.RowVersion);
|
SetETag(result.RowVersion);
|
||||||
return Ok(result.Value);
|
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,60 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ISalesInvoiceService _invoices;
|
private readonly ISalesInvoiceService _invoices;
|
||||||
private readonly ISalesInvoicePaymentService _payments;
|
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;
|
_invoices = invoices;
|
||||||
_payments = payments;
|
_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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
[HttpGet]
|
||||||
|
|||||||
@@ -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,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 BatchListsEnabled { get; set; } = true;
|
||||||
public bool WarrantyPeriodsEnabled { 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 DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
public int? UpdatedBy { 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>
|
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||||
public const int SystemUserId = 1;
|
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 int UserId { get; set; }
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
public string DisplayName { 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; }
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
|
|||||||
public sealed record ProductConfigDto(
|
public sealed record ProductConfigDto(
|
||||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||||
|
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||||
|
bool CustomWarrantyPeriodsEnabled,
|
||||||
DateTime? UpdatedAt, int? UpdatedBy);
|
DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
|
|||||||
[Required] public bool? SerialNumbersEnabled { get; set; }
|
[Required] public bool? SerialNumbersEnabled { get; set; }
|
||||||
[Required] public bool? BatchListsEnabled { get; set; }
|
[Required] public bool? BatchListsEnabled { get; set; }
|
||||||
[Required] public bool? WarrantyPeriodsEnabled { 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; }
|
||||||
|
}
|
||||||
@@ -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,
|
decimal StockQty, decimal StockValue, bool IsLowStock,
|
||||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
||||||
decimal SalesQtyLast30Days);
|
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; }
|
||||||
|
}
|
||||||
@@ -44,6 +44,14 @@ public sealed record SalesInvoicePostingCheckDto(
|
|||||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||||
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
|
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);
|
||||||
|
|
||||||
public sealed class CreateSalesInvoiceLineRequest
|
public sealed class CreateSalesInvoiceLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[Required] public int ItemId { get; set; }
|
||||||
|
|||||||
@@ -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.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
|
||||||
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
|
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
|
||||||
builder.Property(c => c.WarrantyPeriodsEnabled).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).
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
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,7 @@ public static class DataSeeder
|
|||||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
dirty |= await SeedSalesStockAsync(db, ct);
|
dirty |= await SeedSalesStockAsync(db, ct);
|
||||||
@@ -106,7 +107,10 @@ public static class DataSeeder
|
|||||||
SellingPricesEnabled = true,
|
SellingPricesEnabled = true,
|
||||||
SerialNumbersEnabled = true,
|
SerialNumbersEnabled = true,
|
||||||
BatchListsEnabled = true,
|
BatchListsEnabled = true,
|
||||||
WarrantyPeriodsEnabled = true
|
WarrantyPeriodsEnabled = true,
|
||||||
|
CustomVariantsEnabled = false,
|
||||||
|
ItemImagesEnabled = false,
|
||||||
|
CustomWarrantyPeriodsEnabled = false
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -128,6 +132,18 @@ public static class DataSeeder
|
|||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
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<TaxRate> TaxRates => Set<TaxRate>();
|
||||||
public DbSet<Item> Items => Set<Item>();
|
public DbSet<Item> Items => Set<Item>();
|
||||||
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
|
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<Vendor> Vendors => Set<Vendor>();
|
||||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||||
public DbSet<Bin> Bins => Set<Bin>();
|
public DbSet<Bin> Bins => Set<Bin>();
|
||||||
@@ -43,6 +45,7 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||||
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
||||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||||
|
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1831,6 +1831,56 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("items", (string)null);
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("ReorderId")
|
b.Property<int>("ReorderId")
|
||||||
@@ -3252,6 +3302,21 @@ namespace ERPCore.Migrations
|
|||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasDefaultValue(true);
|
.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")
|
b.Property<bool>("ItemTypesEnabled")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
@@ -4337,6 +4402,68 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("hr_salary_components", (string)null);
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SalesDayEndId")
|
b.Property<int>("SalesDayEndId")
|
||||||
@@ -6593,6 +6720,43 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("warehouses", (string)null);
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("WorkShiftId")
|
b.Property<int>("WorkShiftId")
|
||||||
@@ -7145,6 +7309,17 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("TaxRate");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -7835,6 +8010,30 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Uom");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
|||||||
// Master-data services (docs/11 §2)
|
// Master-data services (docs/11 §2)
|
||||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||||
builder.Services.AddScoped<IItemService, ItemService>();
|
builder.Services.AddScoped<IItemService, ItemService>();
|
||||||
|
builder.Services.AddScoped<IItemImageService, ItemImageService>();
|
||||||
|
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
|
||||||
builder.Services.AddScoped<IUomService, UomService>();
|
builder.Services.AddScoped<IUomService, UomService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
@@ -95,6 +97,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
|||||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||||
|
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||||
|
|||||||
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
|
|||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
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;
|
user.Status = EntityStatus.Inactive;
|
||||||
await _uow.SaveChangesAsync(ct);
|
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
|
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ public interface IFifoCostingService
|
|||||||
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
|
/// 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
|
/// <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
|
/// 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>
|
/// </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(
|
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>
|
/// <summary>
|
||||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||||
|
|||||||
@@ -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>
|
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||||
|
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, 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>> GetAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
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,
|
bool requireCashierUser,
|
||||||
CancellationToken ct = default);
|
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(
|
Task ValidateSalesLineAsync(
|
||||||
int headerWarehouseId,
|
int headerWarehouseId,
|
||||||
int lineItemId,
|
int lineItemId,
|
||||||
|
|||||||
@@ -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(
|
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||||
|
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _items.Query().AsNoTracking();
|
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 (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
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 (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 total = await q.CountAsync(ct);
|
||||||
var page = await q.OrderBy(i => i.Sku)
|
var page = await q.OrderBy(i => i.Sku)
|
||||||
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
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()
|
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)
|
.GroupBy(l => l.ItemId)
|
||||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
.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);
|
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||||
|
|
||||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
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 })
|
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||||
.ToListAsync(ct))
|
.ToListAsync(ct))
|
||||||
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
|
|||||||
|
|
||||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(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
|
var item = new Item
|
||||||
{
|
{
|
||||||
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
|
|||||||
|
|
||||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(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.Sku = request.Sku.Trim();
|
||||||
item.Name = request.Name.Trim();
|
item.Name = request.Name.Trim();
|
||||||
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A warranty-tracked item must declare a coverage period (one of
|
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
|
||||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
|
||||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
/// 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>
|
/// </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 (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(
|
throw new DomainException(
|
||||||
ErrorCodes.Validation,
|
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;
|
return warrantyPeriodMonths;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
|
|||||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.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.UpdatedAt = DateTime.UtcNow;
|
||||||
config.UpdatedBy = _currentUser.AuditUserId;
|
config.UpdatedBy = _currentUser.AuditUserId;
|
||||||
|
|
||||||
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
|
|||||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
||||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||||
|
c.CustomVariantsEnabled, c.ItemImagesEnabled,
|
||||||
|
c.CustomWarrantyPeriodsEnabled,
|
||||||
c.UpdatedAt, c.UpdatedBy);
|
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<Item> _items;
|
||||||
private readonly IRepository<TaxRate> _taxRates;
|
private readonly IRepository<TaxRate> _taxRates;
|
||||||
private readonly ISalesPricingService _pricing;
|
private readonly ISalesPricingService _pricing;
|
||||||
|
private readonly IRepository<SalesConfig> _salesConfig;
|
||||||
|
|
||||||
public SalesDomainService(
|
public SalesDomainService(
|
||||||
IRepository<Customer> customers,
|
IRepository<Customer> customers,
|
||||||
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
|
|||||||
IRepository<User> users,
|
IRepository<User> users,
|
||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IRepository<TaxRate> taxRates,
|
IRepository<TaxRate> taxRates,
|
||||||
ISalesPricingService pricing)
|
ISalesPricingService pricing,
|
||||||
|
IRepository<SalesConfig> salesConfig)
|
||||||
{
|
{
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_taxRates = taxRates;
|
_taxRates = taxRates;
|
||||||
_pricing = pricing;
|
_pricing = pricing;
|
||||||
|
_salesConfig = salesConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
|
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))
|
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
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))
|
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||||
if (requireCashierUser)
|
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(
|
public async Task ValidateSalesLineAsync(
|
||||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
|
|
||||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
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);
|
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
{
|
{
|
||||||
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
|
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);
|
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||||
invoice.CustomerId = request.CustomerId;
|
invoice.CustomerId = request.CustomerId;
|
||||||
invoice.WarehouseId = request.WarehouseId;
|
invoice.WarehouseId = request.WarehouseId;
|
||||||
@@ -157,9 +159,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
return _mapping.MapInvoice(invoice);
|
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)
|
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var lines = new List<SalesInvoiceLine>();
|
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)
|
foreach (var r in requests)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
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)
|
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));
|
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)
|
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));
|
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)
|
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
||||||
throw new ConflictException(ensureDraftMessage(doc));
|
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 =>
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
var totalCogs = 0m;
|
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
|
// 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.
|
// 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);
|
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||||
totalCogs += cost * line.Qty;
|
totalCogs += cost * line.Qty;
|
||||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
|
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
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)
|
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);
|
$"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).
|
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
|
||||||
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
|
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
|
||||||
// sits quarantined until a Purchase Return consumes it directly (see
|
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
|
||||||
// PurchaseReturnService), never through this general-issue path. No LINQ is
|
// Return consumes it directly (see PurchaseReturnService), never through this
|
||||||
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
|
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
|
||||||
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
|
// reaches the database intact.
|
||||||
// parameter — without it a null batch filter fails with 42P18.
|
var locked = await _db.StockLayers
|
||||||
var layers = await _db.StockLayers
|
|
||||||
.FromSqlInterpolated($"""
|
.FromSqlInterpolated($"""
|
||||||
SELECT * FROM stock_layers sl
|
SELECT * FROM stock_layers sl
|
||||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
|
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
|
||||||
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
|
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 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"
|
ORDER BY sl."ReceiptDate", sl."LayerId"
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""")
|
""")
|
||||||
.ToListAsync(ct);
|
.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);
|
var available = layers.Sum(l => l.QtyRemaining);
|
||||||
if (available < qtyBase)
|
if (available < qtyBase && !allowShortfall)
|
||||||
{
|
{
|
||||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
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));
|
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;
|
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(
|
public async Task<StockLedger> PostLedgerAsync(
|
||||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
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)
|
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var open = await _layers.Query().AsNoTracking()
|
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)
|
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||||
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
|
|
||||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
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))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
{
|
{
|
||||||
var term = query.Q.Trim();
|
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);
|
||||||
|
}
|
||||||
@@ -519,7 +519,7 @@ export default function EmployeesPage() {
|
|||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title="This employee has login access"
|
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"
|
confirmLabel="Delete User Access"
|
||||||
cancelLabel="Keep, but Deactivate"
|
cancelLabel="Keep, but Deactivate"
|
||||||
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
|
|||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
@@ -35,6 +36,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import LineChart from "@/components/ui/line-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
import BarChart from "@/components/ui/bar-chart"
|
||||||
|
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||||
|
|
||||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||||
@@ -115,6 +117,8 @@ export default function ItemDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [imagesEnabled, setImagesEnabled] = useState(false)
|
||||||
|
const [imagesVersion, setImagesVersion] = useState(0)
|
||||||
|
|
||||||
function applyItem(data: Item) {
|
function applyItem(data: Item) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
@@ -162,6 +166,7 @@ export default function ItemDetailPage() {
|
|||||||
setTaxRates(tr.items)
|
setTaxRates(tr.items)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [itemId])
|
}, [itemId])
|
||||||
|
|
||||||
@@ -254,6 +259,7 @@ export default function ItemDetailPage() {
|
|||||||
function closeEdit() {
|
function closeEdit() {
|
||||||
if (item) applyItem(item)
|
if (item) applyItem(item)
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
|
setImagesVersion((v) => v + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -282,6 +288,7 @@ export default function ItemDetailPage() {
|
|||||||
applyItem(result.data)
|
applyItem(result.data)
|
||||||
setEtag(result.etag)
|
setEtag(result.etag)
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
|
setImagesVersion((v) => v + 1)
|
||||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as { code?: string })?.code
|
const code = (err as { code?: string })?.code
|
||||||
@@ -408,6 +415,10 @@ export default function ItemDetailPage() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
<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} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<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 flex-col gap-3 rounded-xl border p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -828,6 +839,7 @@ export default function ItemDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,375 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Plus, Ruler, Tag } from "lucide-react"
|
|
||||||
|
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
export default function BrandsRedirectPage() {
|
||||||
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() {
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<BrandsPageContent />
|
<BrandsRedirect />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrandsPageContent() {
|
function BrandsRedirect() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||||
const [tab, setTab] = useState<Tab>(initialTab)
|
|
||||||
|
|
||||||
function selectTab(next: Tab) {
|
useEffect(() => {
|
||||||
setTab(next)
|
router.replace(`/dashboard/products/categories?tab=${tab}`)
|
||||||
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
}, [router, tab])
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,484 +1,76 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import Link from "next/link"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
|
||||||
|
|
||||||
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 { 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 { BrandPanel } from "@/components/products/BrandPanel"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { CategoryPanel } from "@/components/products/CategoryPanel"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { UomPanel } from "@/components/products/UomPanel"
|
||||||
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"
|
|
||||||
|
|
||||||
type SortOrder = "asc" | "desc"
|
type Tab = "category" | "brand" | "uom"
|
||||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
|
||||||
|
|
||||||
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 BASE_HREF = "/dashboard/products/categories"
|
||||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
function parseTab(value: string | null): Tab {
|
||||||
const [search, setSearch] = useState("")
|
return value === "brand" || value === "uom" ? value : "category"
|
||||||
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)
|
* Category, Brand and UOM masters on one page as tabs. The old /brands and /uoms
|
||||||
const [name, setName] = useState("")
|
* routes redirect here with `?tab=` so existing links/bookmarks keep working.
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
*/
|
||||||
const [submitting, setSubmitting] = useState(false)
|
export default function ProductMastersPage() {
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
return (
|
||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
<Suspense>
|
||||||
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
<ProductMastersPageContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function ProductMastersPageContent() {
|
||||||
productConfig().then(setConfig).catch(() => setConfig(null))
|
const router = useRouter()
|
||||||
}, [])
|
const searchParams = useSearchParams()
|
||||||
|
const [tab, setTab] = useState<Tab>(parseTab(searchParams.get("tab")))
|
||||||
|
|
||||||
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
function selectTab(next: Tab) {
|
||||||
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
setTab(next)
|
||||||
// is needed) once the feature is on and the page's categories are known.
|
router.replace(next === "category" ? BASE_HREF : `${BASE_HREF}?tab=${next}`, { scroll: false })
|
||||||
useEffect(() => {
|
|
||||||
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
|
||||||
setSubCategoryCounts({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
).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])
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold text-foreground">Category/Brand/UOM</h1>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
<p className="text-base text-muted-foreground">Manage item categories, brands and units of measure.</p>
|
||||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||||
<div className="relative flex-1 basis-0">
|
{TABS.map((t) => (
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
<button
|
||||||
<Input
|
key={t.value}
|
||||||
value={searchInput}
|
type="button"
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onClick={() => selectTab(t.value)}
|
||||||
placeholder="Search categories…"
|
className={cn(
|
||||||
className="h-14 w-full pl-11 text-base"
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||||
aria-label="Search categories"
|
tab === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
/>
|
)}
|
||||||
</div>
|
>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
{t.label}
|
||||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{tab === "category" && <CategoryPanel />}
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
{tab === "brand" && <BrandPanel />}
|
||||||
)}
|
{tab === "uom" && <UomPanel />}
|
||||||
|
|
||||||
{!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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
order,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
active: boolean
|
|
||||||
order: SortOrder
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
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" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function ItemTypesPage() {
|
|||||||
} else {
|
} else {
|
||||||
await itemTypesApi.create({ name, isMeasurable })
|
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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setIsMeasurable(false)
|
setIsMeasurable(false)
|
||||||
@@ -90,7 +90,7 @@ export default function ItemTypesPage() {
|
|||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors({ name: errorMessage(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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ export default function ItemTypesPage() {
|
|||||||
setTogglingId(itemType.itemTypeId)
|
setTogglingId(itemType.itemTypeId)
|
||||||
try {
|
try {
|
||||||
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
|
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()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<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">
|
<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.
|
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.
|
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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<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">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
|
<DialogTitle>{editing ? "Edit item variant" : "New item variant"}</DialogTitle>
|
||||||
<DialogDescription>Give the item type a name.</DialogDescription>
|
<DialogDescription>Give the item variant a name.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
@@ -188,7 +188,7 @@ export default function ItemTypesPage() {
|
|||||||
{!error && itemTypes !== null && itemTypes.length === 0 && (
|
{!error && itemTypes !== null && itemTypes.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus, X } from "lucide-react"
|
import { ImagePlus, Plus, X } from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { itemTypesApi } from "@/lib/api/item-types"
|
import { itemTypesApi } from "@/lib/api/item-types"
|
||||||
import { productConfig } from "@/lib/api/product-config"
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
|
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -17,9 +18,11 @@ import {
|
|||||||
contentPairErrors,
|
contentPairErrors,
|
||||||
validateVariantContent,
|
validateVariantContent,
|
||||||
validateVariantItemForm,
|
validateVariantItemForm,
|
||||||
|
validateItemTypeName,
|
||||||
validateVariantPrices,
|
validateVariantPrices,
|
||||||
} from "@/lib/validations/master-data"
|
} from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
|
||||||
import {
|
import {
|
||||||
Brand,
|
Brand,
|
||||||
Category,
|
Category,
|
||||||
@@ -28,7 +31,6 @@ import {
|
|||||||
ProductConfig,
|
ProductConfig,
|
||||||
StockNature,
|
StockNature,
|
||||||
SubCategory,
|
SubCategory,
|
||||||
WARRANTY_PERIOD_MONTHS_OPTIONS,
|
|
||||||
Warranty,
|
Warranty,
|
||||||
WarrantyPeriodMonths,
|
WarrantyPeriodMonths,
|
||||||
} from "@/types/master-data"
|
} from "@/types/master-data"
|
||||||
@@ -43,10 +45,24 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
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 { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
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 {
|
function skuSegment(text: string, maxLen: number): string {
|
||||||
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
|
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.
|
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
||||||
const [contentQty, setContentQty] = useState("")
|
const [contentQty, setContentQty] = useState("")
|
||||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
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 [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||||
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
|
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
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.
|
// submit, without having to remove and re-add the whole value that produced it.
|
||||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
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);
|
// 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;
|
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
|
||||||
// each row keeps its own flat price in `pricesByKey`.
|
// 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.
|
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
|
||||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
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 [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
@@ -169,6 +213,11 @@ export default function NewItemPage() {
|
|||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.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
|
// Subcategories are their own resource now — fetched per category rather than filtered
|
||||||
@@ -213,6 +262,22 @@ export default function NewItemPage() {
|
|||||||
config?.sellingPricesEnabled,
|
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 ?? ""
|
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||||
/**
|
/**
|
||||||
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
|
* 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
|
* 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.
|
* 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 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)
|
const qty = Number(raw)
|
||||||
|
|
||||||
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
||||||
@@ -416,6 +575,7 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
let created = 0
|
let created = 0
|
||||||
|
const createdIds: number[] = []
|
||||||
try {
|
try {
|
||||||
const effectiveTrackingMode =
|
const effectiveTrackingMode =
|
||||||
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||||
@@ -430,7 +590,7 @@ export default function NewItemPage() {
|
|||||||
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
|
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const content = contentForVariant(variant)
|
const content = contentForVariant(variant)
|
||||||
await itemsApi.create({
|
const { data: createdItem } = await itemsApi.create({
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
|
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
|
||||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||||
@@ -463,8 +623,24 @@ export default function NewItemPage() {
|
|||||||
contentUnit: content?.unit ?? null,
|
contentUnit: content?.unit ?? null,
|
||||||
})
|
})
|
||||||
created += 1
|
created += 1
|
||||||
|
createdIds.push(createdItem.itemId)
|
||||||
}
|
}
|
||||||
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
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")
|
router.push("/dashboard/products")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
// 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 className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -502,7 +678,7 @@ export default function NewItemPage() {
|
|||||||
{!loading && baseUomId === null && (
|
{!loading && baseUomId === null && (
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
<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 —{" "}
|
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
|
create one first
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
@@ -631,6 +807,32 @@ export default function NewItemPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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
|
{/* 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.
|
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
|
Hidden once a measurement dimension is in play, because that dimension then
|
||||||
@@ -822,19 +1024,116 @@ export default function NewItemPage() {
|
|||||||
<Select<WarrantyPeriodMonths>
|
<Select<WarrantyPeriodMonths>
|
||||||
value={warrantyPeriodMonths}
|
value={warrantyPeriodMonths}
|
||||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
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" />
|
<SelectValue placeholder="Select period" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
{warrantySelectGroups.length === 1
|
||||||
<SelectItem key={months} value={months} className="text-base">
|
? warrantySelectGroups[0].months.map((months) => (
|
||||||
{months} months
|
<SelectItem key={months} value={months} className="text-base">
|
||||||
</SelectItem>
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -842,14 +1141,68 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</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
|
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||||
item-type reference), so this section IS the enforcement. */}
|
item-type reference), so this section IS the enforcement. */}
|
||||||
{config?.itemTypesEnabled && (
|
{config?.itemTypesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
|
{/* 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
|
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">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Selling the same product in several sizes (500 ml, 1 L)?{" "}
|
Selling the same product in several sizes (500 ml, 1 L)?{" "}
|
||||||
<Link href="/dashboard/products/item-types" className="font-medium underline">
|
<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>{" "}
|
</Link>{" "}
|
||||||
and each size will capture its own number and unit, giving every variant its own content size.
|
and each size will capture its own number and unit, giving every variant its own content size.
|
||||||
</p>
|
</p>
|
||||||
@@ -886,13 +1239,83 @@ export default function NewItemPage() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-base font-medium">{t.name}</span>
|
<span className="text-base font-medium">{t.name}</span>
|
||||||
{t.isMeasurable && (
|
{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>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{config?.customVariantsEnabled && !customOpen && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Custom variant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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
|
<FieldError
|
||||||
errors={[
|
errors={[
|
||||||
errors.variants ? { message: errors.variants } : undefined,
|
errors.variants ? { message: errors.variants } : undefined,
|
||||||
@@ -930,22 +1353,32 @@ export default function NewItemPage() {
|
|||||||
aria-invalid={!!valueErrors[t.itemTypeId]}
|
aria-invalid={!!valueErrors[t.itemTypeId]}
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
/>
|
/>
|
||||||
<Select<MeasureUnit | null>
|
{measurementUnit ? (
|
||||||
value={unitByCategory[t.itemTypeId] ?? null}
|
// Fixed by the form-level Measurement unit — every size is in that unit.
|
||||||
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
|
<div
|
||||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
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"
|
||||||
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
|
>
|
||||||
<SelectValue placeholder="Unit" />
|
{UNIT_LABEL[measurementUnit]}
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
) : (
|
||||||
{CONTENT_UNITS.map((u) => (
|
<Select<MeasureUnit | null>
|
||||||
<SelectItem key={u} value={u} className="text-base">
|
value={unitByCategory[t.itemTypeId] ?? null}
|
||||||
{UNIT_LABEL[u]}
|
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
|
||||||
</SelectItem>
|
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||||
))}
|
>
|
||||||
</SelectContent>
|
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
|
||||||
</Select>
|
<SelectValue placeholder="Unit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONTENT_UNITS.map((u) => (
|
||||||
|
<SelectItem key={u} value={u} className="text-base">
|
||||||
|
{UNIT_LABEL[u]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add
|
Add
|
||||||
|
|||||||
@@ -10,15 +10,14 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
|
ListFilter,
|
||||||
ListTree,
|
ListTree,
|
||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Ruler,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { ItemStockFilter, itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { productConfig } from "@/lib/api/product-config"
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
@@ -27,13 +26,13 @@ import { downloadCsv } from "@/lib/csv-export"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { ColumnsMenu } from "@/components/ui/columns-menu"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { FilterBar } from "@/components/ui/filter-bar"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -45,12 +44,23 @@ type SortOrder = "asc" | "desc"
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
|
||||||
const COLUMN_OPTIONS = [
|
const STOCK_OPTIONS: { label: string; value: ItemStockFilter | "All" }[] = [
|
||||||
{ key: "categoryBrand", label: "Category / Brand" },
|
{ label: "Any stock level", value: "All" },
|
||||||
{ key: "stockValue", label: "Stock Value" },
|
{ label: "In stock", value: "InStock" },
|
||||||
{ key: "lastPurchase", label: "Last Purchase" },
|
{ label: "Out of stock", value: "OutOfStock" },
|
||||||
{ key: "sales30d", label: "Sales (30d)" },
|
{ label: "Low stock", value: "LowStock" },
|
||||||
{ key: "status", label: "Status" },
|
]
|
||||||
|
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) {
|
function formatMoney(n: number) {
|
||||||
@@ -70,6 +80,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() {
|
export default function ItemsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
@@ -84,6 +128,12 @@ export default function ItemsPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [categoryId, setCategoryId] = useState<number | "All">("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 [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
@@ -91,7 +141,6 @@ export default function ItemsPage() {
|
|||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("sku")
|
const [sortKey, setSortKey] = useState<SortKey>("sku")
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
@@ -100,7 +149,7 @@ export default function ItemsPage() {
|
|||||||
|
|
||||||
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
|
// 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.
|
// 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)
|
const [prevResetKey, setPrevResetKey] = useState(resetKey)
|
||||||
if (prevResetKey !== resetKey) {
|
if (prevResetKey !== resetKey) {
|
||||||
setPrevResetKey(resetKey)
|
setPrevResetKey(resetKey)
|
||||||
@@ -118,6 +167,11 @@ export default function ItemsPage() {
|
|||||||
q: query || undefined,
|
q: query || undefined,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
categoryId: categoryId === "All" ? undefined : categoryId,
|
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) => {
|
.then((res) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -135,7 +189,23 @@ export default function ItemsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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() {
|
function showMore() {
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
@@ -156,11 +226,21 @@ export default function ItemsPage() {
|
|||||||
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
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 changeCategory(next: number | "All") {
|
||||||
function toggleColumn(key: string, next: boolean) {
|
setCategoryId(next)
|
||||||
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
|
setSubCategoryId("All")
|
||||||
|
setSubCategories([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdvanced() {
|
||||||
|
setSubCategoryId("All")
|
||||||
|
setBrandId("All")
|
||||||
|
setStock("All")
|
||||||
|
setTrackingMode("All")
|
||||||
|
setStockNature("All")
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
function toggleSort(key: SortKey) {
|
||||||
@@ -236,14 +316,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>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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" }))}>
|
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
<ListTree className="size-5" />
|
<ListTree className="size-5" />
|
||||||
Categories
|
Category/Brand/UOM
|
||||||
</Link>
|
</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" }))}>
|
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Item
|
New Item
|
||||||
@@ -251,50 +331,85 @@ export default function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
{/* Same filter bar as the Sales list, but search on the left and every filter
|
||||||
<div className="relative flex-1 basis-0">
|
(category, status, "Advanced" popover) grouped on the right. */}
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
<FilterBar
|
||||||
<Input
|
searchFirst
|
||||||
value={searchInput}
|
search={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onSearchChange={setSearchInput}
|
||||||
placeholder="Search SKU or name…"
|
searchPlaceholder="Search SKU or name…"
|
||||||
className="h-14 w-full pl-11 text-base"
|
searchAriaLabel="Search items"
|
||||||
aria-label="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) => changeCategory(v ?? "All")}
|
||||||
|
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
|
||||||
|
>
|
||||||
|
<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">All categories</SelectItem>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<SelectItem key={c.categoryId} value={c.categoryId}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<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">All statuses</SelectItem>
|
||||||
|
<SelectItem value="Active">Active</SelectItem>
|
||||||
|
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Select<number | "All">
|
</FilterBar>
|
||||||
value={categoryId}
|
|
||||||
onValueChange={(v) => setCategoryId(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">
|
|
||||||
<SelectValue placeholder="All categories" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="All" className="text-base">All categories</SelectItem>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
|
||||||
{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">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
@@ -355,61 +470,51 @@ export default function ItemsPage() {
|
|||||||
<p className="text-sm text-muted-foreground">{item.sku}</p>
|
<p className="text-sm text-muted-foreground">{item.sku}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCol("status") && (
|
<Badge
|
||||||
<Badge
|
variant="outline"
|
||||||
variant="outline"
|
className={cn(
|
||||||
className={cn(
|
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{item.status}
|
||||||
{item.status}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCol("categoryBrand") && (
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
{categoryName(item.categoryId)}
|
||||||
{categoryName(item.categoryId)}
|
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
|
||||||
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||||
{showCol("stockValue") && (
|
<div>
|
||||||
<div>
|
<p className="text-muted-foreground">Stock Value</p>
|
||||||
<p className="text-muted-foreground">Stock Value</p>
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className="font-medium text-foreground">{item.stockQty.toLocaleString()} on hand</span>
|
||||||
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
|
{item.isLowStock && (
|
||||||
{item.isLowStock && (
|
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||||
<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" />
|
||||||
<AlertTriangle className="size-3" />
|
Low
|
||||||
Low
|
</Badge>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</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 ? (
|
|
||||||
<>
|
|
||||||
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-foreground">—</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Sales (30d)</p>
|
||||||
|
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Last Purchase</p>
|
||||||
|
{item.lastPurchaseDate ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -440,11 +545,11 @@ export default function ItemsPage() {
|
|||||||
</TableHead>
|
</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="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>
|
<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>}
|
<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>}
|
<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>}
|
<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>}
|
<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">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -474,52 +579,44 @@ export default function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||||
{showCol("categoryBrand") && (
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<p className="text-foreground">{categoryName(item.categoryId)}</p>
|
||||||
<p className="text-foreground">{categoryName(item.categoryId)}</p>
|
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
|
||||||
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-3 py-3.5">
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{showCol("stockValue") && (
|
<span className="font-medium">{item.stockQty.toLocaleString()} on hand</span>
|
||||||
<TableCell className="px-3 py-3.5">
|
{item.isLowStock && (
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||||
<span className="font-medium">{formatMoney(item.stockValue)}</span>
|
<AlertTriangle className="size-3" />
|
||||||
{item.isLowStock && (
|
Low
|
||||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
</Badge>
|
||||||
<AlertTriangle className="size-3" />
|
|
||||||
Low
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{showCol("lastPurchase") && (
|
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
|
||||||
{item.lastPurchaseDate ? (
|
|
||||||
<>
|
|
||||||
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
|
||||||
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
)}
|
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||||
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
|
</TableCell>
|
||||||
{showCol("status") && (
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
<TableCell className="px-3 py-3.5">
|
{item.lastPurchaseDate ? (
|
||||||
<Badge
|
<>
|
||||||
variant="outline"
|
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
|
||||||
className={cn(
|
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
</>
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
) : (
|
||||||
)}
|
"—"
|
||||||
>
|
)}
|
||||||
{item.status}
|
</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-3 py-3.5">
|
||||||
)}
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { ReactNode, useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { Package } from "lucide-react"
|
import { Package } from "lucide-react"
|
||||||
|
|
||||||
import { productConfigApi } from "@/lib/api/product-config"
|
import { productConfigApi } from "@/lib/api/product-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
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 { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
@@ -37,18 +38,10 @@ export default function ProductSettingsPage() {
|
|||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
|
|
||||||
async function toggle(flag: keyof Pick<
|
/** PUT is a full replacement, so every save sends the whole current config plus `patch`. */
|
||||||
ProductConfig,
|
async function save(key: keyof ProductConfig, patch: Partial<UpdateProductConfigRequest>, message: string): Promise<boolean> {
|
||||||
| "subcategoriesEnabled"
|
if (!config) return false
|
||||||
| "brandsEnabled"
|
setSaving(key)
|
||||||
| "itemTypesEnabled"
|
|
||||||
| "sellingPricesEnabled"
|
|
||||||
| "serialNumbersEnabled"
|
|
||||||
| "batchListsEnabled"
|
|
||||||
| "warrantyPeriodsEnabled"
|
|
||||||
>, next: boolean) {
|
|
||||||
if (!config) return
|
|
||||||
setSaving(flag)
|
|
||||||
try {
|
try {
|
||||||
const res = await productConfigApi.update(
|
const res = await productConfigApi.update(
|
||||||
{
|
{
|
||||||
@@ -59,21 +52,30 @@ export default function ProductSettingsPage() {
|
|||||||
serialNumbersEnabled: config.serialNumbersEnabled,
|
serialNumbersEnabled: config.serialNumbersEnabled,
|
||||||
batchListsEnabled: config.batchListsEnabled,
|
batchListsEnabled: config.batchListsEnabled,
|
||||||
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
|
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
|
||||||
[flag]: next,
|
customVariantsEnabled: config.customVariantsEnabled,
|
||||||
|
itemImagesEnabled: config.itemImagesEnabled,
|
||||||
|
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
|
||||||
|
...patch,
|
||||||
},
|
},
|
||||||
etag ?? "",
|
etag ?? "",
|
||||||
)
|
)
|
||||||
setConfig(res.data)
|
setConfig(res.data)
|
||||||
setEtag(res.etag)
|
setEtag(res.etag)
|
||||||
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
toast.success("Configuration saved", message)
|
||||||
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not save configuration", errorMessage(err))
|
toast.error("Could not save configuration", errorMessage(err))
|
||||||
load()
|
load()
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggle(flag: FlagKey, next: boolean) {
|
||||||
|
return save(flag, { [flag]: next }, `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Product variants"
|
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}
|
checked={config.itemTypesEnabled}
|
||||||
busy={saving === "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
|
<ToggleRow
|
||||||
@@ -137,7 +171,39 @@ export default function ProductSettingsPage() {
|
|||||||
description="Allows warranty-tracked items to pick a coverage period."
|
description="Allows warranty-tracked items to pick a coverage period."
|
||||||
checked={config.warrantyPeriodsEnabled}
|
checked={config.warrantyPeriodsEnabled}
|
||||||
busy={saving === "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
|
<ToggleRow
|
||||||
@@ -177,21 +243,40 @@ const LABELS: Record<keyof ProductConfig, string> = {
|
|||||||
serialNumbersEnabled: "Serial numbers",
|
serialNumbersEnabled: "Serial numbers",
|
||||||
batchListsEnabled: "Batch lists",
|
batchListsEnabled: "Batch lists",
|
||||||
warrantyPeriodsEnabled: "Warranty periods",
|
warrantyPeriodsEnabled: "Warranty periods",
|
||||||
|
customVariantsEnabled: "Custom variants",
|
||||||
|
itemImagesEnabled: "Item images",
|
||||||
|
customWarrantyPeriodsEnabled: "Custom warranty periods",
|
||||||
updatedAt: "Updated at",
|
updatedAt: "Updated at",
|
||||||
updatedBy: "Updated by",
|
updatedBy: "Updated by",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlagKey = keyof Pick<
|
||||||
|
ProductConfig,
|
||||||
|
| "subcategoriesEnabled"
|
||||||
|
| "brandsEnabled"
|
||||||
|
| "itemTypesEnabled"
|
||||||
|
| "sellingPricesEnabled"
|
||||||
|
| "serialNumbersEnabled"
|
||||||
|
| "batchListsEnabled"
|
||||||
|
| "warrantyPeriodsEnabled"
|
||||||
|
| "customVariantsEnabled"
|
||||||
|
| "itemImagesEnabled"
|
||||||
|
| "customWarrantyPeriodsEnabled"
|
||||||
|
>
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
checked,
|
checked,
|
||||||
busy,
|
busy,
|
||||||
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: ReactNode
|
||||||
checked: boolean
|
checked: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
disabled?: boolean
|
||||||
onChange: (next: boolean) => void
|
onChange: (next: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -199,7 +284,7 @@ function ToggleRow({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onChange}
|
onCheckedChange={onChange}
|
||||||
disabled={busy}
|
disabled={busy || disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="mt-1 shrink-0"
|
className="mt-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
|
// UOM now lives as a tab on the merged Category/Brand/UOM page — this route stays
|
||||||
// to keep old links/bookmarks working.
|
// only to keep old links/bookmarks working.
|
||||||
export default function UomsRedirectPage() {
|
export default function UomsRedirectPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
router.replace("/dashboard/products/brands?tab=uom")
|
router.replace("/dashboard/products/categories?tab=uom")
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
return null
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
|||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Combobox } from "@/components/ui/combobox"
|
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -141,13 +141,8 @@ export default function NewFreeIssuePage() {
|
|||||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||||
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||||
setCashierUserId(userRes.items[0]?.userId ?? null)
|
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||||
setLines([
|
// No item pre-selected — the user searches for one.
|
||||||
{
|
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
|
||||||
...blankLine("line-1"),
|
|
||||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
|
||||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
await refreshRows()
|
await refreshRows()
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
@@ -167,7 +162,7 @@ export default function NewFreeIssuePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemOptions = useMemo(
|
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]
|
[items]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -369,12 +364,29 @@ export default function NewFreeIssuePage() {
|
|||||||
<FieldGroup key={line.key}>
|
<FieldGroup key={line.key}>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Item</FieldLabel>
|
<FieldLabel>Item</FieldLabel>
|
||||||
<Combobox
|
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
|
||||||
items={itemOptions}
|
{line.itemId ? (
|
||||||
value={line.itemId || null}
|
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
|
||||||
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
|
<span className="truncate font-medium">
|
||||||
placeholder="Search item…"
|
{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>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>UOM</FieldLabel>
|
<FieldLabel>UOM</FieldLabel>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
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 { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -13,6 +13,7 @@ import { baseUomLabel } from "@/lib/uom-label"
|
|||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
@@ -24,6 +25,7 @@ import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, Sales
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
@@ -85,6 +87,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
|
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
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)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(invoiceId)) {
|
if (!Number.isFinite(invoiceId)) {
|
||||||
@@ -100,8 +110,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
salesApi.getInvoice(invoiceId),
|
salesApi.getInvoice(invoiceId),
|
||||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
invoiceTemplateConfigApi.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)
|
||||||
setCustomers(customerRes.items)
|
setCustomers(customerRes.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.items)
|
setUoms(uomRes.items)
|
||||||
@@ -170,6 +185,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const isDraft = invoice?.status === "Draft"
|
const isDraft = invoice?.status === "Draft"
|
||||||
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
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])
|
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 freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
||||||
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
|
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
|
||||||
|
|
||||||
@@ -182,11 +200,34 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
// Type-ahead "Add item" options — nothing is listed until the user types.
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const itemOptions = useMemo(
|
||||||
updateLine(key, {
|
() =>
|
||||||
itemId,
|
items.map((i) => {
|
||||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
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) },
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +236,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
return getSuggestedUnitPrice(items, line.itemId)
|
return getSuggestedUnitPrice(items, line.itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLine() {
|
|
||||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLine(key: string) {
|
function removeLine(key: string) {
|
||||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
}
|
}
|
||||||
@@ -224,15 +261,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: Number(line.freeQty),
|
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
||||||
unitPrice: resolveLineUnitPrice(line),
|
unitPrice: resolveLineUnitPrice(line),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: Number(line.discountPct),
|
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
||||||
discountAmount: Number(line.discountAmount),
|
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
|
||||||
discountValue: Number(line.discountValue),
|
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
|
||||||
taxPct: Number(line.taxPct),
|
taxPct: Number(line.taxPct),
|
||||||
isFreeIssue: line.isFreeIssue,
|
isFreeIssue: allowFreeItems && line.isFreeIssue,
|
||||||
parentLineId: line.parentLineId || null,
|
parentLineId: line.parentLineId || null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -429,13 +466,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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="border-t pt-5">
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
<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 className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Stock shortage detected before posting</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>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/receiving/grn/new"
|
href="/dashboard/receiving/grn/new"
|
||||||
@@ -486,10 +529,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
|
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<>
|
<>
|
||||||
|
<SuggestInput
|
||||||
|
options={itemOptions}
|
||||||
|
onSelect={(v) => addItem(Number(v))}
|
||||||
|
placeholder="Add item — type SKU or name…"
|
||||||
|
emptyText="No items match."
|
||||||
|
className="mb-4 w-full sm:max-w-md"
|
||||||
|
aria-label="Add item"
|
||||||
|
/>
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<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={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">
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
{saving ? "Saving..." : "Save invoice"}
|
{saving ? "Saving..." : "Save invoice"}
|
||||||
@@ -520,7 +568,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
<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-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">Qty</th>
|
||||||
<th className="px-4 py-2 text-right font-medium">Free</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">Unit price</th>
|
||||||
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -529,18 +577,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
{lines.map((line) => (
|
{lines.map((line) => (
|
||||||
<tr key={line.key} className="border-t border-border align-middle">
|
<tr key={line.key} className="border-t border-border align-middle">
|
||||||
<td className="px-4 py-3 min-w-64">
|
<td className="px-4 py-3 min-w-64">
|
||||||
<select
|
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
|
||||||
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>
|
||||||
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
|
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
@@ -555,16 +592,18 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 w-28">
|
{allowFreeItems && (
|
||||||
<input
|
<td className="px-4 py-3 w-28">
|
||||||
type="number"
|
<input
|
||||||
min="0"
|
type="number"
|
||||||
step="1"
|
min="0"
|
||||||
value={line.freeQty}
|
step="1"
|
||||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
value={line.freeQty}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
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>
|
||||||
|
)}
|
||||||
<td className="px-4 py-3 w-32">
|
<td className="px-4 py-3 w-32">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -614,18 +653,25 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Warehouse</Label>
|
<Label className="text-xs">Warehouse</Label>
|
||||||
<select
|
{allowWarehouseSelection ? (
|
||||||
value={warehouseId ? String(warehouseId) : ""}
|
<select
|
||||||
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
value={warehouseId ? String(warehouseId) : ""}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
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 value="">Select warehouse</option>
|
||||||
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
{warehouses.map((w) => (
|
||||||
{w.code} - {w.name}
|
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
</option>
|
{w.code} - {w.name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Invoice type</Label>
|
<Label className="text-xs">Invoice type</Label>
|
||||||
@@ -634,8 +680,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="B2B">B2B</option>
|
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
|
||||||
<option value="B2C">B2C</option>
|
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
|
||||||
<option value="Cash">Cash</option>
|
<option value="Cash">Cash</option>
|
||||||
<option value="Credit">Credit</option>
|
<option value="Credit">Credit</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
import { Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||||
@@ -12,7 +12,7 @@ import { FieldError } from "@/components/ui/field"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Combobox } from "@/components/ui/combobox"
|
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -21,6 +21,7 @@ import { baseUomLabel } from "@/lib/uom-label"
|
|||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { ApiError } from "@/lib/api-client"
|
import { ApiError } from "@/lib/api-client"
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { bundleApi } from "@/lib/api/bundles"
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -30,7 +31,13 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
|||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { Branch } from "@/types/hrm"
|
import { Branch } from "@/types/hrm"
|
||||||
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
|
import {
|
||||||
|
CreateSalesInvoiceLineRequest,
|
||||||
|
CreateSalesInvoiceRequest,
|
||||||
|
SalesConfig,
|
||||||
|
SalesInvoiceType,
|
||||||
|
SalesStockAvailability,
|
||||||
|
} from "@/types/sales"
|
||||||
import { BundleSaleTemplateSummary } from "@/types/bundles"
|
import { BundleSaleTemplateSummary } from "@/types/bundles"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
@@ -119,14 +126,46 @@ function NewSalesInvoiceContent() {
|
|||||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("Cash")
|
||||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
// Starts empty — lines are only ever added by picking an item/bundle in the "Add item" box
|
||||||
|
// (or scanning), never as blank rows with a dropdown to fill in.
|
||||||
|
const [lines, setLines] = useState<Line[]>([])
|
||||||
const [freeIssueSchemes, setFreeIssueSchemes] = useState<FreeIssueScheme[]>([])
|
const [freeIssueSchemes, setFreeIssueSchemes] = useState<FreeIssueScheme[]>([])
|
||||||
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
|
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
|
||||||
|
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
|
||||||
|
// Sales Configuration "Allow cash customer": when on, the customer dropdown offers the generic
|
||||||
|
// cash customer first and the sale starts on it; when off, a real customer must be picked (no
|
||||||
|
// default). The cash customer row itself never shows up among the regular customers.
|
||||||
|
const [salesConfig, setSalesConfig] = useState<SalesConfig | null>(null)
|
||||||
|
const cashCustomerId = salesConfig?.allowCashCustomer ? salesConfig.cashCustomerId : null
|
||||||
|
// "Allow free items": off hides the Free column and never auto-fills a free qty. A missing
|
||||||
|
// config keeps the pre-option behaviour (allowed).
|
||||||
|
const allowFreeItems = salesConfig?.allowFreeItems ?? true
|
||||||
|
// "Allow discounts": off hides the Disc % column and never sends a line discount.
|
||||||
|
const allowDiscounts = salesConfig?.allowDiscounts ?? true
|
||||||
|
// A saved default sales warehouse pins the sale to it (locked field, server-forced too); the
|
||||||
|
// picker only appears when none is saved and "Allow warehouse selection" is on.
|
||||||
|
const allowWarehouseSelection = !salesConfig?.defaultWarehouseId && (salesConfig?.allowWarehouseSelection ?? true)
|
||||||
|
|
||||||
|
function warehouseLabel(id: number | null): string | null {
|
||||||
|
const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id)
|
||||||
|
return w ? `${w.code} — ${w.name}` : null
|
||||||
|
}
|
||||||
|
const regularCustomers = useMemo(
|
||||||
|
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
||||||
|
[customers, salesConfig],
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectCustomer(nextCustomerId: number | null) {
|
||||||
|
setCustomerId(nextCustomerId)
|
||||||
|
// A cash customer sale is a cash sale — preset the type (still changeable).
|
||||||
|
if (nextCustomerId !== null && nextCustomerId === cashCustomerId) setInvoiceType("Cash")
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -136,24 +175,25 @@ function NewSalesInvoiceContent() {
|
|||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
salesApi.listFreeIssues({ pageSize: 20 }),
|
salesApi.listFreeIssues({ pageSize: 20 }),
|
||||||
bundleApi.listTemplates({ pageSize: 200 }),
|
bundleApi.listTemplates({ pageSize: 200 }),
|
||||||
|
// Missing/failed config just means "no cash customer" — never block the form on it.
|
||||||
|
salesConfigApi.get().then((r) => r.data).catch(() => null),
|
||||||
])
|
])
|
||||||
.then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes]) => {
|
.then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes, config]) => {
|
||||||
setCustomers(cust.items)
|
setCustomers(cust.items)
|
||||||
setItems(itemRes.items)
|
setItems(itemRes.items)
|
||||||
setUoms(uomRes.items)
|
setUoms(uomRes.items)
|
||||||
setWarehouses(whRes.items)
|
setWarehouses(whRes.items)
|
||||||
setBundleTemplates(templateRes.items)
|
setBundleTemplates(templateRes.items)
|
||||||
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
|
setSalesConfig(config)
|
||||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
const defaultWarehouseId =
|
||||||
|
config?.defaultWarehouseId ?? (config && !config.allowWarehouseSelection ? null : (whRes.items[0]?.warehouseId ?? null))
|
||||||
|
if (config?.allowCashCustomer && config.cashCustomerId) {
|
||||||
|
setCustomerId(config.cashCustomerId)
|
||||||
|
setInvoiceType("Cash")
|
||||||
|
} else {
|
||||||
|
setCustomerId(null)
|
||||||
|
}
|
||||||
setWarehouseId(defaultWarehouseId)
|
setWarehouseId(defaultWarehouseId)
|
||||||
setLines([
|
|
||||||
{
|
|
||||||
...blankLine("line-1"),
|
|
||||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
|
||||||
warehouseId: defaultWarehouseId ?? 0,
|
|
||||||
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
// Cancelled Free Issues don't represent a live deal — everything else (Draft/Posted)
|
// Cancelled Free Issues don't represent a live deal — everything else (Draft/Posted)
|
||||||
// does. First match per item wins (listFreeIssues returns newest first).
|
// does. First match per item wins (listFreeIssues returns newest first).
|
||||||
setFreeIssueSchemes(
|
setFreeIssueSchemes(
|
||||||
@@ -201,7 +241,9 @@ function NewSalesInvoiceContent() {
|
|||||||
|
|
||||||
// Switching branch can strip the currently-picked warehouse out of the visible set —
|
// Switching branch can strip the currently-picked warehouse out of the visible set —
|
||||||
// clear it (and every line's warehouse) rather than silently submitting a mismatched one.
|
// clear it (and every line's warehouse) rather than silently submitting a mismatched one.
|
||||||
|
// A pinned default sales warehouse is never cleared this way — there's no picker to re-pick with.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!allowWarehouseSelection) return
|
||||||
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
|
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visibleWarehouses])
|
}, [visibleWarehouses])
|
||||||
@@ -214,20 +256,12 @@ function NewSalesInvoiceContent() {
|
|||||||
// own qty (10 units -> 2 free, 25 units -> still 2, 30 units -> 4) — matches how the Free
|
// own qty (10 units -> 2 free, 25 units -> still 2, 30 units -> 4) — matches how the Free
|
||||||
// Issue slip itself frames the deal ("Buy {qty} Get {freeQty}"), not a per-unit ratio.
|
// Issue slip itself frames the deal ("Buy {qty} Get {freeQty}"), not a per-unit ratio.
|
||||||
function suggestedFreeQty(itemId: number, qty: number): number {
|
function suggestedFreeQty(itemId: number, qty: number): number {
|
||||||
|
if (!allowFreeItems) return 0
|
||||||
const scheme = freeIssueSchemeFor(itemId)
|
const scheme = freeIssueSchemeFor(itemId)
|
||||||
if (!scheme || !qty) return 0
|
if (!scheme || !qty) return 0
|
||||||
return Math.floor(qty / scheme.qty) * scheme.freeQty
|
return Math.floor(qty / scheme.qty) * scheme.freeQty
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
|
||||||
const qty = Number(lines.find((l) => l.key === key)?.qty ?? 0)
|
|
||||||
updateLine(key, {
|
|
||||||
itemId,
|
|
||||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
|
||||||
freeQty: suggestedFreeQty(itemId, qty),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQty(key: string, itemId: number, qty: number) {
|
function updateQty(key: string, itemId: number, qty: number) {
|
||||||
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
|
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
|
||||||
}
|
}
|
||||||
@@ -236,46 +270,163 @@ function NewSalesInvoiceContent() {
|
|||||||
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one
|
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one
|
||||||
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability
|
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability
|
||||||
// (see the Line type note above re: submit() not yet sending them).
|
// (see the Line type note above re: submit() not yet sending them).
|
||||||
|
// Shared by the "Add item" suggestion box and barcode scans: bumps an existing line for the
|
||||||
|
// same item+lot by one unit, or appends a new line for it. Returns the affected line's key.
|
||||||
|
function addItem(
|
||||||
|
itemId: number,
|
||||||
|
lot: { batchId?: number | null; batchNo?: string | null; serialId?: number | null; serialNo?: string | null } = {},
|
||||||
|
): string {
|
||||||
|
const sameLine = (l: Line) =>
|
||||||
|
!l.bundleGroupId &&
|
||||||
|
l.itemId === itemId &&
|
||||||
|
(l.batchId ?? null) === (lot.batchId ?? null) &&
|
||||||
|
(l.serialId ?? null) === (lot.serialId ?? null)
|
||||||
|
const newKey = `line-${Date.now()}`
|
||||||
|
const lineKey = lines.find(sameLine)?.key ?? newKey
|
||||||
|
setLines((prev) => {
|
||||||
|
const idx = prev.findIndex(sameLine)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev]
|
||||||
|
const nextQty = Number(next[idx].qty || 0) + 1
|
||||||
|
next[idx] = { ...next[idx], qty: nextQty, freeQty: suggestedFreeQty(itemId, nextQty) }
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...blankLine(newKey),
|
||||||
|
itemId,
|
||||||
|
warehouseId: warehouseId ?? 0,
|
||||||
|
qty: 1,
|
||||||
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
|
freeQty: suggestedFreeQty(itemId, 1),
|
||||||
|
batchId: lot.batchId ?? null,
|
||||||
|
batchNo: lot.batchNo ?? null,
|
||||||
|
serialId: lot.serialId ?? null,
|
||||||
|
serialNo: lot.serialNo ?? null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
fillFallbackPrice(itemId)
|
||||||
|
return lineKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard entry flow: picking from the list jumps the cursor to that line's Qty, then Enter
|
||||||
|
// walks Qty → Free → Unit price → Disc %, and Enter on Disc % returns to the search box. Each
|
||||||
|
// field's text is selected on arrival, so typing replaces it. Scans don't jump to Qty, so a
|
||||||
|
// scanner can keep firing codes into the search box. Both the mobile card and the desktop row
|
||||||
|
// render these inputs per line — always focus whichever one is actually visible.
|
||||||
|
const lineFieldOrder = ["qty", "free", "price", "disc"] as const
|
||||||
|
type LineField = (typeof lineFieldOrder)[number]
|
||||||
|
|
||||||
|
function focusVisible(selector: string): boolean {
|
||||||
|
const input = Array.from(document.querySelectorAll<HTMLInputElement>(selector)).find((el) => el.offsetParent !== null)
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
input.select()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const [focusQtyKey, setFocusQtyKey] = useState<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusQtyKey && focusVisible(`input[data-line-key="${focusQtyKey}"][data-nav="qty"]`)) setFocusQtyKey(null)
|
||||||
|
}, [focusQtyKey, lines])
|
||||||
|
|
||||||
|
function onLineFieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>, lineKey: string, field: LineField) {
|
||||||
|
if (e.key !== "Enter") return
|
||||||
|
e.preventDefault()
|
||||||
|
const order: readonly LineField[] = lineFieldOrder.filter(
|
||||||
|
(f) => (f !== "free" || allowFreeItems) && (f !== "disc" || allowDiscounts),
|
||||||
|
)
|
||||||
|
const next = order[order.indexOf(field) + 1]
|
||||||
|
if (next) focusVisible(`input[data-line-key="${lineKey}"][data-nav="${next}"]`)
|
||||||
|
else focusVisible('input[aria-label="Add item"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
const navProps = (lineKey: string, field: LineField) => ({
|
||||||
|
"data-line-key": lineKey,
|
||||||
|
"data-nav": field,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => onLineFieldKeyDown(e, lineKey, field),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Items without a flat selling price get priced on save from the warehouse's GRN/FIFO average —
|
||||||
|
// fetch that same figure now so Unit price isn't left blank. Only fills lines still blank, so a
|
||||||
|
// price the user already typed is never overwritten. A 0 result (no stock history) stays blank.
|
||||||
|
async function fillFallbackPrice(itemId: number) {
|
||||||
|
if (!warehouseId || getSuggestedUnitPrice(items, itemId) !== null) return
|
||||||
|
const key = `${itemId}:${warehouseId}`
|
||||||
|
let price = resolvedPrices[key]
|
||||||
|
if (price === undefined) {
|
||||||
|
try {
|
||||||
|
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResolvedPrices((prev) => ({ ...prev, [key]: price }))
|
||||||
|
}
|
||||||
|
if (!price) return
|
||||||
|
setLines((prev) =>
|
||||||
|
prev.map((l) =>
|
||||||
|
!l.bundleGroupId && l.itemId === itemId && l.warehouseId === warehouseId && l.unitPrice === null
|
||||||
|
? { ...l, unitPrice: price }
|
||||||
|
: l,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Allow selling beyond stock" off: each item's total qty (+ free) per warehouse — summed across
|
||||||
|
// lines, since a bundle and a loose line can draw on the same stock — must fit the issuable
|
||||||
|
// stock, else the line shows the available count and Create is blocked. Fetched once per
|
||||||
|
// item/warehouse pair; non-stock items are never short.
|
||||||
|
const [availability, setAvailability] = useState<Record<string, SalesStockAvailability>>({})
|
||||||
|
const checkStock = !!salesConfig && !salesConfig.allowOverStockSale
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkStock) return
|
||||||
|
const pending = new Set(
|
||||||
|
lines.filter((l) => l.itemId && l.warehouseId).map((l) => `${l.itemId}:${l.warehouseId}`),
|
||||||
|
)
|
||||||
|
pending.forEach((key) => {
|
||||||
|
if (availability[key]) return
|
||||||
|
const [itemId, whId] = key.split(":").map(Number)
|
||||||
|
salesApi
|
||||||
|
.getStockAvailability(itemId, whId)
|
||||||
|
.then((a) => setAvailability((prev) => ({ ...prev, [key]: a })))
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lines, checkStock])
|
||||||
|
|
||||||
|
const requestedByStockKey = useMemo(() => {
|
||||||
|
const totals: Record<string, number> = {}
|
||||||
|
for (const l of lines) {
|
||||||
|
if (!l.itemId || !l.warehouseId) continue
|
||||||
|
const key = `${l.itemId}:${l.warehouseId}`
|
||||||
|
totals[key] = (totals[key] ?? 0) + Number(l.qty || 0) + (allowFreeItems ? Number(l.freeQty || 0) : 0)
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}, [lines, allowFreeItems])
|
||||||
|
|
||||||
|
/** Available count when this line's item is over-requested at its warehouse, else null. */
|
||||||
|
function stockShortage(line: Line): number | null {
|
||||||
|
if (!checkStock) return null
|
||||||
|
const key = `${line.itemId}:${line.warehouseId}`
|
||||||
|
const a = availability[key]
|
||||||
|
if (!a || !a.isStocked) return null
|
||||||
|
return (requestedByStockKey[key] ?? 0) > a.available ? Math.max(0, a.available) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestedPriceFor(line: Line): number | null {
|
||||||
|
const flat = getSuggestedUnitPrice(items, line.itemId)
|
||||||
|
if (flat !== null) return flat
|
||||||
|
return resolvedPrices[`${line.itemId}:${line.warehouseId}`] || null
|
||||||
|
}
|
||||||
|
|
||||||
async function handleScan(code: string) {
|
async function handleScan(code: string) {
|
||||||
setScanError(null)
|
setScanError(null)
|
||||||
try {
|
try {
|
||||||
const result = await itemsApi.scan(code)
|
const result = await itemsApi.scan(code)
|
||||||
setLines((prev) => {
|
addItem(result.itemId, result)
|
||||||
const idx = prev.findIndex(
|
|
||||||
(l) =>
|
|
||||||
!l.bundleGroupId &&
|
|
||||||
l.itemId === result.itemId &&
|
|
||||||
(l.batchId ?? null) === (result.batchId ?? null) &&
|
|
||||||
(l.serialId ?? null) === (result.serialId ?? null)
|
|
||||||
)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const next = [...prev]
|
|
||||||
const nextQty = Number(next[idx].qty || 0) + 1
|
|
||||||
next[idx] = { ...next[idx], qty: nextQty, freeQty: suggestedFreeQty(result.itemId, nextQty) }
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
// Fill the first still-blank row (item never picked) instead of appending below it —
|
|
||||||
// whether it's the lone starter line or one left empty further down the table.
|
|
||||||
const blankIdx = prev.findIndex((l) => l.itemId === 0 && !l.bundleGroupId)
|
|
||||||
const newLine: Line = {
|
|
||||||
...blankLine(blankIdx >= 0 ? prev[blankIdx].key : `line-${Date.now()}`),
|
|
||||||
itemId: result.itemId,
|
|
||||||
warehouseId: warehouseId ?? 0,
|
|
||||||
qty: 1,
|
|
||||||
unitPrice: getSuggestedUnitPrice(items, result.itemId),
|
|
||||||
freeQty: suggestedFreeQty(result.itemId, 1),
|
|
||||||
batchId: result.batchId,
|
|
||||||
batchNo: result.batchNo,
|
|
||||||
serialId: result.serialId,
|
|
||||||
serialNo: result.serialNo,
|
|
||||||
}
|
|
||||||
if (blankIdx >= 0) {
|
|
||||||
const next = [...prev]
|
|
||||||
next[blankIdx] = newLine
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return [...prev, newLine]
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
setScanError(`No item or lot found for "${code}"`)
|
setScanError(`No item or lot found for "${code}"`)
|
||||||
@@ -285,10 +436,14 @@ function NewSalesInvoiceContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The per-line item search doubles as bundle search: item values are prefixed "item:" and
|
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
|
||||||
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
|
// template values "bundle:" so one handler can tell them apart on select.
|
||||||
const itemOptions = useMemo(
|
const itemOptions = useMemo(
|
||||||
() => items.map((i) => ({ value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}` })),
|
() =>
|
||||||
|
items.map((i) => {
|
||||||
|
const price = getSuggestedUnitPrice(items, i.itemId)
|
||||||
|
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
|
||||||
|
}),
|
||||||
[items],
|
[items],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -302,16 +457,6 @@ function NewSalesInvoiceContent() {
|
|||||||
[itemOptions, bundleTemplateOptions],
|
[itemOptions, bundleTemplateOptions],
|
||||||
)
|
)
|
||||||
|
|
||||||
function addLine() {
|
|
||||||
setLines((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
...blankLine(`line-${Date.now()}`),
|
|
||||||
warehouseId: warehouseId ?? 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// A bundle template isn't a stock item itself — it has no itemId of its own — so "adding" one
|
// A bundle template isn't a stock item itself — it has no itemId of its own — so "adding" one
|
||||||
// expands it into its real component lines (each with its own itemId/qty/price) rather than a
|
// expands it into its real component lines (each with its own itemId/qty/price) rather than a
|
||||||
// single pseudo-item row. That's what keeps stock and GL postings correct per component.
|
// single pseudo-item row. That's what keeps stock and GL postings correct per component.
|
||||||
@@ -362,18 +507,14 @@ function NewSalesInvoiceContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined handler for each line's item/bundle search box — routes to the right action based
|
// Handler for the "Add item" suggestion box — routes on the "item:"/"bundle:" value prefix
|
||||||
// on the "item:"/"bundle:" value prefix (see lineComboOptions above).
|
// (see lineComboOptions above).
|
||||||
function selectLineOption(key: string, value: string | null) {
|
function addFromSuggestion(value: string) {
|
||||||
if (!value) return
|
|
||||||
const sep = value.indexOf(":")
|
const sep = value.indexOf(":")
|
||||||
const kind = value.slice(0, sep)
|
const kind = value.slice(0, sep)
|
||||||
const id = Number(value.slice(sep + 1))
|
const id = Number(value.slice(sep + 1))
|
||||||
if (kind === "bundle") {
|
if (kind === "bundle") addBundle(id)
|
||||||
addBundle(id, key)
|
else setFocusQtyKey(addItem(id))
|
||||||
} else {
|
|
||||||
selectItem(key, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep-linked from the Bundle Templates page's "Sell" button (?bundleTemplateId=...) — applies
|
// Deep-linked from the Bundle Templates page's "Sell" button (?bundleTemplateId=...) — applies
|
||||||
@@ -386,15 +527,17 @@ function NewSalesInvoiceContent() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loading, bundleTemplateIdFromQuery])
|
}, [loading, bundleTemplateIdFromQuery])
|
||||||
|
|
||||||
|
function itemLabel(itemId: number) {
|
||||||
|
const item = items.find((i) => i.itemId === itemId)
|
||||||
|
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
|
||||||
|
}
|
||||||
|
|
||||||
function removeLine(key: string) {
|
function removeLine(key: string) {
|
||||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
setLines((prev) => prev.filter((line) => line.key !== key))
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBundleGroup(groupId: string) {
|
function removeBundleGroup(groupId: string) {
|
||||||
setLines((prev) => {
|
setLines((prev) => prev.filter((line) => line.bundleGroupId !== groupId))
|
||||||
const next = prev.filter((line) => line.bundleGroupId !== groupId)
|
|
||||||
return next.length === 0 ? [{ ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }] : next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups consecutive component lines added from the same bundle into one display row, so the
|
// Groups consecutive component lines added from the same bundle into one display row, so the
|
||||||
@@ -450,8 +593,14 @@ function NewSalesInvoiceContent() {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||||
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
|
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
|
||||||
|
if (lines.length === 0) return setSubmitError("Add at least one item.")
|
||||||
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
||||||
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
||||||
|
const short = lines.filter((line) => stockShortage(line) !== null)
|
||||||
|
if (short.length > 0) {
|
||||||
|
const detail = [...new Set(short.map((l) => `${itemLabel(l.itemId)} (${stockShortage(l)} available)`))].join(", ")
|
||||||
|
return setSubmitError(`Quantity exceeds available stock: ${detail}. Reduce the quantity to continue.`)
|
||||||
|
}
|
||||||
|
|
||||||
const payload: CreateSalesInvoiceRequest = {
|
const payload: CreateSalesInvoiceRequest = {
|
||||||
customerId,
|
customerId,
|
||||||
@@ -462,15 +611,15 @@ function NewSalesInvoiceContent() {
|
|||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: Number(line.freeQty),
|
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
||||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: Number(line.discountPct),
|
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
||||||
discountAmount: Number(line.discountAmount),
|
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
|
||||||
discountValue: Number(line.discountValue),
|
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
|
||||||
taxPct: Number(line.taxPct),
|
taxPct: Number(line.taxPct),
|
||||||
isFreeIssue: line.isFreeIssue,
|
isFreeIssue: allowFreeItems && line.isFreeIssue,
|
||||||
parentLineId: line.parentLineId || null,
|
parentLineId: line.parentLineId || null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -512,12 +661,17 @@ function NewSalesInvoiceContent() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Customer</Label>
|
<Label className="text-base">Customer</Label>
|
||||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => selectCustomer(v ? Number(v) : null)}>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="Select customer" />
|
<SelectValue placeholder="Select customer" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{customers.map((c) => (
|
{cashCustomerId !== null && (
|
||||||
|
<SelectItem value={String(cashCustomerId)} className="text-base font-medium">
|
||||||
|
{salesConfig?.cashCustomerName ?? "Cash Customer"}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{regularCustomers.map((c) => (
|
||||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||||
{c.customerCode} — {c.displayName ?? c.name}
|
{c.customerCode} — {c.displayName ?? c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -552,33 +706,25 @@ function NewSalesInvoiceContent() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Warehouse</Label>
|
<Label className="text-base">Warehouse</Label>
|
||||||
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
{allowWarehouseSelection ? (
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
||||||
<SelectValue placeholder="Select warehouse" />
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select warehouse" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{visibleWarehouses.map((w) => (
|
<SelectContent>
|
||||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
{visibleWarehouses.map((w) => (
|
||||||
{w.code} — {w.name}
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||||
</SelectItem>
|
{w.code} — {w.name}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
// Pinned to the default sales warehouse — shown, not editable (same as a locked branch).
|
||||||
<Label className="text-base">Invoice type</Label>
|
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||||
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
|
{warehouseLabel(warehouseId) ?? salesConfig?.defaultWarehouseName ?? "No default warehouse set"}
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
</div>
|
||||||
<SelectValue />
|
)}
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
|
|
||||||
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
|
|
||||||
<SelectItem value="Cash" className="text-base">Cash</SelectItem>
|
|
||||||
<SelectItem value="Credit" className="text-base">Credit</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -587,21 +733,37 @@ function NewSalesInvoiceContent() {
|
|||||||
label="Scan to add"
|
label="Scan to add"
|
||||||
hint="Scan an item barcode, or a batch/serial lot barcode, to add or bump a line."
|
hint="Scan an item barcode, or a batch/serial lot barcode, to add or bump a line."
|
||||||
onScan={handleScan}
|
onScan={handleScan}
|
||||||
|
// The "Add item" box below gets the cursor instead — it also accepts scans.
|
||||||
|
autoFocus={false}
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
/>
|
/>
|
||||||
{scanError && <FieldError errors={[{ message: scanError }]} />}
|
{scanError && <FieldError errors={[{ message: scanError }]} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it's
|
||||||
<Button type="button" variant="outline" onClick={addLine}>
|
the last row of the table, under the Item column. */}
|
||||||
<Plus className="size-5" />
|
<SuggestInput
|
||||||
Add line
|
options={lineComboOptions}
|
||||||
</Button>
|
onSelect={addFromSuggestion}
|
||||||
|
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||||
|
onEnterWithoutMatch={handleScan}
|
||||||
|
browsable
|
||||||
|
keepOpenOnSelect
|
||||||
|
autoFocus
|
||||||
|
placeholder="Add item — type SKU, name or bundle…"
|
||||||
|
emptyText="No items or bundles match."
|
||||||
|
className="w-full md:hidden"
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Mobile: stacked cards. */}
|
{/* Mobile: stacked cards. */}
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
@@ -619,6 +781,9 @@ function NewSalesInvoiceContent() {
|
|||||||
{row.lines.map((l) => (
|
{row.lines.map((l) => (
|
||||||
<li key={l.key}>
|
<li key={l.key}>
|
||||||
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
|
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
|
||||||
|
{stockShortage(l) !== null && (
|
||||||
|
<span className="text-destructive"> — only {stockShortage(l)} available</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -629,7 +794,6 @@ function NewSalesInvoiceContent() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={() => removeBundleGroup(row.groupId)}
|
onClick={() => removeBundleGroup(row.groupId)}
|
||||||
disabled={displayRows.length === 1}
|
|
||||||
aria-label="Remove bundle"
|
aria-label="Remove bundle"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
@@ -649,10 +813,12 @@ function NewSalesInvoiceContent() {
|
|||||||
aria-label="Bundle quantity"
|
aria-label="Bundle quantity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{allowFreeItems && (
|
||||||
<Label className="text-sm">Free qty</Label>
|
<div>
|
||||||
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
|
<Label className="text-sm">Free qty</Label>
|
||||||
</div>
|
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-3">
|
<div className="flex items-center justify-between border-t pt-3">
|
||||||
@@ -672,13 +838,7 @@ function NewSalesInvoiceContent() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-sm">Item {idx + 1}</Label>
|
<Label className="text-sm">Item {idx + 1}</Label>
|
||||||
<Combobox
|
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
|
||||||
items={lineComboOptions}
|
|
||||||
value={line.itemId ? `item:${line.itemId}` : null}
|
|
||||||
onValueChange={(v) => selectLineOption(line.key, v)}
|
|
||||||
placeholder="Search item or bundle…"
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
{(line.batchNo || line.serialNo) && (
|
{(line.batchNo || line.serialNo) && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
|
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
|
||||||
)}
|
)}
|
||||||
@@ -689,7 +849,6 @@ function NewSalesInvoiceContent() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="mt-6 shrink-0"
|
className="mt-6 shrink-0"
|
||||||
onClick={() => removeLine(line.key)}
|
onClick={() => removeLine(line.key)}
|
||||||
disabled={displayRows.length === 1}
|
|
||||||
aria-label="Remove line"
|
aria-label="Remove line"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
@@ -712,42 +871,55 @@ function NewSalesInvoiceContent() {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={line.qty}
|
value={line.qty}
|
||||||
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
|
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
|
||||||
|
{...navProps(line.key, "qty")}
|
||||||
|
aria-invalid={stockShortage(line) !== null || undefined}
|
||||||
className="h-11 text-sm tabular-nums"
|
className="h-11 text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
|
{stockShortage(line) !== null && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">
|
||||||
|
Exceeds stock — only {stockShortage(line)} available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
{allowFreeItems && (
|
||||||
<Label className="text-sm">Free</Label>
|
<div>
|
||||||
<Input
|
<Label className="text-sm">Free</Label>
|
||||||
type="number"
|
<Input
|
||||||
min="0"
|
type="number"
|
||||||
step="1"
|
min="0"
|
||||||
inputMode="numeric"
|
step="1"
|
||||||
value={line.freeQty}
|
inputMode="numeric"
|
||||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
value={line.freeQty}
|
||||||
className="h-11 text-sm tabular-nums"
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
||||||
/>
|
{...navProps(line.key, "free")}
|
||||||
{scheme && (
|
className="h-11 text-sm tabular-nums"
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
/>
|
||||||
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
|
{scheme && (
|
||||||
</p>
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
)}
|
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
|
||||||
</div>
|
</p>
|
||||||
<div>
|
)}
|
||||||
<Label className="text-sm">Disc %</Label>
|
</div>
|
||||||
<Input
|
)}
|
||||||
type="number"
|
{allowDiscounts && (
|
||||||
min="0"
|
<div>
|
||||||
max="100"
|
<Label className="text-sm">Disc %</Label>
|
||||||
step="0.5"
|
<Input
|
||||||
inputMode="decimal"
|
type="number"
|
||||||
value={line.discountPct}
|
min="0"
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
max="100"
|
||||||
className="h-11 text-sm tabular-nums"
|
step="0.5"
|
||||||
/>
|
inputMode="decimal"
|
||||||
</div>
|
value={line.discountPct}
|
||||||
|
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
||||||
|
{...navProps(line.key, "disc")}
|
||||||
|
className="h-11 text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -759,11 +931,12 @@ function NewSalesInvoiceContent() {
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={line.unitPrice ?? ""}
|
value={line.unitPrice ?? ""}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
{...navProps(line.key, "price")}
|
||||||
className="h-11 text-sm tabular-nums"
|
className="h-11 text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
{suggestedPriceFor(line) !== null
|
||||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
|
||||||
: "No price suggestion available"}
|
: "No price suggestion available"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,9 +959,9 @@ function NewSalesInvoiceContent() {
|
|||||||
<TableHead className="h-12 min-w-72 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 min-w-72 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Qty</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-right">Qty</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>
|
{allowFreeItems && <TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Unit price</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-right">Unit price</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
|
{allowDiscounts && <TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -808,6 +981,9 @@ function NewSalesInvoiceContent() {
|
|||||||
{row.lines.map((l) => (
|
{row.lines.map((l) => (
|
||||||
<li key={l.key}>
|
<li key={l.key}>
|
||||||
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
|
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
|
||||||
|
{stockShortage(l) !== null && (
|
||||||
|
<span className="text-destructive"> — only {stockShortage(l)} available</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -825,11 +1001,13 @@ function NewSalesInvoiceContent() {
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">
|
{allowFreeItems && (
|
||||||
{bundleFreeQty || "—"}
|
<TableCell className="px-3 py-3 align-top text-right text-sm">
|
||||||
</TableCell>
|
{bundleFreeQty || "—"}
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
||||||
|
{allowDiscounts && <TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>}
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -839,8 +1017,7 @@ function NewSalesInvoiceContent() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeBundleGroup(row.groupId)}
|
onClick={() => removeBundleGroup(row.groupId)}
|
||||||
disabled={displayRows.length === 1}
|
aria-label="Remove bundle"
|
||||||
aria-label="Remove bundle"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -857,13 +1034,7 @@ function NewSalesInvoiceContent() {
|
|||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Combobox
|
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
|
||||||
items={lineComboOptions}
|
|
||||||
value={line.itemId ? `item:${line.itemId}` : null}
|
|
||||||
onValueChange={(v) => selectLineOption(line.key, v)}
|
|
||||||
placeholder="Search item or bundle…"
|
|
||||||
className="h-11 text-sm"
|
|
||||||
/>
|
|
||||||
{(line.batchNo || line.serialNo) && (
|
{(line.batchNo || line.serialNo) && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Lot: {line.batchNo ?? line.serialNo}
|
Lot: {line.batchNo ?? line.serialNo}
|
||||||
@@ -882,24 +1053,34 @@ function NewSalesInvoiceContent() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={line.qty}
|
value={line.qty}
|
||||||
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
|
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
|
||||||
|
{...navProps(line.key, "qty")}
|
||||||
|
aria-invalid={stockShortage(line) !== null || undefined}
|
||||||
className="h-11 text-right text-sm tabular-nums"
|
className="h-11 text-right text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
{stockShortage(line) !== null && (
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<p className="mt-1 text-right text-xs text-destructive">
|
||||||
<Input
|
Exceeds stock — only {stockShortage(line)} available
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
value={line.freeQty}
|
|
||||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
|
||||||
className="h-11 text-right text-sm tabular-nums"
|
|
||||||
/>
|
|
||||||
{scheme && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{allowFreeItems && (
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.freeQty}
|
||||||
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
||||||
|
{...navProps(line.key, "free")}
|
||||||
|
className="h-11 text-right text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
{scheme && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -907,25 +1088,29 @@ function NewSalesInvoiceContent() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={line.unitPrice ?? ""}
|
value={line.unitPrice ?? ""}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
{...navProps(line.key, "price")}
|
||||||
className="h-11 text-right text-sm tabular-nums"
|
className="h-11 text-right text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
{suggestedPriceFor(line) !== null
|
||||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
|
||||||
: "No price suggestion available"}
|
: "No price suggestion available"}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
{allowDiscounts && (
|
||||||
<Input
|
<TableCell className="px-3 py-3 align-top">
|
||||||
type="number"
|
<Input
|
||||||
min="0"
|
type="number"
|
||||||
max="100"
|
min="0"
|
||||||
step="0.5"
|
max="100"
|
||||||
value={line.discountPct}
|
step="0.5"
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
value={line.discountPct}
|
||||||
className="h-11 text-right text-sm tabular-nums"
|
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
||||||
/>
|
{...navProps(line.key, "disc")}
|
||||||
</TableCell>
|
className="h-11 text-right text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(lineNet)}</div>
|
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(lineNet)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -935,8 +1120,7 @@ function NewSalesInvoiceContent() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeLine(line.key)}
|
onClick={() => removeLine(line.key)}
|
||||||
disabled={displayRows.length === 1}
|
aria-label="Remove line"
|
||||||
aria-label="Remove line"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -944,6 +1128,25 @@ function NewSalesInvoiceContent() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{displayRows.length + 1}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<SuggestInput
|
||||||
|
options={lineComboOptions}
|
||||||
|
onSelect={addFromSuggestion}
|
||||||
|
onEnterWithoutMatch={handleScan}
|
||||||
|
browsable
|
||||||
|
keepOpenOnSelect
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search item — SKU, name or bundle…"
|
||||||
|
emptyText="No items or bundles match."
|
||||||
|
aria-label="Add item"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={5 + (allowFreeItems ? 1 : 0) + (allowDiscounts ? 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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -953,10 +1156,12 @@ function NewSalesInvoiceContent() {
|
|||||||
<span className="text-sm text-muted-foreground">Gross</span>
|
<span className="text-sm text-muted-foreground">Gross</span>
|
||||||
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
|
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
{allowDiscounts && (
|
||||||
<span className="text-sm text-muted-foreground">Discount</span>
|
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
||||||
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
|
<span className="text-sm text-muted-foreground">Discount</span>
|
||||||
</div>
|
<span className="tabular-nums">{lkr.format(discountTotal)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
|
||||||
<span className="text-sm text-muted-foreground">Tax</span>
|
<span className="text-sm text-muted-foreground">Tax</span>
|
||||||
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
|
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
|
||||||
@@ -968,13 +1173,28 @@ function NewSalesInvoiceContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
<div className="flex items-center gap-3">
|
||||||
Cancel
|
<Label className="shrink-0 text-base">Invoice type</Label>
|
||||||
</Link>
|
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
|
||||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
|
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||||
{saving ? "Creating…" : "Create Sale"}
|
<SelectValue />
|
||||||
</Button>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Cash" className="text-base">Cash</SelectItem>
|
||||||
|
<SelectItem value="Credit" className="text-base">Credit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? "Creating…" : "Create Sale"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export default function SalesInvoicesPage() {
|
|||||||
{rows !== null ? (
|
{rows !== null ? (
|
||||||
<>
|
<>
|
||||||
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
||||||
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
|
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
|
||||||
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -438,7 +438,7 @@ export default function SalesInvoicesPage() {
|
|||||||
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-react"
|
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
@@ -20,6 +20,12 @@ const cards = [
|
|||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
description: "Configure product and master-data options",
|
description: "Configure product and master-data options",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Sales Configuration",
|
||||||
|
href: "/dashboard/settings/sales-configuration",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
description: "Allow cash customer sales or require picking a customer",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Invoice Template",
|
title: "Invoice Template",
|
||||||
href: "/dashboard/settings/invoice-template",
|
href: "/dashboard/settings/invoice-template",
|
||||||
@@ -34,7 +40,7 @@ export default function ConfigurationPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
|
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
Product options, invoice presentation, and company details used across the ERP.
|
Product and sales options, invoice presentation, and company details used across the ERP.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Settings } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
import { SalesConfig, UpdateSalesConfigRequest } from "@/types/sales"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales Configuration — singleton sales toggles. "Allow cash customer": on, New Sale offers a
|
||||||
|
* generic "Cash Customer" (and defaults to it) alongside the customer list; off, a real customer
|
||||||
|
* must be picked from the list, and the server rejects the cash customer. "Allow free items":
|
||||||
|
* off hides the Free qty column on sale lines, and the server rejects a free qty. "Allow
|
||||||
|
* discounts": same, for the Disc % column and line discounts. "Allow
|
||||||
|
* warehouse selection" + default sales warehouse: a saved default pins Create Sale to that one
|
||||||
|
* warehouse (server-forced too); with none saved, the picker shows unless selection is off.
|
||||||
|
*/
|
||||||
|
export default function SalesConfigurationPage() {
|
||||||
|
const [config, setConfig] = useState<SalesConfig | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setError(null)
|
||||||
|
salesConfigApi
|
||||||
|
.get()
|
||||||
|
.then((res) => {
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const savedDefault = warehouses.find((w) => w.warehouseId === config?.defaultWarehouseId)
|
||||||
|
const savedDefaultInList = savedDefault !== undefined
|
||||||
|
const savedDefaultLabel = savedDefault
|
||||||
|
? `${savedDefault.code} — ${savedDefault.name}`
|
||||||
|
: (config?.defaultWarehouseName ?? `Warehouse #${config?.defaultWarehouseId}`)
|
||||||
|
|
||||||
|
// PUT is a full replacement — send every flag, overriding just the one toggled.
|
||||||
|
async function saveFlag(patch: Partial<UpdateSalesConfigRequest>, message: string) {
|
||||||
|
if (!config) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await salesConfigApi.update(
|
||||||
|
{
|
||||||
|
allowCashCustomer: config.allowCashCustomer,
|
||||||
|
allowFreeItems: config.allowFreeItems,
|
||||||
|
allowDiscounts: config.allowDiscounts,
|
||||||
|
allowOverStockSale: config.allowOverStockSale,
|
||||||
|
allowWarehouseSelection: config.allowWarehouseSelection,
|
||||||
|
defaultWarehouseId: config.defaultWarehouseId,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
etag ?? "",
|
||||||
|
)
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
toast.success("Configuration saved", message)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save configuration", errorMessage(err))
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Configuration</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Choose how sales are raised in this deployment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||||
|
|
||||||
|
{!error && config && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Customers</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowCashCustomer}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
saveFlag({ allowCashCustomer: next }, `Cash customer ${next ? "allowed" : "not allowed"}.`)
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow cash customer"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow cash customer</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: New Sale starts on "{config.cashCustomerName ?? "Cash Customer"}" for walk-in sales,
|
||||||
|
and any customer can still be picked from the dropdown. Off: every sale must pick a
|
||||||
|
customer from the dropdown.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<Settings className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowFreeItems}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
saveFlag({ allowFreeItems: next }, `Free items ${next ? "allowed" : "not allowed"}.`)
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow free items"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow free items</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: sale item lines show a Free qty column, auto-filled from active Free Issue
|
||||||
|
deals and editable. Off: the Free column is hidden and sales can't include free
|
||||||
|
items.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowDiscounts}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
saveFlag({ allowDiscounts: next }, `Discounts ${next ? "allowed" : "not allowed"}.`)
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow discounts"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow discounts</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: sale item lines show a Disc % column for a line discount. Off: the Disc % column
|
||||||
|
is hidden and sales can't include line discounts.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowOverStockSale}
|
||||||
|
onCheckedChange={(next) =>
|
||||||
|
saveFlag({ allowOverStockSale: next }, `Selling beyond stock ${next ? "allowed" : "not allowed"}.`)
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow selling beyond stock"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow selling beyond stock</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: a sale can be posted even when a line's quantity is more than the stock in its
|
||||||
|
warehouse — stock goes negative and is settled from the next stock received. Off: a
|
||||||
|
sale can't be posted until there is enough stock for every line.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<Settings className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Warehouse</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowWarehouseSelection}
|
||||||
|
onCheckedChange={(next) => {
|
||||||
|
// Turning selection off needs somewhere for sales to go — the server rejects it too.
|
||||||
|
if (!next && !config.defaultWarehouseId) {
|
||||||
|
toast.error("Pick a default warehouse first", "Choose the default sales warehouse below, then turn this off.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveFlag({ allowWarehouseSelection: next }, `Warehouse selection ${next ? "enabled" : "disabled"}.`)
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow warehouse selection"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow warehouse selection</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: with no default warehouse saved below, Create Sale shows a Warehouse picker.
|
||||||
|
Off: a default warehouse must be saved. Whenever a default is saved, Create Sale
|
||||||
|
shows only that warehouse.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
|
||||||
|
<span className="text-base font-medium text-foreground">Default sales warehouse</span>
|
||||||
|
<Select
|
||||||
|
value={config.defaultWarehouseId ? String(config.defaultWarehouseId) : ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return
|
||||||
|
const picked = warehouses.find((w) => w.warehouseId === Number(v))
|
||||||
|
saveFlag({ defaultWarehouseId: Number(v) }, `Default sales warehouse: ${picked?.name ?? v}.`)
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder={config.defaultWarehouseName ?? "Select warehouse"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||||
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* The warehouse list can be branch-scoped — keep the saved default selectable
|
||||||
|
(and labelled in the trigger) even when it isn't in this user's list. */}
|
||||||
|
{config.defaultWarehouseId && !savedDefaultInList && (
|
||||||
|
<SelectItem value={String(config.defaultWarehouseId)} className="text-base">
|
||||||
|
{config.defaultWarehouseName ?? `Warehouse #${config.defaultWarehouseId}`}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.defaultWarehouseId ? (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
Create Sale shows only: <span className="font-medium">{savedDefaultLabel}</span>
|
||||||
|
</span>
|
||||||
|
{/* Clearing brings the picker back — only possible while selection is allowed. */}
|
||||||
|
{config.allowWarehouseSelection && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => saveFlag({ defaultWarehouseId: null }, "Default sales warehouse cleared.")}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Not set — Create Sale lets the user pick a warehouse. Required to turn warehouse
|
||||||
|
selection off.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.updatedAt && (
|
||||||
|
<p className="pt-2 text-sm text-muted-foreground">
|
||||||
|
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||||
|
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, Geist_Mono } from "next/font/google";
|
import { Inter, Geist_Mono } from "next/font/google";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
|
import { RouteProgress } from "@/components/Layouts/RouteProgress";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -31,6 +33,10 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
{/* useSearchParams needs a Suspense boundary to keep pages statically renderable. */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RouteProgress />
|
||||||
|
</Suspense>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,14 +43,12 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Receipt,
|
Receipt,
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
Ruler,
|
|
||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Tag,
|
|
||||||
Truck,
|
Truck,
|
||||||
Undo2,
|
Undo2,
|
||||||
Users,
|
Users,
|
||||||
@@ -73,11 +71,7 @@ const navItems: {
|
|||||||
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
||||||
* stays the section prefix used to decide whether this row is "active". */
|
* stays the section prefix used to decide whether this row is "active". */
|
||||||
landingHref?: string
|
landingHref?: string
|
||||||
/** This item's own href has no page of its own (no landingHref either) — clicking the
|
|
||||||
* row should only expand/collapse its children, never navigate. */
|
|
||||||
expandOnly?: boolean
|
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
chevron?: boolean
|
|
||||||
children?: {
|
children?: {
|
||||||
title: string
|
title: string
|
||||||
code: string
|
code: string
|
||||||
@@ -94,12 +88,16 @@ const navItems: {
|
|||||||
code: "products",
|
code: "products",
|
||||||
href: "/dashboard/products",
|
href: "/dashboard/products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
// Category, Brand and UOM share one tabbed page — any of the three permissions shows it.
|
||||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
{
|
||||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
title: "Category/Brand/UOM",
|
||||||
|
code: "products.category",
|
||||||
|
href: "/dashboard/products/categories",
|
||||||
|
icon: ListTree,
|
||||||
|
altCodes: ["products.brand", "products.uom"],
|
||||||
|
},
|
||||||
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -111,7 +109,6 @@ const navItems: {
|
|||||||
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
|
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
|
||||||
landingHref: "/dashboard/procurement/purchase-orders",
|
landingHref: "/dashboard/procurement/purchase-orders",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||||
@@ -124,9 +121,11 @@ const navItems: {
|
|||||||
href: "/dashboard/sales",
|
href: "/dashboard/sales",
|
||||||
landingHref: "/dashboard/sales/invoices",
|
landingHref: "/dashboard/sales/invoices",
|
||||||
icon: ReceiptText,
|
icon: ReceiptText,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||||
|
// Shortcut straight to the New Sale form — reuses the sales.invoices permission code (same
|
||||||
|
// access as the Sales list), so it needs no seeded SubNavItem of its own.
|
||||||
|
{ title: "Create Sale", code: "sales.invoices", href: "/dashboard/sales/invoices/new", icon: ShoppingCart },
|
||||||
// Reuses the sales.invoices permission code — customers aren't yet their own
|
// Reuses the sales.invoices permission code — customers aren't yet their own
|
||||||
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
|
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
|
||||||
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
|
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
|
||||||
@@ -137,7 +136,7 @@ const navItems: {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
||||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck },
|
||||||
{
|
{
|
||||||
title: "Stock",
|
title: "Stock",
|
||||||
code: "stock",
|
code: "stock",
|
||||||
@@ -146,7 +145,6 @@ const navItems: {
|
|||||||
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||||
landingHref: "/dashboard/stock/ledger",
|
landingHref: "/dashboard/stock/ledger",
|
||||||
icon: Warehouse,
|
icon: Warehouse,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||||
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||||
@@ -158,14 +156,13 @@ const navItems: {
|
|||||||
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2 },
|
||||||
{
|
{
|
||||||
title: "Production",
|
title: "Production",
|
||||||
code: "production",
|
code: "production",
|
||||||
href: "/dashboard/production",
|
href: "/dashboard/production",
|
||||||
landingHref: "/dashboard/production/runs",
|
landingHref: "/dashboard/production/runs",
|
||||||
icon: Factory,
|
icon: Factory,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
||||||
@@ -178,7 +175,6 @@ const navItems: {
|
|||||||
href: "/dashboard/hrm",
|
href: "/dashboard/hrm",
|
||||||
landingHref: "/dashboard/hrm/employees",
|
landingHref: "/dashboard/hrm/employees",
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
|
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
|
||||||
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
||||||
@@ -193,7 +189,6 @@ const navItems: {
|
|||||||
code: "ledgers",
|
code: "ledgers",
|
||||||
href: "/dashboard/ledgers",
|
href: "/dashboard/ledgers",
|
||||||
icon: Landmark,
|
icon: Landmark,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
|
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
|
||||||
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
|
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
|
||||||
@@ -209,7 +204,6 @@ const navItems: {
|
|||||||
code: "accounts",
|
code: "accounts",
|
||||||
href: "/dashboard/accounts",
|
href: "/dashboard/accounts",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
|
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
|
||||||
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
|
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
|
||||||
@@ -220,9 +214,7 @@ const navItems: {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
code: "settings",
|
code: "settings",
|
||||||
href: "/dashboard/settings",
|
href: "/dashboard/settings",
|
||||||
expandOnly: true,
|
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
chevron: true,
|
|
||||||
children: [
|
children: [
|
||||||
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
|
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
|
||||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||||
@@ -329,49 +321,46 @@ function SidebarContent({
|
|||||||
isActive ? "bg-primary/10" : "hover:bg-muted"
|
isActive ? "bg-primary/10" : "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
{hasChildren ? (
|
||||||
href={item.landingHref ?? item.href}
|
// Parents with sub-items only expand/collapse on click — navigation
|
||||||
title={iconOnly ? item.title : undefined}
|
// happens through the sub-items themselves.
|
||||||
onClick={() => {
|
|
||||||
onClose?.()
|
|
||||||
if (hasChildren) setExpanded((prev) => (prev[item.code] ? prev : { ...prev, [item.code]: true }))
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
|
|
||||||
iconOnly && "justify-center px-0",
|
|
||||||
isActive ? "text-primary" : "text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
|
||||||
/>
|
|
||||||
{!iconOnly && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1">{item.title}</span>
|
|
||||||
{item.chevron && !hasChildren && (
|
|
||||||
<ChevronRight
|
|
||||||
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{hasChildren && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleExpand(item.code)}
|
onClick={() => toggleExpand(item.code)}
|
||||||
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
|
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
|
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-left text-base font-normal",
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
isActive ? "text-primary" : "text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{item.title}</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
|
className={cn(
|
||||||
|
"size-4 shrink-0 transition-transform duration-300 ease-in-out",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={item.landingHref ?? item.href}
|
||||||
|
title={iconOnly ? item.title : undefined}
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
|
||||||
|
iconOnly && "justify-center px-0",
|
||||||
|
isActive ? "text-primary" : "text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||||
|
/>
|
||||||
|
{!iconOnly && <span className="flex-1">{item.title}</span>}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ function titleFromPath(pathname: string) {
|
|||||||
|
|
||||||
if (pathname === "/dashboard/products") return "Items"
|
if (pathname === "/dashboard/products") return "Items"
|
||||||
if (pathname === "/dashboard/products/new") return "New Item"
|
if (pathname === "/dashboard/products/new") return "New Item"
|
||||||
if (pathname === "/dashboard/products/categories") return "Categories"
|
if (pathname === "/dashboard/products/categories") return "Category/Brand/UOM"
|
||||||
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
|
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
|
||||||
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
|
if (pathname === "/dashboard/products/item-types") return "Item Variants"
|
||||||
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
if (pathname === "/dashboard/products/warranty-periods") return "Warranty Periods"
|
||||||
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
||||||
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
|
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
|
||||||
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
|
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin top-of-page progress bar for client-side navigations.
|
||||||
|
*
|
||||||
|
* The App Router has no "navigation started" event, so the start is detected from clicks on
|
||||||
|
* internal links (capture phase, before next/link handles them) and back/forward. The route
|
||||||
|
* committing — pathname or query changing — finishes it. Programmatic router.push() isn't
|
||||||
|
* seen at start; app/dashboard/loading.tsx covers those with an in-content spinner.
|
||||||
|
*/
|
||||||
|
export function RouteProgress() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
// Set by the mount effect; lets the route-change effect end the bar.
|
||||||
|
const finishRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = false
|
||||||
|
let trickle: ReturnType<typeof setInterval> | null = null
|
||||||
|
let safety: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let hide: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (trickle) clearInterval(trickle)
|
||||||
|
if (safety) clearTimeout(safety)
|
||||||
|
if (hide) clearTimeout(hide)
|
||||||
|
trickle = safety = hide = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (active) return
|
||||||
|
active = true
|
||||||
|
clearTimers()
|
||||||
|
setVisible(true)
|
||||||
|
setProgress(8)
|
||||||
|
// Ease toward 90% — never reaches the end until the route actually commits.
|
||||||
|
trickle = setInterval(() => setProgress((p) => (p < 90 ? p + (90 - p) * 0.1 : p)), 200)
|
||||||
|
// Never leave a bar hanging if a navigation is cancelled or fails silently.
|
||||||
|
safety = setTimeout(finish, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
clearTimers()
|
||||||
|
setProgress(100)
|
||||||
|
hide = setTimeout(() => {
|
||||||
|
setVisible(false)
|
||||||
|
setProgress(0)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
finishRef.current = finish
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||||
|
const anchor = (e.target as Element | null)?.closest?.("a")
|
||||||
|
if (!anchor || !anchor.href) return
|
||||||
|
if (anchor.target && anchor.target !== "_self") return
|
||||||
|
if (anchor.hasAttribute("download")) return
|
||||||
|
|
||||||
|
const next = new URL(anchor.href, window.location.href)
|
||||||
|
if (next.origin !== window.location.origin) return
|
||||||
|
// API downloads (e.g. document links) aren't page navigations.
|
||||||
|
if (next.pathname.startsWith("/api/")) return
|
||||||
|
// Same page (or hash-only jump) — nothing will commit, so don't start.
|
||||||
|
if (next.pathname === window.location.pathname && next.search === window.location.search) return
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", onClick, true)
|
||||||
|
window.addEventListener("popstate", start)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", onClick, true)
|
||||||
|
window.removeEventListener("popstate", start)
|
||||||
|
clearTimers()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Route committed ⇒ done. Keyed on the full URL so query-only navigations count too.
|
||||||
|
const url = `${pathname}?${searchParams.toString()}`
|
||||||
|
useEffect(() => {
|
||||||
|
finishRef.current()
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none fixed inset-x-0 top-0 z-100 h-0.5 print:hidden"
|
||||||
|
style={{ opacity: visible ? 1 : 0, transition: "opacity 250ms ease" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary shadow-[0_0_8px_var(--color-primary)]"
|
||||||
|
style={{ width: `${progress}%`, transition: "width 200ms ease-out" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Plus, Tag } from "lucide-react"
|
||||||
|
|
||||||
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { validateBrandName } from "@/lib/validations/master-data"
|
||||||
|
import { Brand } 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"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/** Brand master — the Brand tab of the Category/Brand/UOM page. */
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"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 { 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"
|
||||||
|
|
||||||
|
type SortOrder = "asc" | "desc"
|
||||||
|
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||||
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
|
||||||
|
/** Item category master (FR-MD-04) — the Category tab of the Category/Brand/UOM page. */
|
||||||
|
export function CategoryPanel() {
|
||||||
|
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState("")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
).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 })
|
||||||
|
.then((res) => {
|
||||||
|
setCategories(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [search, status, page, pageSize])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
order,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
order: SortOrder
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { ImageIcon, ImagePlus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { ItemImage } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/** Mirrors the server's rules (ItemImageService) so a bad pick fails fast, before any upload. */
|
||||||
|
export const ITEM_IMAGE_ACCEPT = "image/jpeg,image/png,image/webp"
|
||||||
|
export const ITEM_IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
export const ITEM_IMAGE_MAX_COUNT = 10
|
||||||
|
|
||||||
|
/** Client-side pre-check; returns a reason the file can't be used, or null. */
|
||||||
|
export function itemImageProblem(file: File): string | null {
|
||||||
|
if (!ITEM_IMAGE_ACCEPT.split(",").includes(file.type)) return `${file.name}: use a JPG, PNG or WebP image.`
|
||||||
|
if (file.size > ITEM_IMAGE_MAX_BYTES) return `${file.name}: larger than 5 MB.`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photos for one existing item. Read-only unless `editable` (the item's Edit dialog) — there
|
||||||
|
* images can be removed, and added too when `canUpload` (ProductConfig.itemImagesEnabled).
|
||||||
|
* Changes apply immediately, not on the dialog's Save. Hides itself when there is nothing
|
||||||
|
* to show and nothing the user could do.
|
||||||
|
*/
|
||||||
|
export function ItemImagesPanel({
|
||||||
|
itemId,
|
||||||
|
editable = false,
|
||||||
|
canUpload = false,
|
||||||
|
}: {
|
||||||
|
itemId: number
|
||||||
|
editable?: boolean
|
||||||
|
canUpload?: boolean
|
||||||
|
}) {
|
||||||
|
const allowUpload = editable && canUpload
|
||||||
|
const [images, setImages] = useState<ItemImage[] | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(0)
|
||||||
|
const [viewing, setViewing] = useState<ItemImage | null>(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<ItemImage | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
itemsApi
|
||||||
|
.listImages(itemId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setImages(res)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setImages([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [itemId])
|
||||||
|
|
||||||
|
async function handleFiles(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const room = ITEM_IMAGE_MAX_COUNT - (images?.length ?? 0)
|
||||||
|
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 toUpload: File[] = []
|
||||||
|
for (const file of picked.slice(0, Math.max(room, 0))) {
|
||||||
|
const problem = itemImageProblem(file)
|
||||||
|
if (problem) toast.error("Image skipped", problem)
|
||||||
|
else toUpload.push(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(toUpload.length)
|
||||||
|
for (const file of toUpload) {
|
||||||
|
try {
|
||||||
|
const image = await itemsApi.uploadImage(itemId, file)
|
||||||
|
setImages((prev) => [...(prev ?? []), image])
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Could not upload ${file.name}`, errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setUploading((n) => n - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirmDelete) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await itemsApi.deleteImage(itemId, confirmDelete.itemImageId)
|
||||||
|
setImages((prev) => (prev ?? []).filter((i) => i.itemImageId !== confirmDelete.itemImageId))
|
||||||
|
if (viewing?.itemImageId === confirmDelete.itemImageId) setViewing(null)
|
||||||
|
setConfirmDelete(null)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not remove image", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images !== null && images.length === 0 && !allowUpload) return null
|
||||||
|
|
||||||
|
const full = (images?.length ?? 0) >= ITEM_IMAGE_MAX_COUNT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="size-4.5 text-primary" />
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Images</h2>
|
||||||
|
{images && images.length > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{images.length} / {ITEM_IMAGE_MAX_COUNT}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images === null ? (
|
||||||
|
<Skeleton className="h-28 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{images.map((img, index) => (
|
||||||
|
<div key={img.itemImageId} className="group relative aspect-square overflow-hidden rounded-lg border bg-muted">
|
||||||
|
<button type="button" className="size-full" onClick={() => setViewing(img)} aria-label={`View ${img.fileName}`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset */}
|
||||||
|
<img src={itemsApi.imageUrl(itemId, img.itemImageId)} alt={img.fileName} className="size-full object-cover" />
|
||||||
|
</button>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(img)}
|
||||||
|
aria-label={`Remove ${img.fileName}`}
|
||||||
|
className="absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-md bg-background/90 text-destructive opacity-100 shadow-sm transition-opacity hover:bg-background sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from({ length: uploading }).map((_, i) => (
|
||||||
|
<Skeleton key={`up-${i}`} className="aspect-square w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{allowUpload && !full && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading > 0}
|
||||||
|
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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ImagePlus className="size-6" />
|
||||||
|
<span className="text-xs font-medium">Add images</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{allowUpload && "JPG, PNG or WebP, up to 5 MB each. The first image is the main one. "}
|
||||||
|
Image changes are saved immediately.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowUpload && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ITEM_IMAGE_ACCEPT}
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files)
|
||||||
|
e.target.value = "" // allow re-picking the same file
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="truncate">{viewing?.fileName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewing && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset
|
||||||
|
<img
|
||||||
|
src={itemsApi.imageUrl(itemId, viewing.itemImageId)}
|
||||||
|
alt={viewing.fileName}
|
||||||
|
className="max-h-[70vh] w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove image?</DialogTitle>
|
||||||
|
<DialogDescription>{confirmDelete?.fileName} will be permanently deleted from this item.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmDelete(null)} disabled={deleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting ? "Removing…" : "Remove"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Plus, Ruler } from "lucide-react"
|
||||||
|
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { validateUomName } from "@/lib/validations/master-data"
|
||||||
|
import { Uom } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
// Large enough to show every UOM in one grid — the chip layout has no pagination.
|
||||||
|
const ALL_ITEMS_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
/** Unit-of-measure master — the UOM tab of the Category/Brand/UOM page. */
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export interface ScanInputProps {
|
|||||||
hint?: string
|
hint?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
/** Grab focus on mount/re-enable (default true). Turn off when another field on the page
|
||||||
|
* should get the cursor first (e.g. a type-ahead "Add item" box). */
|
||||||
|
autoFocus?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ export function ScanInput({
|
|||||||
hint = "Scan item or lot barcode",
|
hint = "Scan item or lot barcode",
|
||||||
placeholder = "Scan or type a barcode, then press Enter",
|
placeholder = "Scan or type a barcode, then press Enter",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
autoFocus = true,
|
||||||
className,
|
className,
|
||||||
}: ScanInputProps) {
|
}: ScanInputProps) {
|
||||||
const [value, setValue] = useState("")
|
const [value, setValue] = useState("")
|
||||||
@@ -39,8 +43,8 @@ export function ScanInput({
|
|||||||
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
|
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
|
||||||
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
|
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disabled) inputRef.current?.focus()
|
if (!disabled && autoFocus) inputRef.current?.focus()
|
||||||
}, [disabled])
|
}, [disabled, autoFocus])
|
||||||
|
|
||||||
function commit() {
|
function commit() {
|
||||||
const code = value.trim()
|
const code = value.trim()
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface FilterBarProps {
|
|||||||
advancedActive?: boolean
|
advancedActive?: boolean
|
||||||
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
|
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
|
||||||
onClearAdvanced?: () => void
|
onClearAdvanced?: () => void
|
||||||
|
/** Puts the search box on the left and groups `children` + "Advanced" together on the right. */
|
||||||
|
searchFirst?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,59 +35,76 @@ interface FilterBarProps {
|
|||||||
*/
|
*/
|
||||||
export function FilterBar({
|
export function FilterBar({
|
||||||
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
|
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
|
||||||
children, advanced, advancedActive, onClearAdvanced, className,
|
children, advanced, advancedActive, onClearAdvanced, searchFirst, className,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
|
const searchBox = (
|
||||||
|
<div className="relative w-full lg:max-w-sm">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const advancedPopover = advanced && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||||
|
<SlidersHorizontal className="size-4" />
|
||||||
|
Advanced
|
||||||
|
{advancedActive && (
|
||||||
|
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent align="end" className="w-72 p-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{advanced}
|
||||||
|
{onClearAdvanced && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||||
|
Clear advanced filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (searchFirst) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||||
|
{searchBox}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||||
|
{children}
|
||||||
|
{advancedPopover}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
|
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||||
<div className="relative w-full lg:max-w-sm">
|
{searchBox}
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
{advancedPopover}
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
className="h-10 pl-9 pr-9"
|
|
||||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSearchChange("")}
|
|
||||||
aria-label="Clear search"
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{advanced && (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
Advanced
|
|
||||||
{advancedActive && (
|
|
||||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="end" className="w-72 p-4">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{advanced}
|
|
||||||
{onClearAdvanced && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
|
||||||
Clear advanced filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { ChevronDown, Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
export interface SuggestOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuggestInputProps {
|
||||||
|
options: SuggestOption[]
|
||||||
|
/** Called with the picked option's value; the box then clears for the next entry. */
|
||||||
|
onSelect: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
maxResults?: number
|
||||||
|
/** Enter with text but no suggestion to pick (e.g. a barcode from a USB scanner, which types
|
||||||
|
* the code then presses Enter) — receives the typed text; the box then clears. */
|
||||||
|
onEnterWithoutMatch?: (text: string) => void
|
||||||
|
autoFocus?: boolean
|
||||||
|
/** Also works as a dropdown: clicking the box (or its arrow, or pressing ↓) lists every option
|
||||||
|
* before anything is typed. Off by default, so plain type-ahead boxes stay quiet on focus. */
|
||||||
|
browsable?: boolean
|
||||||
|
/** With `browsable`: after a pick the box stays focused, keeps its (selected) search text and
|
||||||
|
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||||
|
keepOpenOnSelect?: boolean
|
||||||
|
className?: string
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-ahead "add" box: shows no list until the user types, then suggests matching options
|
||||||
|
* below the input. Every typed word must appear in the label (so "bolt 10" finds
|
||||||
|
* "BLT-10 — Hex bolt 10mm"); labels starting with the query rank first. Picking one fires
|
||||||
|
* `onSelect` and clears the box, so it works as a quick "add another" field rather than a
|
||||||
|
* value holder.
|
||||||
|
*/
|
||||||
|
export function SuggestInput({
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "Type to search…",
|
||||||
|
emptyText = "No matches.",
|
||||||
|
maxResults = 8,
|
||||||
|
onEnterWithoutMatch,
|
||||||
|
autoFocus,
|
||||||
|
browsable = false,
|
||||||
|
keepOpenOnSelect = false,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: SuggestInputProps) {
|
||||||
|
const [query, setQuery] = React.useState("")
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
// Set only by an explicit click/arrow/↓ (never by plain focus), so an autoFocused box doesn't
|
||||||
|
// pop its full list open on page load.
|
||||||
|
const [browsing, setBrowsing] = React.useState(false)
|
||||||
|
const [highlight, setHighlight] = React.useState(0)
|
||||||
|
const listId = React.useId()
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const matches = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return browsing ? options : []
|
||||||
|
const words = q.split(/\s+/)
|
||||||
|
return options
|
||||||
|
.filter((o) => {
|
||||||
|
const label = o.label.toLowerCase()
|
||||||
|
return words.every((w) => label.includes(w))
|
||||||
|
})
|
||||||
|
.sort((a, b) => Number(b.label.toLowerCase().startsWith(q)) - Number(a.label.toLowerCase().startsWith(q)))
|
||||||
|
.slice(0, maxResults)
|
||||||
|
}, [options, query, maxResults, browsing])
|
||||||
|
|
||||||
|
const showList = open && (query.trim().length > 0 || browsing)
|
||||||
|
|
||||||
|
function openBrowse() {
|
||||||
|
if (!browsable) return
|
||||||
|
setBrowsing(true)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setOpen(false)
|
||||||
|
setBrowsing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the keyboard-highlighted option visible when arrowing through a long browse list.
|
||||||
|
React.useEffect(() => {
|
||||||
|
listRef.current?.querySelector(`[data-idx="${highlight}"]`)?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, [highlight])
|
||||||
|
|
||||||
|
// The list is portalled to <body> with fixed positioning so it isn't clipped when the box sits
|
||||||
|
// inside a scroll container (e.g. a table wrapped in overflow-x-auto) — so track the box's
|
||||||
|
// on-screen position while it's open.
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [rect, setRect] = React.useState<DOMRect | null>(null)
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!showList) return
|
||||||
|
const update = () => setRect(wrapperRef.current?.getBoundingClientRect() ?? null)
|
||||||
|
update()
|
||||||
|
window.addEventListener("scroll", update, true)
|
||||||
|
window.addEventListener("resize", update)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", update, true)
|
||||||
|
window.removeEventListener("resize", update)
|
||||||
|
}
|
||||||
|
}, [showList])
|
||||||
|
|
||||||
|
// The box can also move without any scroll/resize — e.g. a pick adds a table row above it and
|
||||||
|
// pushes it down — so re-measure after every render too (only storing a real change, so this
|
||||||
|
// can't loop).
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!showList) return
|
||||||
|
const next = wrapperRef.current?.getBoundingClientRect()
|
||||||
|
if (next && (next.bottom !== rect?.bottom || next.left !== rect?.left || next.width !== rect?.width)) setRect(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
function choose(option: SuggestOption) {
|
||||||
|
onSelect(option.value)
|
||||||
|
if (browsable && keepOpenOnSelect) {
|
||||||
|
// Keep the search text (and its filtered list) so similar items can be added one after
|
||||||
|
// another; the text is selected, so simply typing replaces it with a new search. The
|
||||||
|
// just-picked option stays highlighted for a quick repeat.
|
||||||
|
setBrowsing(true)
|
||||||
|
setOpen(true)
|
||||||
|
setHighlight(Math.max(matches.findIndex((o) => o.value === option.value), 0))
|
||||||
|
wrapperRef.current?.querySelector("input")?.select()
|
||||||
|
} else {
|
||||||
|
setQuery("")
|
||||||
|
close()
|
||||||
|
setHighlight(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
if (!showList) {
|
||||||
|
openBrowse()
|
||||||
|
setHighlight(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setHighlight((h) => Math.min(h + 1, Math.max(matches.length - 1, 0)))
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlight((h) => Math.max(h - 1, 0))
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
// Never submit a surrounding form from here — Enter only picks a suggestion.
|
||||||
|
e.preventDefault()
|
||||||
|
const option = matches[highlight]
|
||||||
|
if (option) {
|
||||||
|
choose(option)
|
||||||
|
} else if (onEnterWithoutMatch && query.trim()) {
|
||||||
|
onEnterWithoutMatch(query.trim())
|
||||||
|
setQuery("")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className={cn("relative", className)}>
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
setHighlight(0)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onClick={openBrowse}
|
||||||
|
onBlur={close}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
// Stop the browser's own autofill history from dropping down "previous entries".
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className={cn("h-11 pl-9 text-sm", browsable && "pr-9")}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={showList}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-label={rest["aria-label"]}
|
||||||
|
/>
|
||||||
|
{browsable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Show all options"
|
||||||
|
// mousedown + preventDefault keeps focus in the input, so its blur doesn't close the list.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
wrapperRef.current?.querySelector("input")?.focus()
|
||||||
|
if (showList) close()
|
||||||
|
else {
|
||||||
|
openBrowse()
|
||||||
|
setHighlight(0)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn("size-4 transition-transform", showList && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showList && rect && createPortal(
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={listId}
|
||||||
|
role="listbox"
|
||||||
|
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||||
|
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||||
|
>
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||||
|
) : (
|
||||||
|
matches.map((option, idx) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
data-idx={idx}
|
||||||
|
aria-selected={idx === highlight}
|
||||||
|
// mousedown (not click) so the input's blur doesn't close the list first.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
choose(option)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlight(idx)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-sm",
|
||||||
|
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
|||||||
import {
|
import {
|
||||||
CreateItemRequest,
|
CreateItemRequest,
|
||||||
Item,
|
Item,
|
||||||
|
ItemImage,
|
||||||
ItemListItem,
|
ItemListItem,
|
||||||
ItemOverview,
|
ItemOverview,
|
||||||
ItemReorderSetting,
|
ItemReorderSetting,
|
||||||
ItemScanResult,
|
ItemScanResult,
|
||||||
|
StockNature,
|
||||||
TrackingMode,
|
TrackingMode,
|
||||||
UpdateItemReorderRequest,
|
UpdateItemReorderRequest,
|
||||||
UpdateItemRequest,
|
UpdateItemRequest,
|
||||||
@@ -34,8 +36,13 @@ export interface ListItemOverviewParams {
|
|||||||
categoryId?: number
|
categoryId?: number
|
||||||
subCategoryId?: number
|
subCategoryId?: number
|
||||||
brandId?: number
|
brandId?: number
|
||||||
|
trackingMode?: TrackingMode
|
||||||
|
stockNature?: StockNature
|
||||||
|
stock?: ItemStockFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ItemStockFilter = "InStock" | "OutOfStock" | "LowStock"
|
||||||
|
|
||||||
export const itemsApi = {
|
export const itemsApi = {
|
||||||
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
|
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
|
||||||
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
|
||||||
@@ -46,6 +53,25 @@ export const itemsApi = {
|
|||||||
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
|
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
|
||||||
return apiRequest<PagedResponse<ItemOverview>>(`/items/overview${buildQuery(params)}`)
|
return apiRequest<PagedResponse<ItemOverview>>(`/items/overview${buildQuery(params)}`)
|
||||||
},
|
},
|
||||||
|
listImages(itemId: number): Promise<ItemImage[]> {
|
||||||
|
return apiRequest<ItemImage[]>(`/items/${itemId}/images`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** JPG/PNG/WebP, ≤ 5 MB, ≤ 10 per item. 422 CONFIG_DISABLED while item images are off. */
|
||||||
|
uploadImage(itemId: number, file: File): Promise<ItemImage> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("file", file)
|
||||||
|
return apiRequest<ItemImage>(`/items/${itemId}/images`, { method: "POST", body: form })
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteImage(itemId: number, itemImageId: number): Promise<void> {
|
||||||
|
return apiRequest<void>(`/items/${itemId}/images/${itemImageId}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Same-origin and cookie-authenticated, so it works directly as an <img> src. */
|
||||||
|
imageUrl(itemId: number, itemImageId: number): string {
|
||||||
|
return `/api/v1/items/${itemId}/images/${itemImageId}/content`
|
||||||
|
},
|
||||||
|
|
||||||
get(itemId: number): Promise<ApiResult<Item>> {
|
get(itemId: number): Promise<ApiResult<Item>> {
|
||||||
return apiRequestWithETag<Item>(`/items/${itemId}`)
|
return apiRequestWithETag<Item>(`/items/${itemId}`)
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ export const productConfigApi = {
|
|||||||
/** All flags are required; a partial body is a 400, never a silent disable. */
|
/** All flags are required; a partial body is a 400, never a silent disable. */
|
||||||
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
|
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
|
||||||
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
|
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
|
||||||
},
|
},}
|
||||||
}
|
|
||||||
|
|
||||||
/** Read-only helper for screens that only need the flags. */
|
/** Read-only helper for screens that only need the flags. */
|
||||||
export function productConfig(): Promise<ProductConfig> {
|
export function productConfig(): Promise<ProductConfig> {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Sales configuration. Singleton - no id. When `allowCashCustomer` is on, sales can be raised
|
||||||
|
// against the generic "Cash Customer" (`cashCustomerId`) and New Sale defaults to it; when off,
|
||||||
|
// a real customer must be picked (the server rejects the cash customer).
|
||||||
|
import { apiRequestWithETag } from "@/lib/api-client"
|
||||||
|
import { ApiResult } from "@/types/common"
|
||||||
|
import { SalesConfig, UpdateSalesConfigRequest } from "@/types/sales"
|
||||||
|
|
||||||
|
export const salesConfigApi = {
|
||||||
|
get(): Promise<ApiResult<SalesConfig>> {
|
||||||
|
return apiRequestWithETag<SalesConfig>("/sales-config")
|
||||||
|
},
|
||||||
|
|
||||||
|
update(request: UpdateSalesConfigRequest, ifMatch: string): Promise<ApiResult<SalesConfig>> {
|
||||||
|
return apiRequestWithETag<SalesConfig>("/sales-config", { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
SalesSlipStatus,
|
SalesSlipStatus,
|
||||||
SalesSlipPostingCheck,
|
SalesSlipPostingCheck,
|
||||||
SalesSlipSummary,
|
SalesSlipSummary,
|
||||||
|
SalesStockAvailability,
|
||||||
|
SalesSuggestedPrice,
|
||||||
UpdateSalesInvoiceRequest,
|
UpdateSalesInvoiceRequest,
|
||||||
UpdateSalesSlipRequest,
|
UpdateSalesSlipRequest,
|
||||||
} from "@/types/sales"
|
} from "@/types/sales"
|
||||||
@@ -46,6 +48,16 @@ export const salesApi = {
|
|||||||
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
|
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
|
||||||
|
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
|
||||||
|
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Issuable stock for an item at a warehouse — New Sale's per-line shortage warning. */
|
||||||
|
getStockAvailability(itemId: number, warehouseId: number): Promise<SalesStockAvailability> {
|
||||||
|
return apiRequest<SalesStockAvailability>(`/sales-invoices/stock-availability${buildQuery({ itemId, warehouseId })}`)
|
||||||
|
},
|
||||||
|
|
||||||
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
|
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
|
||||||
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Custom warranty period master (Products → Warranty Periods). Defaults (3/6/12/18 months)
|
||||||
|
// are fixed client+server side and not returned here.
|
||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
import { WarrantyPeriod } from "@/types/master-data"
|
||||||
|
|
||||||
|
export const warrantyPeriodsApi = {
|
||||||
|
list(params: { status?: EntityStatus } = {}): Promise<WarrantyPeriod[]> {
|
||||||
|
return apiRequest<WarrantyPeriod[]>(`/warranty-periods${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Months 1–600, not a default. Re-adding an inactive length reactivates it; an active duplicate is 409. */
|
||||||
|
create(months: number): Promise<WarrantyPeriod> {
|
||||||
|
return apiRequest<WarrantyPeriod>("/warranty-periods", { method: "POST", body: { months } })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
|
||||||
|
updateStatus(warrantyPeriodId: number, status: EntityStatus): Promise<void> {
|
||||||
|
return apiRequest<void>(`/warranty-periods/${warrantyPeriodId}/status`, { method: "PATCH", body: { status } })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ export function validateTaxRate(code: string, name: string, percentage: number):
|
|||||||
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
|
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
|
||||||
export function validateItemTypeName(name: string): Record<string, string> {
|
export function validateItemTypeName(name: string): Record<string, string> {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
if (!name.trim()) errors.name = "Item type name is required"
|
if (!name.trim()) errors.name = "Item variant name is required"
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Warranty period helpers. Periods are always stored in months; custom ones are the active
|
||||||
|
// rows of the Warranty Periods master and are only offered while
|
||||||
|
// ProductConfig.customWarrantyPeriodsEnabled is on.
|
||||||
|
import { ProductConfig, WARRANTY_PERIOD_MONTHS_OPTIONS } from "@/types/master-data"
|
||||||
|
|
||||||
|
/** Server limit (WarrantyPeriodService) — mirrored so forms fail fast. */
|
||||||
|
export const MAX_CUSTOM_WARRANTY_MONTHS = 600
|
||||||
|
|
||||||
|
/** "6 months", "1 month", or "24 months (2 years)" when it's a whole number of years. */
|
||||||
|
export function formatWarrantyPeriod(months: number): string {
|
||||||
|
const base = `${months} month${months === 1 ? "" : "s"}`
|
||||||
|
if (months >= 12 && months % 12 === 0) {
|
||||||
|
const years = months / 12
|
||||||
|
return `${base} (${years} year${years === 1 ? "" : "s"})`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WarrantyInputUnit = "months" | "years"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a typed length ("2" + years) against the existing active custom periods and
|
||||||
|
* converts it to months. Returns the months, or an error message fit to show under the input.
|
||||||
|
*/
|
||||||
|
export function parseWarrantyInput(
|
||||||
|
amount: string,
|
||||||
|
unit: WarrantyInputUnit,
|
||||||
|
existingCustom: number[],
|
||||||
|
): { months: number; error?: undefined } | { months?: undefined; error: string } {
|
||||||
|
const n = Number(amount)
|
||||||
|
if (!amount.trim() || !Number.isInteger(n) || n < 1) return { error: "Enter a whole number of 1 or more." }
|
||||||
|
const months = unit === "years" ? n * 12 : n
|
||||||
|
if (months > MAX_CUSTOM_WARRANTY_MONTHS)
|
||||||
|
return { error: `A warranty period can be at most ${MAX_CUSTOM_WARRANTY_MONTHS} months (50 years).` }
|
||||||
|
if ((WARRANTY_PERIOD_MONTHS_OPTIONS as readonly number[]).includes(months))
|
||||||
|
return { error: `${formatWarrantyPeriod(months)} is already a default period.` }
|
||||||
|
if (existingCustom.includes(months)) return { error: `${formatWarrantyPeriod(months)} already exists.` }
|
||||||
|
return { months }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The period choices for an item: the defaults, plus the active custom ones when enabled. */
|
||||||
|
export function warrantyPeriodGroups(
|
||||||
|
config: ProductConfig | null,
|
||||||
|
customMonths: number[],
|
||||||
|
): { defaults: number[]; custom: number[] } {
|
||||||
|
const defaults: number[] = [...WARRANTY_PERIOD_MONTHS_OPTIONS]
|
||||||
|
const custom = config?.customWarrantyPeriodsEnabled
|
||||||
|
? [...new Set(customMonths)].filter((m) => !defaults.includes(m)).sort((a, b) => a - b)
|
||||||
|
: []
|
||||||
|
return { defaults, custom }
|
||||||
|
}
|
||||||
@@ -11,9 +11,10 @@ export type StockNature = "Stocked" | "NonStocked" | "Service"
|
|||||||
export type TrackingMode = "None" | "Batch" | "Serial"
|
export type TrackingMode = "None" | "Batch" | "Serial"
|
||||||
export type Warranty = "NonWarranty" | "Warranty"
|
export type Warranty = "NonWarranty" | "Warranty"
|
||||||
|
|
||||||
/** Warranty coverage lengths offered when an item is marked Warranty. */
|
/** Default warranty coverage lengths, always offered when an item is marked Warranty. */
|
||||||
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
|
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
|
||||||
export type WarrantyPeriodMonths = (typeof WARRANTY_PERIOD_MONTHS_OPTIONS)[number]
|
/** Months — a default above or an active {@link WarrantyPeriod}. */
|
||||||
|
export type WarrantyPeriodMonths = number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit of an item's content size — how much one stocked pack holds.
|
* Unit of an item's content size — how much one stocked pack holds.
|
||||||
@@ -364,11 +365,17 @@ export interface ProductConfig {
|
|||||||
serialNumbersEnabled: boolean
|
serialNumbersEnabled: boolean
|
||||||
batchListsEnabled: boolean
|
batchListsEnabled: boolean
|
||||||
warrantyPeriodsEnabled: boolean
|
warrantyPeriodsEnabled: boolean
|
||||||
|
/** Advisory like `itemTypesEnabled`: lets item create add a new item variant inline. */
|
||||||
|
customVariantsEnabled: boolean
|
||||||
|
/** Server-enforced on upload (`CONFIG_DISABLED`); existing images stay viewable when off. */
|
||||||
|
itemImagesEnabled: boolean
|
||||||
|
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
|
||||||
|
customWarrantyPeriodsEnabled: boolean
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
updatedBy: number | null
|
updatedBy: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All three flags are required: an omitted flag is a 400, never a silent disable. */
|
/** Every flag is required: an omitted flag is a 400, never a silent disable. */
|
||||||
export interface UpdateProductConfigRequest {
|
export interface UpdateProductConfigRequest {
|
||||||
subcategoriesEnabled: boolean
|
subcategoriesEnabled: boolean
|
||||||
brandsEnabled: boolean
|
brandsEnabled: boolean
|
||||||
@@ -377,6 +384,33 @@ export interface UpdateProductConfigRequest {
|
|||||||
serialNumbersEnabled: boolean
|
serialNumbersEnabled: boolean
|
||||||
batchListsEnabled: boolean
|
batchListsEnabled: boolean
|
||||||
warrantyPeriodsEnabled: boolean
|
warrantyPeriodsEnabled: boolean
|
||||||
|
customVariantsEnabled: boolean
|
||||||
|
itemImagesEnabled: boolean
|
||||||
|
customWarrantyPeriodsEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom warranty period master (Products → Warranty Periods) — offered alongside
|
||||||
|
* `WARRANTY_PERIOD_MONTHS_OPTIONS` while `customWarrantyPeriodsEnabled`. Always months;
|
||||||
|
* items store the months, not a reference, so deactivating only stops it being offered.
|
||||||
|
*/
|
||||||
|
export interface WarrantyPeriod {
|
||||||
|
warrantyPeriodId: number
|
||||||
|
months: number
|
||||||
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An item photo's metadata; the bytes come from `itemsApi.imageUrl`. */
|
||||||
|
export interface ItemImage {
|
||||||
|
itemImageId: number
|
||||||
|
itemId: number
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
sortOrder: number
|
||||||
|
uploadedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Barcode / RFID lot scanning --------------------------------------------------------
|
// --- Barcode / RFID lot scanning --------------------------------------------------------
|
||||||
|
|||||||
@@ -65,6 +65,51 @@ export interface SalesInvoicePostingIssue {
|
|||||||
isFreeIssue: boolean
|
isFreeIssue: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sales configuration singleton. `cashCustomerId` is null until cash sales have been allowed once. */
|
||||||
|
export interface SalesConfig {
|
||||||
|
allowCashCustomer: boolean
|
||||||
|
cashCustomerId: number | null
|
||||||
|
cashCustomerName: string | null
|
||||||
|
/** When off, sales invoice lines can't carry a free qty (the Free column is hidden). */
|
||||||
|
allowFreeItems: boolean
|
||||||
|
/** When off, sales invoice lines can't carry a discount (the Disc % column is hidden). */
|
||||||
|
allowDiscounts: boolean
|
||||||
|
/** When on, sales post even when stock is short (on-hand goes negative); off blocks posting. */
|
||||||
|
allowOverStockSale: boolean
|
||||||
|
/** When off, New Sale hides the Warehouse picker and every sale uses `defaultWarehouseId`. */
|
||||||
|
allowWarehouseSelection: boolean
|
||||||
|
defaultWarehouseId: number | null
|
||||||
|
defaultWarehouseName: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
updatedBy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesConfigRequest {
|
||||||
|
allowCashCustomer: boolean
|
||||||
|
allowFreeItems: boolean
|
||||||
|
allowDiscounts: boolean
|
||||||
|
allowOverStockSale: boolean
|
||||||
|
allowWarehouseSelection: boolean
|
||||||
|
/** Required when `allowWarehouseSelection` is false. */
|
||||||
|
defaultWarehouseId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issuable stock for one item at one warehouse. `isStocked` false = non-stock item, never short. */
|
||||||
|
export interface SalesStockAvailability {
|
||||||
|
itemId: number
|
||||||
|
warehouseId: number
|
||||||
|
isStocked: boolean
|
||||||
|
available: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSuggestedPrice {
|
||||||
|
itemId: number
|
||||||
|
warehouseId: number
|
||||||
|
unitPrice: number
|
||||||
|
/** SALE_PRICE | GRN_WEIGHTED_AVG | FIFO_AVG */
|
||||||
|
priceSource: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SalesInvoicePostingCheck {
|
export interface SalesInvoicePostingCheck {
|
||||||
salesInvoiceId: number
|
salesInvoiceId: number
|
||||||
invoiceNo: string
|
invoiceNo: string
|
||||||
|
|||||||
Reference in New Issue
Block a user