Compare commits

..

6 Commits

Author SHA1 Message Date
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
Sasanka cea433e320 product and sales fixers 2026-09-30 17:06:28 +05:30
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
ImanThiyanga 9fd290e256 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m38s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m1s
2026-09-25 09:03:39 +00:00
ImanThiyanga 20ecfdc0b2 Merge pull request 'fixing recipts in sales' (#90) from sales-changes into Dev
Reviewed-on: #90
2026-09-25 09:03:21 +00:00
ImanThiyanga ce55d647b5 Merge pull request 'fron build failure fix' (#89) from Dev into production
Reviewed-on: #89
2026-09-25 06:58:55 +00:00
98 changed files with 85367 additions and 1516 deletions
@@ -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 &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
}}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -14,11 +14,60 @@ public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// 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]
@@ -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 WarrantyPeriodsEnabled { get; set; } = true;
/// <summary>
/// Lets the item create screen add a new item variant (item type) on the spot, instead
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
/// </summary>
public bool CustomVariantsEnabled { get; set; }
/// <summary>
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
/// </summary>
public bool ItemImagesEnabled { get; set; }
/// <summary>
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
/// themselves are managed under Products, not here.
/// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -0,0 +1,61 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
/// form defaults to it; when off, a real customer must be picked and the cash customer is
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
/// </summary>
public class SalesConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool AllowCashCustomer { get; set; }
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
/// off and on again reuses the same customer (and its sales history).</summary>
public int? CashCustomerId { get; set; }
public Customer? CashCustomer { get; set; }
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowFreeItems { get; set; } = true;
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
/// column); when off, the column is hidden and a discount above zero is rejected server-side
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
/// this option existed.</summary>
public bool AllowDiscounts { get; set; } = true;
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
public bool AllowOverStockSale { get; set; }
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
/// a Warehouse picker. When off, a default must be saved.</summary>
public bool AllowWarehouseSelection { get; set; } = true;
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
/// and every sales invoice (header and lines) is forced onto it server-side (see
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+6
View File
@@ -15,6 +15,12 @@ public class User
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
/// <summary>
/// Username prefix of a permanently deleted login whose row is kept only because history
/// (audit log, documents) still references it. Such rows are hidden from user management.
/// </summary>
public const string DeletedUsernamePrefix = "deleted-user-";
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Custom warranty period master — a coverage length offered to items alongside the fixed
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
/// <para>
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
/// deactivating a period only stops it being offered — items already using it keep it.
/// The length is immutable (no PUT); a different length is a different row.
/// </para>
/// </summary>
public class WarrantyPeriod
{
public int WarrantyPeriodId { get; set; }
public int Months { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
/// </summary>
public sealed class UpdateSalesConfigRequest
{
[Required] public bool? AllowCashCustomer { get; set; }
[Required] public bool? AllowFreeItems { get; set; }
[Required] public bool? AllowDiscounts { get; set; }
[Required] public bool? AllowOverStockSale { get; set; }
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
}
@@ -0,0 +1,7 @@
namespace ERPCore.Dtos.Items;
/// <summary>An item photo's metadata. The bytes are served by
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
public sealed record ItemImageDto(
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
int SortOrder, DateTime UploadedAt);
@@ -16,3 +16,12 @@ public sealed record ItemOverviewDto(
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
public enum ItemStockFilter
{
InStock,
OutOfStock,
LowStock
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>A new custom length, always in months (the client converts years).</summary>
public sealed class CreateWarrantyPeriodRequest
{
[Required] public int? Months { get; set; }
}
public sealed class UpdateWarrantyPeriodStatusRequest
{
[Required] public EntityStatus Status { get; set; }
}
@@ -44,6 +44,14 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
/// <summary>The unit price a new line would get if left blank — same rule as on save
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
public sealed class CreateSalesInvoiceLineRequest
{
[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.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -0,0 +1,49 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton sales-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
/// </summary>
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
{
public void Configure(EntityTypeBuilder<SalesConfig> builder)
{
builder.ToTable("sales_config", t =>
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.AllowCashCustomer).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
// instead of being swallowed by the database default.
builder.Property(c => c.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
.HasForeignKey(c => c.DefaultWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.CashCustomer)
.WithMany()
.HasForeignKey(c => c.CashCustomerId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
{
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
{
builder.ToTable("warranty_periods", t =>
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
builder.HasKey(p => p.WarrantyPeriodId);
builder.Property(p => p.Months).IsRequired();
builder.HasIndex(p => p.Months).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasIndex(p => p.Status);
}
}
@@ -46,6 +46,7 @@ public static class DataSeeder
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -106,7 +107,10 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false
});
return true;
}
@@ -128,6 +132,18 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
/// </summary>
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
@@ -43,6 +45,7 @@ public class ErpDbContext : DbContext
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sales_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
AllowCashCustomer = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
CashCustomerId = table.Column<int>(type: "integer", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_config", x => x.ConfigId);
table.CheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_sales_config_customers_CashCustomerId",
column: x => x.CashCustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sales_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_config_CashCustomerId",
table: "sales_config",
column: "CashCustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_config_UpdatedBy",
table: "sales_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sales_config");
}
}
}
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");
}
}
}
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");
}
}
}
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");
}
}
}
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");
}
}
}
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");
}
}
}
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");
}
}
}
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);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.Property<int>("ItemImageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("StoredFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UploadedBy")
.HasColumnType("integer");
b.HasKey("ItemImageId");
b.HasIndex("ItemId", "SortOrder");
b.ToTable("item_images", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<int>("ReorderId")
@@ -3252,6 +3302,21 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("CustomVariantsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("CustomWarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemImagesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemTypesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -4337,6 +4402,68 @@ namespace ERPCore.Migrations
b.ToTable("hr_salary_components", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("AllowCashCustomer")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowDiscounts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowOverStockSale")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowWarehouseSelection")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("CashCustomerId")
.HasColumnType("integer");
b.Property<int?>("DefaultWarehouseId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("CashCustomerId");
b.HasIndex("DefaultWarehouseId");
b.HasIndex("UpdatedBy");
b.ToTable("sales_config", null, t =>
{
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Property<int>("SalesDayEndId")
@@ -6593,6 +6720,43 @@ namespace ERPCore.Migrations
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
{
b.Property<int>("WarrantyPeriodId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("WarrantyPeriodId");
b.HasIndex("Months")
.IsUnique();
b.HasIndex("Status");
b.ToTable("warranty_periods", null, t =>
{
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{
b.Property<int>("WorkShiftId")
@@ -7145,6 +7309,17 @@ namespace ERPCore.Migrations
b.Navigation("TaxRate");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -7835,6 +8010,30 @@ namespace ERPCore.Migrations
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
.WithMany()
.HasForeignKey("CashCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
.WithMany()
.HasForeignKey("DefaultWarehouseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("DefaultWarehouse");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
+3
View File
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IItemImageService, ItemImageService>();
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
@@ -95,6 +97,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
}
catch (DbUpdateException)
{
// Every save a user makes writes an AuditLog row against their UserId, so almost any
// real user is still referenced by history and can't be hard-deleted. The login is
// still permanently removed: the row is kept only as a tombstone so historical
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
// Update() flips the entity back from Deleted, or the next save would retry the delete.
_users.Update(user);
user.Username = User.DeletedUsernamePrefix + user.UserId;
user.Email = null;
user.RoleId = null;
user.Status = EntityStatus.Inactive;
await _uow.SaveChangesAsync(ct);
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
}
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
@@ -24,10 +24,16 @@ public interface IFifoCostingService
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction.
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
/// earlier over-stock sale are settled against issuable stock first.
/// </summary>
/// <param name="allowShortfall">
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false);
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -0,0 +1,16 @@
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
public interface IItemImageService
{
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
}
@@ -19,6 +19,7 @@ public interface IItemService
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
@@ -9,5 +9,4 @@ public interface IProductConfigService
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<ProductConfigDto>> UpdateAsync(
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
@@ -0,0 +1,14 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales configuration business logic. Singleton.</summary>
public interface ISalesConfigService
{
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -12,6 +12,19 @@ public interface ISalesDomainService
bool requireCashierUser,
CancellationToken ct = default);
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
@@ -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.");
}
}
+46 -11
View File
@@ -114,6 +114,7 @@ public sealed class ItemService : IItemService
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
@@ -127,6 +128,24 @@ public sealed class ItemService : IItemService
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
var layers = _stockLayers.Query();
switch (stock)
{
case ItemStockFilter.InStock:
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
break;
case ItemStockFilter.OutOfStock:
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
break;
case ItemStockFilter.LowStock:
q = q.Where(i => i.ReorderSettings.Any(rs =>
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
break;
}
var total = await q.CountAsync(ct);
var page = await q.OrderBy(i => i.Sku)
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
.ToListAsync(ct);
var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer, every warehouse.
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
var item = new Item
{
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
/// just the shared presets, and an item may also carry a length of its own entered on the
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
/// is also accepted, so switching the feature off never blocks editing an item that already
/// uses a custom length. A non-warranty item carries none — any value sent for one is
/// silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
private static int? ValidateWarrantyPeriod(
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
{
if (warranty != Warranty.Warranty) return null;
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
var valid = warrantyPeriodMonths is not null
&& (warrantyPeriodMonths == currentMonths
|| (config.CustomWarrantyPeriodsEnabled
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
if (!valid)
throw new DomainException(
ErrorCodes.Validation,
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
config.CustomWarrantyPeriodsEnabled
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
422);
return warrantyPeriodMonths;
}
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
// Custom variants live in the item-variants section - switching product variants
// off switches this off too, so it can never be left on by itself.
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
// Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -0,0 +1,120 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
/// generic "Cash Customer" record — created the first time cash sales are allowed.
/// </summary>
public sealed class SalesConfigService : ISalesConfigService
{
private const string CashCustomerCode = "CUST-CASH";
private const string CashCustomerName = "Cash Customer";
private readonly IRepository<SalesConfig> _config;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesConfigService(
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
// later throw would otherwise leave the row half-updated.
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
if (request.DefaultWarehouseId is int defaultWarehouseId
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
if (config.AllowCashCustomer && config.CashCustomerId is null)
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
config.AllowFreeItems = request.AllowFreeItems!.Value;
config.AllowDiscounts = request.AllowDiscounts!.Value;
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
// creating a new one on the deployment's default branch.
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
{
var existingId = await _customers.Query().AsNoTracking()
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var customer = new Customer
{
CustomerCode = CashCustomerCode,
CustomerType = CustomerType.WalkIn,
Name = CashCustomerName,
DisplayName = CashCustomerName,
CreditLimit = 0m,
CreditDays = 0,
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _customers.AddAsync(customer, ct);
await _uow.SaveChangesAsync(ct);
return customer.CustomerId;
}
private static SalesConfigDto Map(SalesConfig c) => new(
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
}
+45 -1
View File
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Item> _items;
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig;
public SalesDomainService(
IRepository<Customer> customers,
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<User> users,
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing)
ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig)
{
_customers = customers;
_warehouses = warehouses;
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
_items = items;
_taxRates = taxRates;
_pricing = pricing;
_salesConfig = salesConfig;
}
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
// The generic cash customer is only usable while Sales Configuration allows it.
var cashConfig = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
.FirstOrDefaultAsync(ct);
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (requireCashierUser)
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
}
}
// A missing config row means the option predates it — free items stay allowed.
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowFreeItems)
.FirstOrDefaultAsync(ct) ?? true;
// A missing config row means the option predates it — discounts stay allowed.
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowDiscounts)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
{
var config = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
.FirstOrDefaultAsync(ct);
if (config is null) return null;
// A saved default sales warehouse always wins — sales use only that warehouse.
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
if (config.AllowWarehouseSelection) return null;
// The FK is SetNull on delete, so the default can disappear after it was configured.
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
}
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.AllowOverStockSale)
.FirstOrDefaultAsync(ct);
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
@@ -86,6 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
@@ -122,6 +123,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
@@ -157,9 +159,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return _mapping.MapInvoice(invoice);
}
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
// and every line use the configured default sales warehouse.
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
foreach (var line in lines) line.WarehouseId = forced;
return forced;
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now);
if (batchId is not null)
{
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path. No LINQ is
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
// Return consumes it directly (see PurchaseReturnService), never through this
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
// reaches the database intact.
var locked = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
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})
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
{
foreach (var layer in open)
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
layer.QtyRemaining -= settle;
shortfall.QtyRemaining += settle;
}
}
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase)
if (available < qtyBase && !allowShortfall)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
// below zero), costed at the item's last known unit cost — the next issue from this
// warehouse settles it against the stock received in the meantime (see above).
if (remaining > 0)
{
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
var shortfall = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
QtyReceived = 0m,
QtyRemaining = -remaining,
UnitCost = unitCost,
ReceiptDate = now
};
await _layers.AddAsync(shortfall, ct);
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
}
return segments;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
return await layers.Where(l => l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? 0m;
}
public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -0,0 +1,78 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Custom warranty period master — shared presets offered on every item's form on top of
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
/// item may also carry a one-off length of its own. Nothing references these rows (items
/// store months), so deactivating is always safe and there is no in-use check.
/// </summary>
public sealed class WarrantyPeriodService : IWarrantyPeriodService
{
public const int MaxMonths = 600;
private readonly IRepository<WarrantyPeriod> _periods;
private readonly IUnitOfWork _uow;
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
{
_periods = periods;
_uow = uow;
}
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
{
var q = _periods.Query().AsNoTracking();
if (status is not null) q = q.Where(p => p.Status == status);
return await q.OrderBy(p => p.Months)
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
{
if (months < 1 || months > MaxMonths)
throw new DomainException(ErrorCodes.Validation,
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
if (WarrantyPeriods.AllowedMonths.Contains(months))
throw new ConflictException($"{months} months is already a default warranty period.");
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
if (existing is not null)
{
if (existing.Status == EntityStatus.Active)
throw new ConflictException($"A {months}-month warranty period already exists.");
// Unique by length: re-adding a deactivated one brings it back rather than failing.
existing.Status = EntityStatus.Active;
existing.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(existing);
}
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _periods.AddAsync(period, ct);
await _uow.SaveChangesAsync(ct);
return Map(period);
}
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
{
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
period.Status = status;
period.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
}
@@ -519,7 +519,7 @@ export default function EmployeesPage() {
<AlertDialogContent
variant="warning"
title="This employee has login access"
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login credentials? This cannot be undone — they will no longer be able to sign in, and the user will be removed from Settings > Users. Choose "Keep, but Deactivate" to only disable the login.`}
confirmLabel="Delete User Access"
cancelLabel="Keep, but Deactivate"
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
@@ -0,0 +1,14 @@
import { Loader2 } from "lucide-react"
/**
* Shown inside the dashboard shell (sidebar and header stay put) while the next page's code
* loads — including programmatic navigations the top progress bar can't see start.
*/
export default function DashboardLoading() {
return (
<div role="status" aria-live="polite" className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Loading…</span>
</div>
)
}
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -35,6 +36,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -115,6 +117,8 @@ export default function ItemDetailPage() {
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [imagesEnabled, setImagesEnabled] = useState(false)
const [imagesVersion, setImagesVersion] = useState(0)
function applyItem(data: Item) {
setItem(data)
@@ -162,6 +166,7 @@ export default function ItemDetailPage() {
setTaxRates(tr.items)
})
.catch(() => {})
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
@@ -254,6 +259,7 @@ export default function ItemDetailPage() {
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
setImagesVersion((v) => v + 1)
}
async function handleSave() {
@@ -282,6 +288,7 @@ export default function ItemDetailPage() {
applyItem(result.data)
setEtag(result.etag)
setEditOpen(false)
setImagesVersion((v) => v + 1)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -408,6 +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>
)}
{/* 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="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -828,6 +839,7 @@ export default function ItemDetailPage() {
</Select>
</div>
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
@@ -1,375 +1,26 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Uom } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
type Tab = "brand" | "uom"
// Large enough to show "all brands" in one grid — the chip layout replaces the
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
export default function BrandsPage() {
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
export default function BrandsRedirectPage() {
return (
<Suspense>
<BrandsPageContent />
<BrandsRedirect />
</Suspense>
)
}
function BrandsPageContent() {
function BrandsRedirect() {
const router = useRouter()
const searchParams = useSearchParams()
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
const [tab, setTab] = useState<Tab>(initialTab)
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
}
useEffect(() => {
router.replace(`/dashboard/products/categories?tab=${tab}`)
}, [router, tab])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
</div>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
<button
type="button"
onClick={() => selectTab("brand")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Brand
</button>
<button
type="button"
onClick={() => selectTab("uom")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
UOM
</button>
</div>
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
</div>
)
}
function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
function load() {
setError(null)
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setTogglingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
function openEdit(brand: Brand) {
setEditing(brand)
setEditName(brand.name)
setEditErrors({})
setEditOpen(true)
}
async function handleEditSubmit() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
toast.success("Brand updated", result.data.name)
setEditOpen(false)
setEditing(null)
load()
} catch (err) {
setEditErrors({ name: errorMessage(err) })
toast.error("Could not update brand", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No brands yet.</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{brands.map((b) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
className="shrink-0"
/>
<span className="truncate font-medium">{b.name}</span>
</label>
))}
</div>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(uom: Uom) {
const next = uom.status === "Active" ? "Inactive" : "Active"
setTogglingId(uom.uomId)
try {
await uomsApi.updateStatus(uom.uomId, next)
load()
} catch (err) {
toast.error("Could not update UOM status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && uoms === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{uoms.map((u) => (
<label
key={u.uomId}
title="Tick to activate/deactivate"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
className="shrink-0"
/>
<span className="truncate font-medium">{u.name}</span>
</label>
))}
</div>
)}
</div>
)
return null
}
@@ -1,484 +1,76 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { Suspense, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category, ProductConfig } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { BrandPanel } from "@/components/products/BrandPanel"
import { CategoryPanel } from "@/components/products/CategoryPanel"
import { UomPanel } from "@/components/products/UomPanel"
type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
type Tab = "category" | "brand" | "uom"
const PAGE_SIZE = 25
const TABS: { value: Tab; label: string }[] = [
{ value: "category", label: "Category" },
{ value: "brand", label: "Brand" },
{ value: "uom", label: "UOM" },
]
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const BASE_HREF = "/dashboard/products/categories"
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
function parseTab(value: string | null): Tab {
return value === "brand" || value === "uom" ? value : "category"
}
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>>({})
/**
* Category, Brand and UOM masters on one page as tabs. The old /brands and /uoms
* routes redirect here with `?tab=` so existing links/bookmarks keep working.
*/
export default function ProductMastersPage() {
return (
<Suspense>
<ProductMastersPageContent />
</Suspense>
)
}
useEffect(() => {
productConfig().then(setConfig).catch(() => setConfig(null))
}, [])
function ProductMastersPageContent() {
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,
// 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: 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)
}
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "category" ? BASE_HREF : `${BASE_HREF}?tab=${next}`, { scroll: false })
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
</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>
<h1 className="text-2xl font-bold text-foreground">Category/Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage item categories, brands and units of measure.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search categories…"
className="h-14 w-full pl-11 text-base"
aria-label="Search categories"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
{TABS.map((t) => (
<button
key={t.value}
type="button"
onClick={() => selectTab(t.value)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
</div>
{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} />}
</>
)}
{tab === "category" && <CategoryPanel />}
{tab === "brand" && <BrandPanel />}
{tab === "uom" && <UomPanel />}
</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 {
await itemTypesApi.create({ name, isMeasurable })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
toast.success(editing ? "Item variant updated" : "Item variant created", name)
setOpen(false)
setName("")
setIsMeasurable(false)
@@ -90,7 +90,7 @@ export default function ItemTypesPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
toast.error(editing ? "Could not update item variant" : "Could not create item variant", errorMessage(err))
} finally {
setSubmitting(false)
}
@@ -102,7 +102,7 @@ export default function ItemTypesPage() {
setTogglingId(itemType.itemTypeId)
try {
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
toast.success(next === "Inactive" ? "Item variant deactivated" : "Item variant activated", itemType.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
@@ -116,7 +116,7 @@ export default function ItemTypesPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<h1 className="text-2xl font-bold text-foreground">Item Variants</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item&apos;s content size.
@@ -125,11 +125,11 @@ export default function ItemTypesPage() {
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Variant</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
<DialogTitle>{editing ? "Edit item variant" : "New item variant"}</DialogTitle>
<DialogDescription>Give the item variant a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
@@ -188,7 +188,7 @@ export default function ItemTypesPage() {
{!error && itemTypes !== null && itemTypes.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No item types yet.</p>
<p className="text-base text-muted-foreground">No item variants yet.</p>
</div>
)}
@@ -1,15 +1,16 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Plus, X } from "lucide-react"
import { ImagePlus, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
@@ -17,9 +18,11 @@ import {
contentPairErrors,
validateVariantContent,
validateVariantItemForm,
validateItemTypeName,
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
import {
Brand,
Category,
@@ -28,7 +31,6 @@ import {
ProductConfig,
StockNature,
SubCategory,
WARRANTY_PERIOD_MONTHS_OPTIONS,
Warranty,
WarrantyPeriodMonths,
} from "@/types/master-data"
@@ -43,10 +45,24 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import {
ITEM_IMAGE_ACCEPT,
ITEM_IMAGE_MAX_COUNT,
itemImageProblem,
} from "@/components/products/ItemImagesPanel"
function skuSegment(text: string, maxLen: number): string {
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
@@ -105,6 +121,8 @@ export default function NewItemPage() {
// Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
/** Form-level unit for size variants — the default unit of every size value added below. */
const [measurementUnit, setMeasurementUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null)
@@ -130,6 +148,23 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Inline "custom variant" creation — only offered when config.customVariantsEnabled.
const [customOpen, setCustomOpen] = useState(false)
const [customName, setCustomName] = useState("")
// Ticked ⇒ the custom variant is a size (measurement) dimension, valued in `measurementUnit`.
const [customIsSize, setCustomIsSize] = useState(false)
const [customError, setCustomError] = useState<string | null>(null)
// Item images (config.itemImagesEnabled): the items don't exist until submit, so picks are
// held locally with object-URL previews and uploaded to every created variant afterwards.
const [pendingImages, setPendingImages] = useState<{ file: File; url: string }[]>([])
const imageInputRef = useRef<HTMLInputElement>(null)
const pendingImagesRef = useRef(pendingImages)
useEffect(() => {
pendingImagesRef.current = pendingImages
}, [pendingImages])
useEffect(() => () => pendingImagesRef.current.forEach((p) => URL.revokeObjectURL(p.url)), [])
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// each row keeps its own flat price in `pricesByKey`.
@@ -141,6 +176,15 @@ export default function NewItemPage() {
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
// Active rows of the Warranty Periods master (months), offered when config.customWarrantyPeriodsEnabled.
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
// Inline "custom period" — adds to that master without leaving the form.
const [customPeriodOpen, setCustomPeriodOpen] = useState(false)
const [customPeriodAmount, setCustomPeriodAmount] = useState("")
const [customPeriodUnit, setCustomPeriodUnit] = useState<WarrantyInputUnit>("months")
const [customPeriodError, setCustomPeriodError] = useState<string | null>(null)
// A length entered via "Custom period" — for this item only, never saved to the master.
const [itemOnlyWarrantyMonths, setItemOnlyWarrantyMonths] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
@@ -169,6 +213,11 @@ export default function NewItemPage() {
setWarehouses(wh.items)
})
.catch((err) => setLoadError(errorMessage(err)))
// Optional extra: a failure here just means only the default periods are offered.
warrantyPeriodsApi
.list({ status: "Active" })
.then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months)))
.catch(() => {})
}, [])
// Subcategories are their own resource now — fetched per category rather than filtered
@@ -213,6 +262,22 @@ export default function NewItemPage() {
config?.sellingPricesEnabled,
])
const warrantyGroups = warrantyPeriodGroups(config, customWarrantyMonths)
// Dropdown sections: defaults, the shared presets, then this item's own length (if any and
// not already one of the others).
const warrantySelectGroups = [
{ label: "Default periods", months: warrantyGroups.defaults },
{ label: "Custom periods", months: warrantyGroups.custom },
{
label: "This item only",
months:
config?.customWarrantyPeriodsEnabled &&
itemOnlyWarrantyMonths !== null &&
![...warrantyGroups.defaults, ...warrantyGroups.custom].includes(itemOnlyWarrantyMonths)
? [itemOnlyWarrantyMonths]
: [],
},
].filter((g) => g.months.length > 0)
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
@@ -234,6 +299,98 @@ export default function NewItemPage() {
)
}
function addPendingImages(files: FileList | null) {
if (!files) return
const room = ITEM_IMAGE_MAX_COUNT - pendingImages.length
const picked = Array.from(files)
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
const accepted: { file: File; url: string }[] = []
for (const file of picked.slice(0, Math.max(room, 0))) {
const problem = itemImageProblem(file)
if (problem) toast.error("Image skipped", problem)
else accepted.push({ file, url: URL.createObjectURL(file) })
}
setPendingImages((prev) => [...prev, ...accepted])
}
function removePendingImage(url: string) {
URL.revokeObjectURL(url)
setPendingImages((prev) => prev.filter((p) => p.url !== url))
}
function closeCustomPeriod() {
setCustomPeriodOpen(false)
setCustomPeriodAmount("")
setCustomPeriodUnit("months")
setCustomPeriodError(null)
}
/**
* Sets a warranty length for THIS item only. Nothing is saved to the Warranty Periods
* master — the item just stores the months (the server accepts any 1–600 while custom
* periods are on), so it isn't offered the next time the form opens.
*/
function addCustomWarrantyPeriod() {
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
if (parsed.error !== undefined) {
setCustomPeriodError(parsed.error)
return
}
setItemOnlyWarrantyMonths(parsed.months)
setWarrantyPeriodMonths(parsed.months)
closeCustomPeriod()
}
function closeCustomVariant() {
setCustomOpen(false)
setCustomName("")
setCustomIsSize(false)
setCustomError(null)
}
/**
* Adds a variant dimension for THIS item only. Nothing is saved to the Item Variants master:
* items never reference an item type (the chosen values are baked into each generated
* item's name and SKU), so a form-local entry with a negative id is all the builder needs,
* and it is gone the next time the form opens.
*
* Ticked as a size variant it becomes a measurement dimension: values are entered as numbers
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
*/
function createCustomVariant() {
const name = customName.trim() || (customIsSize ? "Size" : "")
const invalid = validateItemTypeName(name).name
if (invalid) {
setCustomError(invalid)
return
}
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
setCustomError(`"${name}" already exists — tick it above instead.`)
return
}
if (customIsSize && !measurementUnit) {
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
return
}
// Only one dimension may supply the content size, or a variant would have two.
if (customIsSize && measurableCheckedCount > 0) {
setCustomError("Only one size variant can be used at a time — untick the other one first.")
return
}
const local: ItemType = {
itemTypeId: -Date.now(),
name,
isMeasurable: customIsSize,
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: null,
}
setItemTypes((prev) => [...(prev ?? []), local])
setCheckedItemTypeIds((prev) => [...prev, local.itemTypeId])
closeCustomVariant()
}
/**
* Add is the real validation gate for a measurement — rejecting here means a half or
* duplicate pair never reaches state, so nothing downstream has to defend against one.
@@ -255,7 +412,9 @@ export default function NewItemPage() {
}
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
const unit = unitByCategory[itemTypeId] ?? null
// The form-level Measurement unit, when chosen, is fixed for every size; otherwise the
// per-dimension pick.
const unit = measurementUnit ?? unitByCategory[itemTypeId] ?? null
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
@@ -416,6 +575,7 @@ export default function NewItemPage() {
setSubmitting(true)
let created = 0
const createdIds: number[] = []
try {
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
@@ -430,7 +590,7 @@ export default function NewItemPage() {
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
const { data: createdItem } = await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
@@ -463,8 +623,24 @@ export default function NewItemPage() {
contentUnit: content?.unit ?? null,
})
created += 1
createdIds.push(createdItem.itemId)
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
// Images are best-effort: the items already exist, so a failed upload is reported
// (and can be retried from the item page) rather than undoing the creation.
if (config?.itemImagesEnabled && pendingImages.length > 0) {
let failed = 0
for (const itemId of createdIds) {
for (const { file } of pendingImages) {
await itemsApi.uploadImage(itemId, file).catch(() => {
failed += 1
})
}
}
if (failed > 0)
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
}
router.push("/dashboard/products")
} catch (err) {
// Each row is its own POST with no transaction, so a failure partway (e.g. a
@@ -489,7 +665,7 @@ export default function NewItemPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
</div>
@@ -502,7 +678,7 @@ export default function NewItemPage() {
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM —{" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
create one first
</Link>
.
@@ -631,6 +807,32 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
{/* The unit size variants are measured in. Picked once here; every size value added
below (custom size variant or a measurement Item Variant) starts in this unit. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Measurement unit</Label>
<Select<MeasureUnit | "none">
value={measurementUnit ?? "none"}
onValueChange={(v) => setMeasurementUnit(v && v !== "none" ? v : null)}
items={[
{ label: "None", value: "none" as const },
...CONTENT_UNITS.map((u) => ({ label: UNIT_LABEL[u], value: u })),
]}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-base">None</SelectItem>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Used for size variants (e.g. 500 ml, 1 L).</p>
</div>
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
or a label has none. Litres/kilograms are normalised to ml/g by the server.
Hidden once a measurement dimension is in play, because that dimension then
@@ -822,19 +1024,116 @@ export default function NewItemPage() {
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
label: formatWarrantyPeriod(m),
value: m,
}))}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
{warrantySelectGroups.length === 1
? warrantySelectGroups[0].months.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{formatWarrantyPeriod(months)}
</SelectItem>
))
: warrantySelectGroups.map((group, i) => (
<Fragment key={group.label}>
{i > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.label}</SelectLabel>
{group.months.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{formatWarrantyPeriod(months)}
</SelectItem>
))}
</SelectGroup>
</Fragment>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
{config?.customWarrantyPeriodsEnabled &&
(customPeriodOpen ? (
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
<div>
<Label className="text-sm">Custom warranty period</Label>
<p className="text-xs text-muted-foreground">
For this item only — it isn&apos;t added to Warranty Periods or offered on other items.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={1}
step={1}
autoFocus
value={customPeriodAmount}
onChange={(e) => {
setCustomPeriodAmount(e.target.value)
setCustomPeriodError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addCustomWarrantyPeriod()
} else if (e.key === "Escape") {
closeCustomPeriod()
}
}}
placeholder="e.g. 2"
aria-label="Warranty length"
aria-invalid={!!customPeriodError}
className="h-10 w-24 text-base"
/>
<Select<WarrantyInputUnit>
value={customPeriodUnit}
onValueChange={(v) => v && setCustomPeriodUnit(v)}
items={[
{ label: "Months", value: "months" },
{ label: "Years", value: "years" },
]}
>
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="months" className="text-base">Months</SelectItem>
<SelectItem value="years" className="text-base">Years</SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm" onClick={addCustomWarrantyPeriod}>
Use
</Button>
<Button type="button" size="sm" variant="ghost" onClick={closeCustomPeriod}>
Cancel
</Button>
</div>
{customPeriodUnit === "years" &&
Number.isInteger(Number(customPeriodAmount)) &&
Number(customPeriodAmount) > 0 && (
<span className="text-xs text-muted-foreground">
= {Number(customPeriodAmount) * 12} months
</span>
)}
{customPeriodError && <p className="text-sm text-destructive">{customPeriodError}</p>}
</div>
) : (
<Button
type="button"
variant="link"
size="sm"
className="h-auto w-fit px-0"
onClick={() => setCustomPeriodOpen(true)}
>
<Plus className="size-3.5" />
Custom period
</Button>
))}
</div>
)}
</div>
@@ -842,14 +1141,68 @@ export default function NewItemPage() {
</div>
)}
{config?.itemImagesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
</h2>
<p className="text-sm text-muted-foreground">
Not required — you can skip this and add images later from the item page. JPG, PNG or WebP, up to 5 MB each and {ITEM_IMAGE_MAX_COUNT} per item. They are added to every
variant created below; the first image is the main one.
</p>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5 lg:grid-cols-8">
{pendingImages.map((p, index) => (
<div key={p.url} className="relative aspect-square overflow-hidden rounded-lg border bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- local object-URL preview */}
<img src={p.url} alt={p.file.name} className="size-full object-cover" />
{index === 0 && (
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
)}
<button
type="button"
onClick={() => removePendingImage(p.url)}
aria-label={`Remove ${p.file.name}`}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
>
<X className="size-3.5" />
</button>
</div>
))}
{pendingImages.length < ITEM_IMAGE_MAX_COUNT && (
<button
type="button"
onClick={() => imageInputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<ImagePlus className="size-6" />
<span className="text-xs font-medium">Add images</span>
</button>
)}
</div>
<input
ref={imageInputRef}
type="file"
accept={ITEM_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
addPendingImages(e.target.files)
e.target.value = ""
}}
/>
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
Check the item variants that apply, then add their values to generate a SKU per combination.
</p>
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
measurement out of the box, so the builder silently falls back to one shared
@@ -858,7 +1211,7 @@ export default function NewItemPage() {
<p className="mt-2 text-sm text-muted-foreground">
Selling the same product in several sizes (500 ml, 1 L)?{" "}
<Link href="/dashboard/products/item-types" className="font-medium underline">
Mark that item type as a measurement
Mark that item variant as a measurement
</Link>{" "}
and each size will capture its own number and unit, giving every variant its own content size.
</p>
@@ -886,13 +1239,83 @@ export default function NewItemPage() {
/>
<span className="text-base font-medium">{t.name}</span>
{t.isMeasurable && (
<Badge variant="outline" className="text-xs">measurement</Badge>
<Badge variant="outline" className="text-xs">size</Badge>
)}
{t.itemTypeId < 0 && (
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-xs text-primary">
this item only
</Badge>
)}
</label>
)
})}
{config?.customVariantsEnabled && !customOpen && (
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
<Plus className="size-4" />
Custom variant
</Button>
)}
</div>
{config?.customVariantsEnabled && customOpen && (
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-4">
<div>
<Label htmlFor="custom-variant-name" className="text-base">Custom variant</Label>
<p className="text-sm text-muted-foreground">
Used for this item only — it isn&apos;t added to Item Variants or offered on other items. Tick
Size variant to enter sizes in the Measurement unit above (500 ml, 1 L), each with its own
content amount.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
id="custom-variant-name"
autoFocus
value={customName}
onChange={(e) => {
setCustomName(e.target.value)
setCustomError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
createCustomVariant()
} else if (e.key === "Escape") {
closeCustomVariant()
}
}}
placeholder={customIsSize ? "Size" : "e.g. Colour"}
aria-invalid={!!customError}
className="h-11 text-base sm:max-w-64"
/>
<label
className={cn("flex items-center gap-2 text-sm", !measurementUnit && "text-muted-foreground")}
title={measurementUnit ? undefined : "Pick a Measurement unit at the top first"}
>
<Checkbox
checked={customIsSize}
disabled={!measurementUnit}
onCheckedChange={(c) => {
setCustomIsSize(c === true)
setCustomError(null)
}}
/>
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
</label>
<div className="flex gap-2 sm:ml-auto">
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
Cancel
</Button>
<Button type="button" onClick={createCustomVariant}>
Add
</Button>
</div>
</div>
{customError && <p className="text-sm text-destructive">{customError}</p>}
</div>
)}
<FieldError
errors={[
errors.variants ? { message: errors.variants } : undefined,
@@ -930,22 +1353,32 @@ export default function NewItemPage() {
aria-invalid={!!valueErrors[t.itemTypeId]}
className="h-11 text-base"
/>
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
{measurementUnit ? (
// Fixed by the form-level Measurement unit — every size is in that unit.
<div
className="flex h-11 w-24 shrink-0 items-center justify-center rounded-md border bg-muted text-base text-muted-foreground"
title="Set by Measurement unit above"
>
{UNIT_LABEL[measurementUnit]}
</div>
) : (
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<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)}>
<Plus className="size-4" />
Add
@@ -10,15 +10,14 @@ import {
ArrowUpDown,
Download,
Eye,
ListFilter,
ListTree,
Package,
Pencil,
Plus,
Ruler,
Search,
} from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { ItemStockFilter, itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
@@ -27,13 +26,13 @@ import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ColumnsMenu } from "@/components/ui/columns-menu"
import { Input } from "@/components/ui/input"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -45,12 +44,23 @@ type SortOrder = "asc" | "desc"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" },
{ key: "stockValue", label: "Stock Value" },
{ key: "lastPurchase", label: "Last Purchase" },
{ key: "sales30d", label: "Sales (30d)" },
{ key: "status", label: "Status" },
const STOCK_OPTIONS: { label: string; value: ItemStockFilter | "All" }[] = [
{ label: "Any stock level", value: "All" },
{ label: "In stock", value: "InStock" },
{ label: "Out of stock", value: "OutOfStock" },
{ label: "Low stock", value: "LowStock" },
]
const TRACKING_OPTIONS: { label: string; value: TrackingMode | "All" }[] = [
{ label: "Any tracking", value: "All" },
{ label: "No tracking", value: "None" },
{ label: "Batch", value: "Batch" },
{ label: "Serial", value: "Serial" },
]
const NATURE_OPTIONS: { label: string; value: StockNature | "All" }[] = [
{ label: "Any stock nature", value: "All" },
{ label: "Stocked", value: "Stocked" },
{ label: "Non-stocked", value: "NonStocked" },
{ label: "Service", value: "Service" },
]
function formatMoney(n: number) {
@@ -70,6 +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() {
const router = useRouter()
const isMobile = useIsMobile()
@@ -84,6 +128,12 @@ export default function ItemsPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | "All">("All")
const [brandId, setBrandId] = useState<number | "All">("All")
const [stock, setStock] = useState<ItemStockFilter | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingMode | "All">("All")
const [stockNature, setStockNature] = useState<StockNature | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
@@ -91,7 +141,6 @@ export default function ItemsPage() {
const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -100,7 +149,7 @@ export default function ItemsPage() {
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const resetKey = `${query}|${status}|${categoryId}|${subCategoryId}|${brandId}|${stock}|${trackingMode}|${stockNature}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
@@ -118,6 +167,11 @@ export default function ItemsPage() {
q: query || undefined,
status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId,
subCategoryId: subCategoryId === "All" ? undefined : subCategoryId,
brandId: brandId === "All" ? undefined : brandId,
stock: stock === "All" ? undefined : stock,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
stockNature: stockNature === "All" ? undefined : stockNature,
})
.then((res) => {
if (cancelled) return
@@ -135,7 +189,23 @@ export default function ItemsPage() {
return () => {
cancelled = true
}
}, [page, pageSize, query, status, categoryId, isMobile])
}, [page, pageSize, query, status, categoryId, subCategoryId, brandId, stock, trackingMode, stockNature, isMobile])
// Subcategories only make sense under a chosen category.
const subcategoriesOn = !!config?.subcategoriesEnabled && categoryId !== "All"
useEffect(() => {
if (!subcategoriesOn) return
let cancelled = false
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => {
if (!cancelled) setSubCategories(res.items)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [subcategoriesOn, categoryId])
function showMore() {
setLoadingMore(true)
@@ -156,11 +226,21 @@ export default function ItemsPage() {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
const showCol = (key: string) => visibleColumns[key] ?? true
function toggleColumn(key: string, next: boolean) {
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
function changeCategory(next: number | "All") {
setCategoryId(next)
setSubCategoryId("All")
setSubCategories([])
}
function clearAdvanced() {
setSubCategoryId("All")
setBrandId("All")
setStock("All")
setTrackingMode("All")
setStockNature("All")
}
function toggleSort(key: SortKey) {
@@ -236,14 +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>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" />
UOMs
</Link>
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<ListTree className="size-5" />
Categories
Category/Brand/UOM
</Link>
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-5" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
@@ -251,50 +331,85 @@ export default function ItemsPage() {
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search SKU or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search items"
/>
{/* Same filter bar as the Sales list, but search on the left and every filter
(category, status, "Advanced" popover) grouped on the right. */}
<FilterBar
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Search SKU or name…"
searchAriaLabel="Search items"
advancedActive={advancedCount > 0}
onClearAdvanced={clearAdvanced}
advanced={
<>
<FilterSelect label="Stock level" value={stock} onChange={setStock} options={STOCK_OPTIONS} />
{config?.brandsEnabled && (
<FilterSelect
label="Brand"
value={brandId}
onChange={setBrandId}
options={[{ label: "All brands", value: "All" as const }, ...brands.map((b) => ({ label: b.name, value: b.brandId }))]}
/>
)}
{config?.subcategoriesEnabled && (
<FilterSelect
label="Subcategory"
value={subCategoryId}
onChange={setSubCategoryId}
disabled={categoryId === "All"}
placeholder={categoryId === "All" ? "Pick a category first" : undefined}
options={[
{ label: "All subcategories", value: "All" as const },
...subCategories.map((s) => ({ label: s.name, value: s.subCategoryId })),
]}
/>
)}
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
</>
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<number | "All">
value={categoryId}
onValueChange={(v) => 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>
<Select<number | "All">
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>
</FilterBar>
{error && (
<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>
</div>
</div>
{showCol("status") && (
<Badge
variant="outline"
className={cn(
"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}
</Badge>
)}
<Badge
variant="outline"
className={cn(
"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}
</Badge>
</div>
{showCol("categoryBrand") && (
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
)}
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{showCol("stockValue") && (
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</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>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{item.stockQty.toLocaleString()} on hand</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</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>
<Link
@@ -440,11 +545,11 @@ export default function ItemsPage() {
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
<TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -474,52 +579,44 @@ export default function ItemsPage() {
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
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 className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{item.stockQty.toLocaleString()} on hand</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<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>
)}
</div>
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
</TableCell>
<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>
<TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</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>
<div className="flex items-center gap-1">
<Link
@@ -1,11 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { ReactNode, useEffect, useState } from "react"
import Link from "next/link"
import { Package } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { ProductConfig } from "@/types/master-data"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
@@ -37,18 +38,10 @@ export default function ProductSettingsPage() {
useEffect(load, [])
async function toggle(flag: keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
>, next: boolean) {
if (!config) return
setSaving(flag)
/** PUT is a full replacement, so every save sends the whole current config plus `patch`. */
async function save(key: keyof ProductConfig, patch: Partial<UpdateProductConfigRequest>, message: string): Promise<boolean> {
if (!config) return false
setSaving(key)
try {
const res = await productConfigApi.update(
{
@@ -59,21 +52,30 @@ export default function ProductSettingsPage() {
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next,
customVariantsEnabled: config.customVariantsEnabled,
itemImagesEnabled: config.itemImagesEnabled,
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
...patch,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
toast.success("Configuration saved", message)
return true
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
return false
} finally {
setSaving(null)
}
}
function toggle(flag: FlagKey, next: boolean) {
return save(flag, { [flag]: next }, `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -102,10 +104,42 @@ export default function ProductSettingsPage() {
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<ToggleRow
label="Product variants"
description="Controls the variant builder on item create. Off hides the item-type section."
description="Controls the variant builder on item create. Off hides the item-variants section."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
onChange={(v) =>
v
? toggle("itemTypesEnabled", true)
: // Custom variants need this on, so switching it off turns that off too.
save(
"itemTypesEnabled",
{ itemTypesEnabled: false, customVariantsEnabled: false },
config.customVariantsEnabled
? "Product variants and custom variants disabled."
: "Product variants disabled.",
)
}
/>
<ToggleRow
label="Allow custom variants"
description={
config.itemTypesEnabled
? "Lets users create a new item variant (e.g. Flavour) right from the item create screen, without visiting Item Variants first."
: "Needs Product variants switched on — the custom variant option lives in the item-variants section."
}
checked={config.customVariantsEnabled}
busy={saving === "customVariantsEnabled"}
disabled={!config.itemTypesEnabled}
onChange={(v) => toggle("customVariantsEnabled", v)}
/>
<ToggleRow
label="Item images"
description="Adds an images area to item create and the item page (JPG, PNG or WebP, up to 10 per item). Off hides it; images already added stay visible."
checked={config.itemImagesEnabled}
busy={saving === "itemImagesEnabled"}
onChange={(v) => toggle("itemImagesEnabled", v)}
/>
<ToggleRow
@@ -137,7 +171,39 @@ export default function ProductSettingsPage() {
description="Allows warranty-tracked items to pick a coverage period."
checked={config.warrantyPeriodsEnabled}
busy={saving === "warrantyPeriodsEnabled"}
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
onChange={(v) =>
v
? toggle("warrantyPeriodsEnabled", true)
: // Custom warranty periods need this on, so switching it off turns that off too.
save(
"warrantyPeriodsEnabled",
{ warrantyPeriodsEnabled: false, customWarrantyPeriodsEnabled: false },
config.customWarrantyPeriodsEnabled
? "Warranty periods and custom warranty periods disabled."
: "Warranty periods disabled.",
)
}
/>
<ToggleRow
label="Custom warranty periods"
description={
config.warrantyPeriodsEnabled ? (
<>
Offers the periods from{" "}
<Link href="/dashboard/products/warranty-periods" className="font-medium underline">
Products → Warranty Periods
</Link>{" "}
alongside the default 3, 6, 12 and 18 months.
</>
) : (
"Needs Warranty periods switched on."
)
}
checked={config.customWarrantyPeriodsEnabled}
busy={saving === "customWarrantyPeriodsEnabled"}
disabled={!config.warrantyPeriodsEnabled}
onChange={(v) => toggle("customWarrantyPeriodsEnabled", v)}
/>
<ToggleRow
@@ -177,21 +243,40 @@ const LABELS: Record<keyof ProductConfig, string> = {
serialNumbersEnabled: "Serial numbers",
batchListsEnabled: "Batch lists",
warrantyPeriodsEnabled: "Warranty periods",
customVariantsEnabled: "Custom variants",
itemImagesEnabled: "Item images",
customWarrantyPeriodsEnabled: "Custom warranty periods",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
type FlagKey = keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
| "customVariantsEnabled"
| "itemImagesEnabled"
| "customWarrantyPeriodsEnabled"
>
function ToggleRow({
label,
description,
checked,
busy,
disabled,
onChange,
}: {
label: string
description: string
description: ReactNode
checked: boolean
busy: boolean
disabled?: boolean
onChange: (next: boolean) => void
}) {
return (
@@ -199,7 +284,7 @@ function ToggleRow({
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={busy}
disabled={busy || disabled}
aria-label={label}
className="mt-1 shrink-0"
/>
@@ -3,13 +3,13 @@
import { useEffect } from "react"
import { useRouter } from "next/navigation"
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
// to keep old links/bookmarks working.
// UOM now lives as a tab on the merged Category/Brand/UOM page — this route stays
// only to keep old links/bookmarks working.
export default function UomsRedirectPage() {
const router = useRouter()
useEffect(() => {
router.replace("/dashboard/products/brands?tab=uom")
router.replace("/dashboard/products/categories?tab=uom")
}, [router])
return null
@@ -0,0 +1,290 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Ban, CheckCircle2, Plus, ShieldCheck } from "lucide-react"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit } from "@/lib/warranty"
import { WARRANTY_PERIOD_MONTHS_OPTIONS, WarrantyPeriod } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Warranty Periods — custom coverage lengths offered to items on top of the fixed defaults
* (3/6/12/18 months), while Product Configuration → "Custom warranty periods" is on.
*
* Entered in months or years, always stored as months. Items store the months themselves,
* so a length can't be edited (add a new one instead) and deactivating one only stops it
* being offered — items already using it keep it.
*/
export default function WarrantyPeriodsPage() {
const [periods, setPeriods] = useState<WarrantyPeriod[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [customEnabled, setCustomEnabled] = useState<boolean | null>(null)
const [open, setOpen] = useState(false)
const [amount, setAmount] = useState("")
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
const [formError, setFormError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
warrantyPeriodsApi
.list()
.then(setPeriods)
.catch((err) => setError(errorMessage(err)))
}
// Initial fetch sets state only in callbacks (load() also clears the error, for reloads).
useEffect(() => {
warrantyPeriodsApi
.list()
.then(setPeriods)
.catch((err) => setError(errorMessage(err)))
productConfig()
.then((cfg) => setCustomEnabled(cfg.warrantyPeriodsEnabled && cfg.customWarrantyPeriodsEnabled))
.catch(() => setCustomEnabled(null))
}, [])
function openCreateDialog() {
setAmount("")
setUnit("months")
setFormError(null)
setOpen(true)
}
async function handleSubmit() {
// Only an ACTIVE duplicate is an error — re-adding an inactive length reactivates it.
const activeMonths = (periods ?? []).filter((p) => p.status === "Active").map((p) => p.months)
const parsed = parseWarrantyInput(amount, unit, activeMonths)
if (parsed.error !== undefined) {
setFormError(parsed.error)
return
}
setSubmitting(true)
try {
await warrantyPeriodsApi.create(parsed.months)
toast.success("Warranty period added", formatWarrantyPeriod(parsed.months))
setOpen(false)
load()
} catch (err) {
setFormError(errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(period: WarrantyPeriod) {
const next = period.status === "Active" ? "Inactive" : "Active"
setTogglingId(period.warrantyPeriodId)
try {
await warrantyPeriodsApi.updateStatus(period.warrantyPeriodId, next)
toast.success(
next === "Inactive" ? "Warranty period deactivated" : "Warranty period activated",
formatWarrantyPeriod(period.months),
)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
const years = Number(amount)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Warranty Periods</h1>
<p className="text-base text-muted-foreground">
Your own warranty lengths, offered on items alongside the defaults. Enter them in months or years — they are
saved as months.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateDialog}>
<Plus className="size-5" />
Add Warranty Period
</Button>
}
/>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New warranty period</DialogTitle>
<DialogDescription>How long the warranty lasts.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!formError}>
<FieldLabel htmlFor="warranty-period-amount">Length</FieldLabel>
<div className="flex gap-2">
<Input
id="warranty-period-amount"
type="number"
inputMode="numeric"
min={1}
step={1}
autoFocus
value={amount}
onChange={(e) => {
setAmount(e.target.value)
setFormError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSubmit()
}
}}
placeholder="e.g. 2"
aria-invalid={!!formError}
/>
<Select<WarrantyInputUnit>
value={unit}
onValueChange={(v) => v && setUnit(v)}
items={[
{ label: "Months", value: "months" },
{ label: "Years", value: "years" },
]}
>
<SelectTrigger className="w-32 shrink-0" aria-label="Unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="months">Months</SelectItem>
<SelectItem value="years">Years</SelectItem>
</SelectContent>
</Select>
</div>
{unit === "years" && Number.isInteger(years) && years > 0 && (
<span className="text-sm text-muted-foreground">= {years * 12} months</span>
)}
<FieldError errors={[formError ? { message: formError } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Adding…" : "Add"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{customEnabled === false && (
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
Custom warranty periods are switched off, so items only offer the defaults. Turn on{" "}
<span className="font-medium">Custom warranty periods</span> in{" "}
<Link href="/dashboard/products/settings" className="font-medium underline">
Product Configuration
</Link>{" "}
to use these.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Default periods (always offered):</span>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((m) => (
<Badge key={m} variant="outline" className="text-muted-foreground">
{formatWarrantyPeriod(m)}
</Badge>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && periods === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && periods !== null && periods.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ShieldCheck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No custom warranty periods yet.</p>
</div>
)}
{!error && periods !== null && periods.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{periods.map((p) => (
<TableRow key={p.warrantyPeriodId}>
<TableCell className="px-3 py-3.5">{p.warrantyPeriodId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{formatWarrantyPeriod(p.months)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={p.status === "Active" ? "default" : "secondary"}>{p.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(p.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={p.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}`}
disabled={togglingId === p.warrantyPeriodId}
/>
}
>
{p.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={p.status === "Active" ? "destructive" : "success"}
title={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}?`}
description={
p.status === "Active"
? "It stops being offered on items. Items already using it keep their warranty period."
: "It is offered on items again."
}
confirmLabel={p.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(p)}
/>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -6,7 +6,7 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox"
import { SuggestInput } from "@/components/ui/suggest-input"
import {
Dialog,
DialogContent,
@@ -141,13 +141,8 @@ export default function NewFreeIssuePage() {
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
},
])
// No item pre-selected — the user searches for one.
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
@@ -167,7 +162,7 @@ export default function NewFreeIssuePage() {
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
[items]
)
@@ -369,12 +364,29 @@ export default function NewFreeIssuePage() {
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
placeholder="Search item…"
/>
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
{line.itemId ? (
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
<span className="truncate font-medium">
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
</span>
<button
type="button"
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
Change
</button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
placeholder="Search item by SKU or name…"
emptyText="No items match."
aria-label="Item"
/>
)}
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -13,6 +13,7 @@ import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
@@ -24,6 +25,7 @@ import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, Sales
import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SuggestInput } from "@/components/ui/suggest-input"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
@@ -85,6 +87,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
// Sales Configuration "Allow free items": off hides the draft editor's Free column.
const [allowFreeItems, setAllowFreeItems] = useState(true)
// "Allow discounts": off saves the draft's lines without a line discount (the server rejects one).
const [allowDiscounts, setAllowDiscounts] = useState(true)
// A saved default sales warehouse (or "Allow warehouse selection" off) replaces the draft
// editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -100,8 +110,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
salesApi.getInvoice(invoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
salesConfigApi.get().then((r) => r.data).catch(() => null),
])
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes]) => {
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes, config]) => {
setAllowFreeItems(config?.allowFreeItems ?? true)
setAllowDiscounts(config?.allowDiscounts ?? true)
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
setCustomers(customerRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
@@ -170,6 +185,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const isDraft = invoice?.status === "Draft"
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
// With selection off, saving moves the draft onto the default sales warehouse (server-forced),
// so that's the one the editor shows.
const pinnedWarehouse = useMemo(() => warehouses.find((w) => w.warehouseId === defaultWarehouseId), [warehouses, defaultWarehouseId])
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
@@ -182,11 +200,34 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
// Type-ahead "Add item" options — nothing is listed until the user types.
const itemOptions = useMemo(
() =>
items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId)
return { value: String(i.itemId), label: `${i.sku} — ${i.name}`, hint: price !== null ? money.format(price) : undefined }
}),
[items],
)
function itemLabel(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
}
// Picking an item already on the invoice bumps its qty instead of adding a duplicate line.
function addItem(itemId: number) {
setLines((prev) => {
const idx = prev.findIndex((l) => l.itemId === itemId)
if (idx >= 0) {
const next = [...prev]
next[idx] = { ...next[idx], qty: Number(next[idx].qty || 0) + 1 }
return next
}
return [
...prev,
{ ...blankLine(`line-${Date.now()}`), itemId, warehouseId: warehouseId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId) },
]
})
}
@@ -195,10 +236,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
@@ -224,15 +261,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
},
@@ -429,13 +466,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
) : null}
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
{/* canPost with issues = Sales Configuration allows selling beyond stock: shown as a
warning, posting still goes ahead and drives on-hand negative. */}
{invoice.status === "Draft" && postingCheck && postingCheck.issues.length > 0 ? (
<div className="border-t pt-5">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="mt-1 text-sm">
{postingCheck.canPost
? "Selling beyond stock is allowed — posting will take these items' stock below zero."
: "The invoice cannot be posted until every line has enough available stock in the selected warehouse."}
</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
@@ -486,10 +529,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{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">
<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">
<Save className="size-4" />
{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">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
<th className="px-4 py-2 text-right font-medium">Free</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
@@ -529,18 +577,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="px-4 py-3 min-w-64">
<select
value={line.itemId ? String(line.itemId) : ""}
onChange={(e) => selectItem(line.key, Number(e.target.value))}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select item</option>
{items.map((i) => (
<option key={i.itemId} value={String(i.itemId)}>
{i.sku} - {i.name}
</option>
))}
</select>
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{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"
/>
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
{allowFreeItems && (
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
)}
<td className="px-4 py-3 w-32">
<input
type="number"
@@ -614,18 +653,25 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice type</Label>
@@ -634,8 +680,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="B2B">B2B</option>
<option value="B2C">B2C</option>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Plus, Trash2 } from "lucide-react"
import { Trash2 } from "lucide-react"
import { useAuth } from "@/components/auth/AuthProvider"
import { ScanInput } from "@/components/scanning/ScanInput"
@@ -12,7 +12,7 @@ import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
@@ -21,6 +21,7 @@ import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { ApiError } from "@/lib/api-client"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
@@ -30,7 +31,13 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
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 { toast } from "@/components/ui/toast"
@@ -119,14 +126,46 @@ function NewSalesInvoiceContent() {
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("Cash")
// 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 [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
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(() => {
Promise.all([
@@ -136,24 +175,25 @@ function NewSalesInvoiceContent() {
warehousesApi.list({ pageSize: 200 }),
salesApi.listFreeIssues({ pageSize: 20 }),
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)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setBundleTemplates(templateRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
setCustomerId(cust.items[0]?.customerId ?? null)
setSalesConfig(config)
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)
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)
// does. First match per item wins (listFreeIssues returns newest first).
setFreeIssueSchemes(
@@ -201,7 +241,9 @@ function NewSalesInvoiceContent() {
// 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.
// A pinned default sales warehouse is never cleared this way — there's no picker to re-pick with.
useEffect(() => {
if (!allowWarehouseSelection) return
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
@@ -214,20 +256,12 @@ function NewSalesInvoiceContent() {
// 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.
function suggestedFreeQty(itemId: number, qty: number): number {
if (!allowFreeItems) return 0
const scheme = freeIssueSchemeFor(itemId)
if (!scheme || !qty) return 0
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) {
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
// 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).
// 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) {
setScanError(null)
try {
const result = await itemsApi.scan(code)
setLines((prev) => {
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]
})
addItem(result.itemId, result)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
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
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
// template values "bundle:" so one handler can tell them apart on select.
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],
)
@@ -302,16 +457,6 @@ function NewSalesInvoiceContent() {
[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
// 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.
@@ -362,18 +507,14 @@ function NewSalesInvoiceContent() {
)
}
// Combined handler for each line's item/bundle search box — routes to the right action based
// on the "item:"/"bundle:" value prefix (see lineComboOptions above).
function selectLineOption(key: string, value: string | null) {
if (!value) return
// Handler for the "Add item" suggestion box — routes on the "item:"/"bundle:" value prefix
// (see lineComboOptions above).
function addFromSuggestion(value: string) {
const sep = value.indexOf(":")
const kind = value.slice(0, sep)
const id = Number(value.slice(sep + 1))
if (kind === "bundle") {
addBundle(id, key)
} else {
selectItem(key, id)
}
if (kind === "bundle") addBundle(id)
else setFocusQtyKey(addItem(id))
}
// 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
}, [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) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
setLines((prev) => prev.filter((line) => line.key !== key))
}
function removeBundleGroup(groupId: string) {
setLines((prev) => {
const next = prev.filter((line) => line.bundleGroupId !== groupId)
return next.length === 0 ? [{ ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }] : next
})
setLines((prev) => prev.filter((line) => line.bundleGroupId !== groupId))
}
// Groups consecutive component lines added from the same bundle into one display row, so the
@@ -450,8 +593,14 @@ function NewSalesInvoiceContent() {
async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
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.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 = {
customerId,
@@ -462,15 +611,15 @@ function NewSalesInvoiceContent() {
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
}
@@ -512,12 +661,17 @@ function NewSalesInvoiceContent() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<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">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<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">
{c.customerCode} — {c.displayName ?? c.name}
</SelectItem>
@@ -552,33 +706,25 @@ function NewSalesInvoiceContent() {
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-12! w-full text-base">
<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>
{allowWarehouseSelection ? (
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
// Pinned to the default sales warehouse — shown, not editable (same as a locked branch).
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{warehouseLabel(warehouseId) ?? salesConfig?.defaultWarehouseName ?? "No default warehouse set"}
</div>
)}
</div>
</div>
@@ -587,21 +733,37 @@ function NewSalesInvoiceContent() {
label="Scan to add"
hint="Scan an item barcode, or a batch/serial lot barcode, to add or bump a line."
onScan={handleScan}
// The "Add item" box below gets the cursor instead — it also accepts scans.
autoFocus={false}
className="max-w-md"
/>
{scanError && <FieldError errors={[{ message: scanError }]} />}
</div>
<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>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" onClick={addLine}>
<Plus className="size-5" />
Add line
</Button>
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it's
the last row of the table, under the Item column. */}
<SuggestInput
options={lineComboOptions}
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 &quot;Add item&quot; above, or scan a barcode.
</div>
</div>
)}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
@@ -619,6 +781,9 @@ function NewSalesInvoiceContent() {
{row.lines.map((l) => (
<li key={l.key}>
{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>
))}
</ul>
@@ -629,7 +794,6 @@ function NewSalesInvoiceContent() {
size="icon"
className="shrink-0"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
@@ -649,10 +813,12 @@ function NewSalesInvoiceContent() {
aria-label="Bundle quantity"
/>
</div>
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
{allowFreeItems && (
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
)}
</div>
<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-1">
<Label className="text-sm">Item {idx + 1}</Label>
<Combobox
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"
/>
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
@@ -689,7 +849,6 @@ function NewSalesInvoiceContent() {
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
@@ -712,42 +871,55 @@ function NewSalesInvoiceContent() {
inputMode="numeric"
value={line.qty}
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"
/>
{stockShortage(line) !== null && (
<p className="mt-1 text-xs text-destructive">
Exceeds stock — only {stockShortage(line)} available
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-sm tabular-nums"
/>
</div>
{allowFreeItems && (
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
{...navProps(line.key, "free")}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
)}
{allowDiscounts && (
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
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>
@@ -759,11 +931,12 @@ function NewSalesInvoiceContent() {
inputMode="decimal"
value={line.unitPrice ?? ""}
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"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
{suggestedPriceFor(line) !== null
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
: "No price suggestion available"}
</p>
</div>
@@ -786,9 +959,9 @@ function NewSalesInvoiceContent() {
<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 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">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 w-10 px-3" />
</TableRow>
@@ -808,6 +981,9 @@ function NewSalesInvoiceContent() {
{row.lines.map((l) => (
<li key={l.key}>
{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>
))}
</ul>
@@ -825,11 +1001,13 @@ function NewSalesInvoiceContent() {
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">
{bundleFreeQty || "—"}
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
{allowFreeItems && (
<TableCell className="px-3 py-3 align-top text-right text-sm">
{bundleFreeQty || "—"}
</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">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
</TableCell>
@@ -839,8 +1017,7 @@ function NewSalesInvoiceContent() {
variant="ghost"
size="icon"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
@@ -857,13 +1034,7 @@ function NewSalesInvoiceContent() {
<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">
<Combobox
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"
/>
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">
Lot: {line.batchNo ?? line.serialNo}
@@ -882,24 +1053,34 @@ function NewSalesInvoiceContent() {
step="1"
value={line.qty}
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"
/>
</TableCell>
<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) })}
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}`}
{stockShortage(line) !== null && (
<p className="mt-1 text-right text-xs text-destructive">
Exceeds stock — only {stockShortage(line)} available
</p>
)}
</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">
<Input
type="number"
@@ -907,25 +1088,29 @@ function NewSalesInvoiceContent() {
step="1"
value={line.unitPrice ?? ""}
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"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
{suggestedPriceFor(line) !== null
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
: "No price suggestion available"}
</p>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
{allowDiscounts && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
{...navProps(line.key, "disc")}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
)}
<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>
</TableCell>
@@ -935,8 +1120,7 @@ function NewSalesInvoiceContent() {
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
@@ -944,6 +1128,25 @@ function NewSalesInvoiceContent() {
</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>
</Table>
</div>
@@ -953,10 +1156,12 @@ function NewSalesInvoiceContent() {
<span className="text-sm text-muted-foreground">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
</div>
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div>
{allowDiscounts && (
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">{lkr.format(discountTotal)}</span>
</div>
)}
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
@@ -968,13 +1173,28 @@ function NewSalesInvoiceContent() {
</div>
</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 className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Label className="shrink-0 text-base">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</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>
</>
)}
@@ -215,7 +215,7 @@ export default function SalesInvoicesPage() {
{rows !== null ? (
<>
<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} />
</>
) : (
@@ -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 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.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">
<div className="flex flex-col items-start gap-1">
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-react"
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -20,6 +20,12 @@ const cards = [
icon: SlidersHorizontal,
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",
href: "/dashboard/settings/invoice-template",
@@ -34,7 +40,7 @@ export default function ConfigurationPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
<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>
</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 &quot;{config.cashCustomerName ?? "Cash Customer"}&quot; 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&apos;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&apos;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&apos;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&apos;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>
)
}
+6
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Inter, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import { Providers } from "@/components/providers";
import { RouteProgress } from "@/components/Layouts/RouteProgress";
import "./globals.css";
const inter = Inter({
@@ -31,6 +33,10 @@ export default function RootLayout({
suppressHydrationWarning
>
<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>
</body>
</html>
@@ -43,14 +43,12 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Tag,
Truck,
Undo2,
Users,
@@ -73,11 +71,7 @@ const navItems: {
/** Where clicking the row actually navigates, if different from `href`. `href` itself
* stays the section prefix used to decide whether this row is "active". */
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
chevron?: boolean
children?: {
title: string
code: string
@@ -94,12 +88,16 @@ const navItems: {
code: "products",
href: "/dashboard/products",
icon: Package,
chevron: true,
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
// Category, Brand and UOM share one tabbed page — any of the three permissions shows it.
{
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 },
],
},
@@ -111,7 +109,6 @@ const navItems: {
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
landingHref: "/dashboard/procurement/purchase-orders",
icon: ClipboardList,
chevron: true,
children: [
{ 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 },
@@ -124,9 +121,11 @@ const navItems: {
href: "/dashboard/sales",
landingHref: "/dashboard/sales/invoices",
icon: ReceiptText,
chevron: true,
children: [
{ 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
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
{ 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: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck },
{
title: "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.
landingHref: "/dashboard/stock/ledger",
icon: Warehouse,
chevron: true,
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ 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: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2 },
{
title: "Production",
code: "production",
href: "/dashboard/production",
landingHref: "/dashboard/production/runs",
icon: Factory,
chevron: true,
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
@@ -178,7 +175,6 @@ const navItems: {
href: "/dashboard/hrm",
landingHref: "/dashboard/hrm/employees",
icon: IdCard,
chevron: true,
children: [
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
@@ -193,7 +189,6 @@ const navItems: {
code: "ledgers",
href: "/dashboard/ledgers",
icon: Landmark,
chevron: true,
children: [
{ 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 },
@@ -209,7 +204,6 @@ const navItems: {
code: "accounts",
href: "/dashboard/accounts",
icon: CreditCard,
chevron: true,
children: [
{ 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 },
@@ -220,9 +214,7 @@ const navItems: {
title: "Settings",
code: "settings",
href: "/dashboard/settings",
expandOnly: true,
icon: Settings,
chevron: true,
children: [
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
@@ -329,49 +321,46 @@ function SidebarContent({
isActive ? "bg-primary/10" : "hover:bg-muted"
)}
>
<Link
href={item.landingHref ?? item.href}
title={iconOnly ? item.title : undefined}
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 && (
{hasChildren ? (
// Parents with sub-items only expand/collapse on click — navigation
// happens through the sub-items themselves.
<button
type="button"
onClick={() => toggleExpand(item.code)}
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
aria-expanded={isOpen}
className={cn(
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
isActive ? "text-primary" : "text-muted-foreground"
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-left text-base font-normal",
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
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>
) : (
<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>
@@ -132,10 +132,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products") return "Items"
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 === "/dashboard/products/brands") return "Brand/UOM"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/item-types") return "Item Variants"
if (pathname === "/dashboard/products/warranty-periods") return "Warranty Periods"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
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
placeholder?: string
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
}
@@ -31,6 +34,7 @@ export function ScanInput({
hint = "Scan item or lot barcode",
placeholder = "Scan or type a barcode, then press Enter",
disabled = false,
autoFocus = true,
className,
}: ScanInputProps) {
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
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
useEffect(() => {
if (!disabled) inputRef.current?.focus()
}, [disabled])
if (!disabled && autoFocus) inputRef.current?.focus()
}, [disabled, autoFocus])
function commit() {
const code = value.trim()
@@ -22,6 +22,8 @@ interface FilterBarProps {
advancedActive?: boolean
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
onClearAdvanced?: () => void
/** Puts the search box on the left and groups `children` + "Advanced" together on the right. */
searchFirst?: boolean
className?: string
}
@@ -33,59 +35,76 @@ interface FilterBarProps {
*/
export function FilterBar({
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
children, advanced, advancedActive, onClearAdvanced, className,
children, advanced, advancedActive, onClearAdvanced, searchFirst, className,
}: 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 (
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
{children}
<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">
<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>
{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>
)}
{searchBox}
{advancedPopover}
</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>
)
}
+26
View File
@@ -5,10 +5,12 @@ import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
Item,
ItemImage,
ItemListItem,
ItemOverview,
ItemReorderSetting,
ItemScanResult,
StockNature,
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
@@ -34,8 +36,13 @@ export interface ListItemOverviewParams {
categoryId?: number
subCategoryId?: number
brandId?: number
trackingMode?: TrackingMode
stockNature?: StockNature
stock?: ItemStockFilter
}
export type ItemStockFilter = "InStock" | "OutOfStock" | "LowStock"
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
@@ -46,6 +53,25 @@ export const itemsApi = {
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
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>> {
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. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
}
},}
/** Read-only helper for screens that only need the flags. */
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 })
},
}
+12
View File
@@ -20,6 +20,8 @@ import {
SalesSlipStatus,
SalesSlipPostingCheck,
SalesSlipSummary,
SalesStockAvailability,
SalesSuggestedPrice,
UpdateSalesInvoiceRequest,
UpdateSalesSlipRequest,
} from "@/types/sales"
@@ -46,6 +48,16 @@ export const salesApi = {
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>> {
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. */
export function validateItemTypeName(name: string): 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
}
+51
View File
@@ -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 }
}
+37 -3
View File
@@ -11,9 +11,10 @@ export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
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 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.
@@ -364,11 +365,17 @@ export interface ProductConfig {
serialNumbersEnabled: boolean
batchListsEnabled: 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
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 {
subcategoriesEnabled: boolean
brandsEnabled: boolean
@@ -377,6 +384,33 @@ export interface UpdateProductConfigRequest {
serialNumbersEnabled: boolean
batchListsEnabled: 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 --------------------------------------------------------
+45
View File
@@ -65,6 +65,51 @@ export interface SalesInvoicePostingIssue {
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 {
salesInvoiceId: number
invoiceNo: string