Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ec3b4c65 | |||
| 3d3dbbbb59 | |||
| cea433e320 | |||
| 9fd290e256 | |||
| 20ecfdc0b2 | |||
| dd82f5b5c4 | |||
| ce55d647b5 | |||
| 268b6562dc | |||
| b389c01dfe |
@@ -23,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false,
|
||||
[FromQuery] int? branchId = null)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/grn-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnConfigService _config;
|
||||
|
||||
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
|
||||
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,70 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||
[HttpGet("stock-summary")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -31,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
@@ -94,6 +155,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||
/// </summary>
|
||||
[HttpPost("payments/bulk")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
|
||||
/// Controls which warehouse goods are received into.
|
||||
/// </summary>
|
||||
public class GrnConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
|
||||
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
|
||||
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
|
||||
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
|
||||
/// (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
|
||||
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
|
||||
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
|
||||
public bool AllowVat { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
|
||||
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
|
||||
/// only reachable through the authenticated items API — never a static path. Only uploadable
|
||||
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
|
||||
/// after the flag is switched off (reads are never gated).
|
||||
/// </summary>
|
||||
public class ItemImage
|
||||
{
|
||||
public int ItemImageId { get; set; }
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Display order; the lowest is the item's main image.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
}
|
||||
@@ -31,6 +31,26 @@ public class ProductConfig
|
||||
public bool BatchListsEnabled { get; set; } = true;
|
||||
public bool WarrantyPeriodsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Lets the item create screen add a new item variant (item type) on the spot, instead
|
||||
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
|
||||
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
|
||||
/// </summary>
|
||||
public bool CustomVariantsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
|
||||
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
|
||||
/// </summary>
|
||||
public bool ItemImagesEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
|
||||
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
|
||||
/// themselves are managed under Products, not here.
|
||||
/// </summary>
|
||||
public bool CustomWarrantyPeriodsEnabled { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
|
||||
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
|
||||
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
|
||||
/// form defaults to it; when off, a real customer must be picked and the cash customer is
|
||||
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
|
||||
/// </summary>
|
||||
public class SalesConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
public bool AllowCashCustomer { get; set; }
|
||||
|
||||
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
|
||||
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
|
||||
/// off and on again reuses the same customer (and its sales history).</summary>
|
||||
public int? CashCustomerId { get; set; }
|
||||
public Customer? CashCustomer { get; set; }
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
|
||||
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowFreeItems { get; set; } = true;
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
|
||||
/// column); when off, the column is hidden and a discount above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
|
||||
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
|
||||
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
|
||||
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
|
||||
public bool AllowOverStockSale { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
|
||||
/// and every sales invoice (header and lines) is forced onto it server-side (see
|
||||
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
|
||||
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public class User
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const int SystemUserId = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Username prefix of a permanently deleted login whose row is kept only because history
|
||||
/// (audit log, documents) still references it. Such rows are hidden from user management.
|
||||
/// </summary>
|
||||
public const string DeletedUsernamePrefix = "deleted-user-";
|
||||
|
||||
public int UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — a coverage length offered to items alongside the fixed
|
||||
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
|
||||
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
|
||||
/// <para>
|
||||
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
|
||||
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
|
||||
/// deactivating a period only stops it being offered — items already using it keep it.
|
||||
/// The length is immutable (no PUT); a different length is a different row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WarrantyPeriod
|
||||
{
|
||||
public int WarrantyPeriodId { get; set; }
|
||||
public int Months { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>GRN configuration resource.</summary>
|
||||
public sealed record GrnConfigDto(
|
||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, bool AllowDiscounts, bool AllowVat,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||
/// (same reasoning as <see cref="UpdateSalesConfigRequest"/>).
|
||||
/// </summary>
|
||||
public sealed class UpdateGrnConfigRequest
|
||||
{
|
||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
[Required] public bool? AllowDiscounts { get; set; }
|
||||
[Required] public bool? AllowVat { get; set; }
|
||||
}
|
||||
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
|
||||
public sealed record ProductConfigDto(
|
||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||
bool CustomWarrantyPeriodsEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
|
||||
[Required] public bool? SerialNumbersEnabled { get; set; }
|
||||
[Required] public bool? BatchListsEnabled { get; set; }
|
||||
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
|
||||
[Required] public bool? CustomVariantsEnabled { get; set; }
|
||||
[Required] public bool? ItemImagesEnabled { get; set; }
|
||||
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
|
||||
/// the generic cash customer (null until the option has been switched on at least once).</summary>
|
||||
public sealed record SalesConfigDto(
|
||||
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
|
||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
|
||||
/// </summary>
|
||||
public sealed class UpdateSalesConfigRequest
|
||||
{
|
||||
[Required] public bool? AllowCashCustomer { get; set; }
|
||||
[Required] public bool? AllowFreeItems { get; set; }
|
||||
[Required] public bool? AllowDiscounts { get; set; }
|
||||
[Required] public bool? AllowOverStockSale { get; set; }
|
||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
}
|
||||
@@ -23,7 +23,12 @@ public sealed record DashboardStatsDto(
|
||||
int PendingCounts,
|
||||
int OpenRfqs,
|
||||
int PendingLeaveRequests,
|
||||
int OverdueSalesInvoices);
|
||||
int OverdueSalesInvoices,
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
|
||||
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
|
||||
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
|
||||
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
|
||||
|
||||
@@ -133,5 +133,6 @@ public sealed class ReorderSettingInput
|
||||
|
||||
public sealed class UpdateReorderRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
|
||||
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>An item photo's metadata. The bytes are served by
|
||||
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
|
||||
public sealed record ItemImageDto(
|
||||
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
|
||||
int SortOrder, DateTime UploadedAt);
|
||||
@@ -16,3 +16,12 @@ public sealed record ItemOverviewDto(
|
||||
decimal StockQty, decimal StockValue, bool IsLowStock,
|
||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
||||
decimal SalesQtyLast30Days);
|
||||
|
||||
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
|
||||
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
|
||||
public enum ItemStockFilter
|
||||
{
|
||||
InStock,
|
||||
OutOfStock,
|
||||
LowStock
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>A new custom length, always in months (the client converts years).</summary>
|
||||
public sealed class CreateWarrantyPeriodRequest
|
||||
{
|
||||
[Required] public int? Months { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateWarrantyPeriodStatusRequest
|
||||
{
|
||||
[Required] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
|
||||
public sealed record SalesInvoiceSummaryDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
@@ -34,6 +34,28 @@ public sealed class CreateSalesInvoicePaymentRequest
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
|
||||
public sealed class BulkSalesInvoicePaymentLine
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
|
||||
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
|
||||
/// </summary>
|
||||
public sealed class CreateBulkSalesInvoicePaymentRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
|
||||
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
@@ -44,6 +66,18 @@ public sealed record SalesInvoicePostingCheckDto(
|
||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
|
||||
|
||||
/// <summary>The unit price a new line would get if left blank — same rule as on save
|
||||
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
|
||||
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
|
||||
|
||||
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
|
||||
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
|
||||
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
|
||||
|
||||
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
|
||||
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
|
||||
public sealed record SalesItemStockDto(int ItemId, decimal Available);
|
||||
|
||||
public sealed class CreateSalesInvoiceLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton GRN-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
|
||||
/// </summary>
|
||||
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnConfig> builder)
|
||||
{
|
||||
builder.ToTable("grn_config", t =>
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||
// instead of being swallowed by the database default.
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class ItemImageConfiguration : IEntityTypeConfiguration<ItemImage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ItemImage> builder)
|
||||
{
|
||||
builder.ToTable("item_images");
|
||||
builder.HasKey(i => i.ItemImageId);
|
||||
|
||||
builder.Property(i => i.OriginalFileName).IsRequired().HasMaxLength(260);
|
||||
builder.Property(i => i.StoredFileName).IsRequired().HasMaxLength(260);
|
||||
builder.Property(i => i.RelativePath).IsRequired().HasMaxLength(500);
|
||||
builder.Property(i => i.ContentType).IsRequired().HasMaxLength(200);
|
||||
builder.Property(i => i.UploadedAt).IsRequired();
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany()
|
||||
.HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(i => new { i.ItemId, i.SortOrder });
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
|
||||
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
|
||||
|
||||
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton sales-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors BranchConfig).
|
||||
/// </summary>
|
||||
public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SalesConfig> builder)
|
||||
{
|
||||
builder.ToTable("sales_config", t =>
|
||||
t.HasCheckConstraint("ck_sales_config_singleton", $"\"ConfigId\" = {SalesConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.AllowCashCustomer).IsRequired().HasDefaultValue(false);
|
||||
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||
// instead of being swallowed by the database default.
|
||||
builder.Property(c => c.AllowFreeItems).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.CashCustomer)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CashCustomerId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
|
||||
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
|
||||
{
|
||||
builder.ToTable("warranty_periods", t =>
|
||||
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
|
||||
builder.HasKey(p => p.WarrantyPeriodId);
|
||||
|
||||
builder.Property(p => p.Months).IsRequired();
|
||||
builder.HasIndex(p => p.Months).IsUnique();
|
||||
|
||||
builder.Property(p => p.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
builder.HasIndex(p => p.Status);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public static class DataSeeder
|
||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||
dirty |= await SeedSalesStockAsync(db, ct);
|
||||
@@ -106,7 +108,10 @@ public static class DataSeeder
|
||||
SellingPricesEnabled = true,
|
||||
SerialNumbersEnabled = true,
|
||||
BatchListsEnabled = true,
|
||||
WarrantyPeriodsEnabled = true
|
||||
WarrantyPeriodsEnabled = true,
|
||||
CustomVariantsEnabled = false,
|
||||
ItemImagesEnabled = false,
|
||||
CustomWarrantyPeriodsEnabled = false
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -128,6 +133,28 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
|
||||
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
|
||||
/// </summary>
|
||||
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
|
||||
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
|
||||
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
|
||||
public DbSet<Item> Items => Set<Item>();
|
||||
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
|
||||
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
|
||||
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
|
||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||
public DbSet<Bin> Bins => Set<Bin>();
|
||||
@@ -43,6 +45,8 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
|
||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllowCashCustomer = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
CashCustomerId = table.Column<int>(type: "integer", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_config_customers_CashCustomerId",
|
||||
column: x => x.CashCustomerId,
|
||||
principalTable: "customers",
|
||||
principalColumn: "CustomerId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_CashCustomerId",
|
||||
table: "sales_config",
|
||||
column: "CashCustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_UpdatedBy",
|
||||
table: "sales_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8804
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowFreeItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowOverStockSale : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowOverStockSale",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowOverStockSale",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8826
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigWarehouseSelection : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId",
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8831
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductConfigCustomVariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CustomVariantsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomVariantsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_images",
|
||||
columns: table => new
|
||||
{
|
||||
ItemImageId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
|
||||
table.ForeignKey(
|
||||
name: "FK_item_images_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_images_ItemId_SortOrder",
|
||||
table: "item_images",
|
||||
columns: new[] { "ItemId", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "item_images");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomWarrantyPeriods : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "warranty_periods",
|
||||
columns: table => new
|
||||
{
|
||||
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Months = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
|
||||
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Months",
|
||||
table: "warranty_periods",
|
||||
column: "Months",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Status",
|
||||
table: "warranty_periods",
|
||||
column: "Status");
|
||||
|
||||
// Carry over any periods already saved on the config (hand-added — EF would have
|
||||
// dropped the column first and lost them). Out-of-range values are skipped rather
|
||||
// than failing the check constraint.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
|
||||
SELECT DISTINCT m, 'Active', now()
|
||||
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
|
||||
WHERE m BETWEEN 1 AND 600
|
||||
ON CONFLICT ("Months") DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
|
||||
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
|
||||
'{}'::integer[]);
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "warranty_periods");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8944
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowDiscounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllowWarehouseSelection = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
DefaultWarehouseId = table.Column<int>(type: "integer", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_warehouses_DefaultWarehouseId",
|
||||
column: x => x.DefaultWarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_DefaultWarehouseId",
|
||||
table: "grn_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_UpdatedBy",
|
||||
table: "grn_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfigDiscountsVat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowVat",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowVat",
|
||||
table: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowVat")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("grn_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Property<int>("GrnLineId")
|
||||
@@ -1831,6 +1878,56 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
|
||||
{
|
||||
b.Property<int>("ItemImageId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("character varying(260)");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("UploadedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ItemImageId");
|
||||
|
||||
b.HasIndex("ItemId", "SortOrder");
|
||||
|
||||
b.ToTable("item_images", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||
{
|
||||
b.Property<int>("ReorderId")
|
||||
@@ -3252,6 +3349,21 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("CustomVariantsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("CustomWarrantyPeriodsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("ItemImagesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("ItemTypesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -4337,6 +4449,68 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_salary_components", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowCashCustomer")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowFreeItems")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowOverStockSale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("CashCustomerId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("CashCustomerId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("sales_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Property<int>("SalesDayEndId")
|
||||
@@ -6593,6 +6767,43 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("warehouses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
|
||||
{
|
||||
b.Property<int>("WarrantyPeriodId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Months")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("WarrantyPeriodId");
|
||||
|
||||
b.HasIndex("Months")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("warranty_periods", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
|
||||
{
|
||||
b.Property<int>("WorkShiftId")
|
||||
@@ -7047,6 +7258,23 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
@@ -7145,6 +7373,17 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("TaxRate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -7835,6 +8074,30 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashCustomerId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CashCustomer");
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
|
||||
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
// Master-data services (docs/11 §2)
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IItemService, ItemService>();
|
||||
builder.Services.AddScoped<IItemImageService, ItemImageService>();
|
||||
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
|
||||
builder.Services.AddScoped<IUomService, UomService>();
|
||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||
@@ -95,6 +97,8 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
|
||||
@@ -16,19 +16,41 @@ public sealed class CustomerService : ICustomerService
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
|
||||
public CustomerService(
|
||||
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_salesConfig = salesConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _customers.Query().AsNoTracking();
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||
if (!includeCashCustomer)
|
||||
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
|
||||
// under. The cash customer is shared by every branch, so it is never filtered out here.
|
||||
if (branchId is not null)
|
||||
q = q.Where(c => c.BranchId == branchId
|
||||
|| c.CustomerCode == CashCustomerCode
|
||||
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class DashboardService : IDashboardService
|
||||
private readonly IRepository<Rfq> _rfqs;
|
||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
||||
@@ -33,11 +34,14 @@ public sealed class DashboardService : IDashboardService
|
||||
// balance older than this many days from its invoice date.
|
||||
private const int OverdueAgeDays = 30;
|
||||
|
||||
// Monthly Purchases chart window: the current month plus the previous 11.
|
||||
private const int ChartMonths = 12;
|
||||
|
||||
public DashboardService(
|
||||
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
||||
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
|
||||
IReorderService reorder)
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
@@ -47,6 +51,7 @@ public sealed class DashboardService : IDashboardService
|
||||
_rfqs = rfqs;
|
||||
_leaveRequests = leaveRequests;
|
||||
_salesInvoices = salesInvoices;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
@@ -103,9 +108,50 @@ public sealed class DashboardService : IDashboardService
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
.Pagination.TotalItems;
|
||||
|
||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices);
|
||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last
|
||||
/// <see cref="ChartMonths"/> months, oldest first. Months with neither are omitted, so an
|
||||
/// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate,
|
||||
/// sales by InvoiceDate.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<MonthlyPurchasesVsSalesDto>> GetMonthlyPurchasesVsSalesAsync(
|
||||
int? branchId, IQueryable<SalesInvoice> salesInvoices, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1));
|
||||
|
||||
var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking();
|
||||
if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId);
|
||||
var purchases = await purchaseInvoices
|
||||
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since)
|
||||
.GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var sales = await salesInvoices
|
||||
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since)
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total);
|
||||
var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total);
|
||||
|
||||
return Enumerable.Range(0, ChartMonths)
|
||||
.Select(offset => since.AddMonths(offset))
|
||||
.Select(m => new MonthlyPurchasesVsSalesDto(
|
||||
$"{m.Year:D4}-{m.Month:D2}",
|
||||
purchasesByMonth.GetValueOrDefault((m.Year, m.Month)),
|
||||
salesByMonth.GetValueOrDefault((m.Year, m.Month))))
|
||||
.Where(m => m.Purchases != 0m || m.Sales != 0m)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>GRN configuration service over the singleton row seeded by DataSeeder.</summary>
|
||||
public sealed class GrnConfigService : IGrnConfigService
|
||||
{
|
||||
private readonly IRepository<GrnConfig> _config;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public GrnConfigService(
|
||||
IRepository<GrnConfig> config, IRepository<Warehouse> warehouses, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_warehouses = warehouses;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||
|
||||
return new ETagged<GrnConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(GrnConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default receiving warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowVat = request.AllowVat!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (config is null) return null;
|
||||
// A saved default receiving warehouse always wins — GRNs use only that warehouse.
|
||||
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||
if (config.AllowWarehouseSelection) return null;
|
||||
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in GRN Configuration but no default receiving warehouse is set.", 422);
|
||||
}
|
||||
|
||||
public async Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||
.Select(c => new { c.AllowDiscounts, c.AllowVat })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return config is null ? (true, true) : (config.AllowDiscounts, config.AllowVat);
|
||||
}
|
||||
|
||||
private static GrnConfigDto Map(GrnConfig c) => new(
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.AllowDiscounts, c.AllowVat, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public sealed class GrnService : IGrnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IGrnConfigService _grnConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly INotificationService _notifications;
|
||||
@@ -52,7 +53,7 @@ public sealed class GrnService : IGrnService
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
@@ -70,6 +71,7 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_grnConfig = grnConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_notifications = notifications;
|
||||
@@ -118,6 +120,10 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// GRN Configuration default receiving warehouse: whatever the client sent, the GRN uses it.
|
||||
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
|
||||
request.WarehouseId = forcedWarehouseId;
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
@@ -144,6 +150,12 @@ public sealed class GrnService : IGrnService
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
|
||||
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
|
||||
if (!allowVat && request.Lines.Any(l => l.VatPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "VAT is turned off in GRN Configuration.", 422);
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
@@ -187,7 +199,8 @@ public sealed class GrnService : IGrnService
|
||||
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
|
||||
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
|
||||
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
||||
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
|
||||
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
||||
: 0m);
|
||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
@@ -285,9 +285,18 @@ public sealed class EmployeeService : IEmployeeService
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Every save a user makes writes an AuditLog row against their UserId, so almost any
|
||||
// real user is still referenced by history and can't be hard-deleted. The login is
|
||||
// still permanently removed: the row is kept only as a tombstone so historical
|
||||
// "created by" references stay intact — credentials/contact scrubbed, role revoked,
|
||||
// and hidden from Settings > Users. The AuthHex identity is purged below as usual.
|
||||
// Update() flips the entity back from Deleted, or the next save would retry the delete.
|
||||
_users.Update(user);
|
||||
user.Username = User.DeletedUsernamePrefix + user.UserId;
|
||||
user.Email = null;
|
||||
user.RoleId = null;
|
||||
user.Status = EntityStatus.Inactive;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new EmployeeDeleteResultDto(UserAccountDeleted: false, UserAccountDeactivated: true, AuthHexIdentityPurged: false);
|
||||
}
|
||||
|
||||
// The local User row is gone, but AuthHex's own duplicate check (CanRegisterUser) has no
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
||||
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
@@ -24,10 +24,16 @@ public interface IFifoCostingService
|
||||
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
|
||||
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
|
||||
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
|
||||
/// and ledger costing). Must run inside a UoW transaction.
|
||||
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
|
||||
/// earlier over-stock sale are settled against issuable stock first.
|
||||
/// </summary>
|
||||
/// <param name="allowShortfall">
|
||||
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
|
||||
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
|
||||
/// </param>
|
||||
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false);
|
||||
|
||||
/// <summary>
|
||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>GRN configuration business logic. Singleton.</summary>
|
||||
public interface IGrnConfigService
|
||||
{
|
||||
Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>GRN Configuration default receiving warehouse: when one is saved, every GRN must
|
||||
/// use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||
Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Whether GRN lines may carry a discount and VAT (both default to allowed).</summary>
|
||||
Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
|
||||
public interface IItemImageService
|
||||
{
|
||||
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
|
||||
|
||||
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
|
||||
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public interface IItemService
|
||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
@@ -9,5 +9,4 @@ public interface IProductConfigService
|
||||
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Sales configuration business logic. Singleton.</summary>
|
||||
public interface ISalesConfigService
|
||||
{
|
||||
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
|
||||
Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -12,6 +12,19 @@ public interface ISalesDomainService
|
||||
bool requireCashierUser,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
|
||||
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
|
||||
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
|
||||
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
|
||||
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
|
||||
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
|
||||
@@ -6,5 +6,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface ISalesInvoicePaymentService
|
||||
{
|
||||
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
/// <summary>One customer, several invoices, one GL journal entry; returns a payment per invoice/account split.</summary>
|
||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -12,6 +12,9 @@ public interface IStockService
|
||||
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
|
||||
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Custom warranty period master. Never hard-deleted (FR-MD-08).</summary>
|
||||
public interface IWarrantyPeriodService
|
||||
{
|
||||
Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>1–600 months, not a default length. Re-adding an inactive length reactivates it;
|
||||
/// an active duplicate is a 409.</summary>
|
||||
Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default);
|
||||
|
||||
Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
|
||||
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
|
||||
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
|
||||
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
|
||||
/// images added earlier remain manageable.
|
||||
/// </summary>
|
||||
public sealed class ItemImageService : IItemImageService
|
||||
{
|
||||
public const int MaxImagesPerItem = 10;
|
||||
public const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".jpg"] = "image/jpeg",
|
||||
[".jpeg"] = "image/jpeg",
|
||||
[".png"] = "image/png",
|
||||
[".webp"] = "image/webp"
|
||||
};
|
||||
|
||||
private readonly IRepository<ItemImage> _images;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IFileStorageService _storage;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemImageService(
|
||||
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
|
||||
IFileStorageService storage, IUnitOfWork uow)
|
||||
{
|
||||
_images = images;
|
||||
_items = items;
|
||||
_config = config;
|
||||
_storage = storage;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
return await _images.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
|
||||
throw new DomainException(ErrorCodes.ConfigDisabled,
|
||||
"Item images are disabled in the product configuration.", 422);
|
||||
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|
||||
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
|
||||
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
|
||||
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
|
||||
|
||||
if (content.Length > MaxSizeBytes)
|
||||
throw new DomainException(ErrorCodes.FileTooLarge,
|
||||
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
|
||||
|
||||
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
if (existing.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
|
||||
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
|
||||
|
||||
var image = new ItemImage
|
||||
{
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _images.AddAsync(image, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
|
||||
image.SizeBytes, image.SortOrder, image.UploadedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
_images.Remove(image);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
// File removed only after the row is gone, so a failed save never orphans a row.
|
||||
await _storage.DeleteAsync(image.RelativePath, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
@@ -127,6 +128,24 @@ public sealed class ItemService : IItemService
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
|
||||
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
|
||||
var layers = _stockLayers.Query();
|
||||
switch (stock)
|
||||
{
|
||||
case ItemStockFilter.InStock:
|
||||
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
|
||||
break;
|
||||
case ItemStockFilter.OutOfStock:
|
||||
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
|
||||
break;
|
||||
case ItemStockFilter.LowStock:
|
||||
q = q.Where(i => i.ReorderSettings.Any(rs =>
|
||||
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
|
||||
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
|
||||
break;
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
||||
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(
|
||||
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
|
||||
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
|
||||
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
|
||||
/// just the shared presets, and an item may also carry a length of its own entered on the
|
||||
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
|
||||
/// is also accepted, so switching the feature off never blocks editing an item that already
|
||||
/// uses a custom length. A non-warranty item carries none — any value sent for one is
|
||||
/// silently dropped rather than trusted from the client.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
private static int? ValidateWarrantyPeriod(
|
||||
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
var valid = warrantyPeriodMonths is not null
|
||||
&& (warrantyPeriodMonths == currentMonths
|
||||
|| (config.CustomWarrantyPeriodsEnabled
|
||||
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
|
||||
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
|
||||
|
||||
if (!valid)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
config.CustomWarrantyPeriodsEnabled
|
||||
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
|
||||
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
|
||||
422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
// Custom variants live in the item-variants section - switching product variants
|
||||
// off switches this off too, so it can never be left on by itself.
|
||||
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
|
||||
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||
c.CustomVariantsEnabled, c.ItemImagesEnabled,
|
||||
c.CustomWarrantyPeriodsEnabled,
|
||||
c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
|
||||
/// generic "Cash Customer" record — created the first time cash sales are allowed.
|
||||
/// </summary>
|
||||
public sealed class SalesConfigService : ISalesConfigService
|
||||
{
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
private const string CashCustomerName = "Cash Customer";
|
||||
|
||||
private readonly IRepository<SalesConfig> _config;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesConfigService(
|
||||
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
|
||||
// later throw would otherwise leave the row half-updated.
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
|
||||
if (config.AllowCashCustomer && config.CashCustomerId is null)
|
||||
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
|
||||
config.AllowFreeItems = request.AllowFreeItems!.Value;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
|
||||
// creating a new one on the deployment's default branch.
|
||||
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
|
||||
{
|
||||
var existingId = await _customers.Query().AsNoTracking()
|
||||
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
|
||||
if (existingId != 0) return existingId;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CustomerCode = CashCustomerCode,
|
||||
CustomerType = CustomerType.WalkIn,
|
||||
Name = CashCustomerName,
|
||||
DisplayName = CashCustomerName,
|
||||
CreditLimit = 0m,
|
||||
CreditDays = 0,
|
||||
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customers.AddAsync(customer, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return customer.CustomerId;
|
||||
}
|
||||
|
||||
private static SalesConfigDto Map(SalesConfig c) => new(
|
||||
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
|
||||
public SalesDomainService(
|
||||
IRepository<Customer> customers,
|
||||
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<TaxRate> taxRates,
|
||||
ISalesPricingService pricing)
|
||||
ISalesPricingService pricing,
|
||||
IRepository<SalesConfig> salesConfig)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
_items = items;
|
||||
_taxRates = taxRates;
|
||||
_pricing = pricing;
|
||||
_salesConfig = salesConfig;
|
||||
}
|
||||
|
||||
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||
// The generic cash customer is only usable while Sales Configuration allows it.
|
||||
var cashConfig = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||
if (requireCashierUser)
|
||||
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
}
|
||||
}
|
||||
|
||||
// A missing config row means the option predates it — free items stay allowed.
|
||||
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowFreeItems)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
// A missing config row means the option predates it — discounts stay allowed.
|
||||
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => (bool?)c.AllowDiscounts)
|
||||
.FirstOrDefaultAsync(ct) ?? true;
|
||||
|
||||
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (config is null) return null;
|
||||
// A saved default sales warehouse always wins — sales use only that warehouse.
|
||||
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||
if (config.AllowWarehouseSelection) return null;
|
||||
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
|
||||
}
|
||||
|
||||
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
|
||||
await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.AllowOverStockSale)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesInvoicePayment> _payments;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
|
||||
public SalesInvoicePaymentService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_salesConfig = salesConfig;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = invoice.InvoiceNo,
|
||||
Narration = $"Payment against invoice {invoice.InvoiceNo}",
|
||||
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ids = request.SalesInvoiceIds.Distinct().ToList();
|
||||
if (ids.Count == 0 || request.Lines.Count == 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
|
||||
|
||||
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
|
||||
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
|
||||
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
|
||||
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
|
||||
|
||||
// The generic cash customer's invoices are paid one at a time, never grouped.
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
|
||||
|
||||
foreach (var inv in invoices)
|
||||
{
|
||||
if (inv.Status != SalesInvoiceStatus.Posted)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
|
||||
if (inv.BalanceAmount <= 0)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
|
||||
}
|
||||
|
||||
var total = request.Lines.Sum(l => l.Amount);
|
||||
var totalBalance = invoices.Sum(i => i.BalanceAmount);
|
||||
if (total > totalBalance)
|
||||
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
|
||||
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var lines = request.Lines.Select(l => (
|
||||
Line: l,
|
||||
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
|
||||
.ToList();
|
||||
|
||||
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
||||
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
||||
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
|
||||
var invIdx = 0;
|
||||
var invRemaining = ordered[0].BalanceAmount;
|
||||
foreach (var (line, account) in lines)
|
||||
{
|
||||
var lineRemaining = line.Amount;
|
||||
while (lineRemaining > 0)
|
||||
{
|
||||
var take = Math.Min(lineRemaining, invRemaining);
|
||||
allocations.Add((ordered[invIdx], account, take));
|
||||
lineRemaining -= take;
|
||||
invRemaining -= take;
|
||||
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
||||
invRemaining = ordered[++invIdx].BalanceAmount;
|
||||
}
|
||||
}
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
|
||||
var narration = $"Payment against invoices {paidNos}";
|
||||
if (narration.Length > 250) narration = narration[..247] + "...";
|
||||
|
||||
var payments = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var glLines = lines
|
||||
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
||||
.ToList();
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = request.Reference ?? ordered[0].InvoiceNo,
|
||||
Narration = narration,
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
var entities = new List<SalesInvoicePayment>();
|
||||
foreach (var (invoice, account, amount) in allocations)
|
||||
{
|
||||
var entity = new SalesInvoicePayment
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = amount,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
invoice.PaidAmount += amount;
|
||||
invoice.BalanceAmount -= amount;
|
||||
entities.Add(entity);
|
||||
}
|
||||
return entities;
|
||||
}, ct);
|
||||
|
||||
return payments.Select(Map).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
||||
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
|
||||
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
|
||||
/// that day's start.
|
||||
/// </summary>
|
||||
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
var day = requested ?? today;
|
||||
if (day > today.AddDays(1))
|
||||
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
// Invoices have no CashierUserId — Day End matches them to a cashier by CreatedBy.
|
||||
if (createdBy is not null) q = q.Where(x => x.CreatedBy == createdBy);
|
||||
// paid=true: nothing left to pay; paid=false: a balance is still due.
|
||||
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
|
||||
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
@@ -86,6 +91,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
@@ -122,6 +128,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
{
|
||||
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
|
||||
|
||||
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||
invoice.CustomerId = request.CustomerId;
|
||||
invoice.WarehouseId = request.WarehouseId;
|
||||
@@ -157,9 +164,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
|
||||
// and every line use the configured default sales warehouse.
|
||||
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
|
||||
{
|
||||
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
|
||||
foreach (var line in lines) line.WarehouseId = forced;
|
||||
return forced;
|
||||
}
|
||||
|
||||
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesInvoiceLine>();
|
||||
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
|
||||
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
|
||||
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
@@ -209,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
}
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
|
||||
throw new ConflictException(ensureDraftMessage(doc));
|
||||
|
||||
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
|
||||
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
|
||||
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var totalCogs = 0m;
|
||||
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
// Line quantities are already a count of the item's base UOM — sales documents
|
||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
totalCogs += cost * line.Qty;
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
|
||||
|
||||
@@ -21,8 +21,17 @@ public sealed class SalesPricingService : ISalesPricingService
|
||||
_fifo = fifo;
|
||||
}
|
||||
|
||||
// Sales unit prices are money — always 2dp, whichever source (flat, GRN weighted avg, FIFO avg,
|
||||
// manual) produced them, so the suggested-price endpoint and saved lines agree.
|
||||
public async Task<SalesPriceResolution> ResolveAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
||||
{
|
||||
var resolved = await ResolveUnroundedAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
|
||||
return resolved with { UnitPrice = Math.Round(resolved.UnitPrice, 2, MidpointRounding.AwayFromZero) };
|
||||
}
|
||||
|
||||
private async Task<SalesPriceResolution> ResolveUnroundedAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
|
||||
?? throw new InvalidOperationException($"Item {itemId} was not found.");
|
||||
|
||||
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
|
||||
}
|
||||
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
|
||||
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
|
||||
// sits quarantined until a Purchase Return consumes it directly (see
|
||||
// PurchaseReturnService), never through this general-issue path. No LINQ is
|
||||
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
|
||||
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
|
||||
// parameter — without it a null batch filter fails with 42P18.
|
||||
var layers = await _db.StockLayers
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
|
||||
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
|
||||
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
|
||||
// Return consumes it directly (see PurchaseReturnService), never through this
|
||||
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
|
||||
// reaches the database intact.
|
||||
var locked = await _db.StockLayers
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM stock_layers sl
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
|
||||
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
|
||||
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
|
||||
ORDER BY sl."ReceiptDate", sl."LayerId"
|
||||
FOR UPDATE
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
|
||||
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
|
||||
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
|
||||
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
|
||||
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
|
||||
foreach (var shortfall in owed)
|
||||
{
|
||||
foreach (var layer in open)
|
||||
{
|
||||
if (shortfall.QtyRemaining >= 0) break;
|
||||
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= settle;
|
||||
shortfall.QtyRemaining += settle;
|
||||
}
|
||||
}
|
||||
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase)
|
||||
if (available < qtyBase && !allowShortfall)
|
||||
{
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
|
||||
}
|
||||
|
||||
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
|
||||
// below zero), costed at the item's last known unit cost — the next issue from this
|
||||
// warehouse settles it against the stock received in the meantime (see above).
|
||||
if (remaining > 0)
|
||||
{
|
||||
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
|
||||
var shortfall = new StockLayer
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = warehouseId,
|
||||
QtyReceived = 0m,
|
||||
QtyRemaining = -remaining,
|
||||
UnitCost = unitCost,
|
||||
ReceiptDate = now
|
||||
};
|
||||
await _layers.AddAsync(shortfall, ct);
|
||||
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
|
||||
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
|
||||
return await layers.Where(l => l.WarehouseId == warehouseId)
|
||||
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
|
||||
?? 0m;
|
||||
}
|
||||
|
||||
public async Task<StockLedger> PostLedgerAsync(
|
||||
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var open = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
|
||||
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -132,6 +132,18 @@ public sealed class StockService : IStockService
|
||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same figure as <see cref="GetOnHandAsync"/>'s Available (all layers, negative ones included,
|
||||
/// less held OnHold/Rejected GRN stock), for every item at the warehouse in one grouped query.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.WarehouseId == warehouseId
|
||||
&& !(l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected)))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
|
||||
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — shared presets offered on every item's form on top of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>. Not an allow-list: with custom periods on, an
|
||||
/// item may also carry a one-off length of its own. Nothing references these rows (items
|
||||
/// store months), so deactivating is always safe and there is no in-use check.
|
||||
/// </summary>
|
||||
public sealed class WarrantyPeriodService : IWarrantyPeriodService
|
||||
{
|
||||
public const int MaxMonths = 600;
|
||||
|
||||
private readonly IRepository<WarrantyPeriod> _periods;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public WarrantyPeriodService(IRepository<WarrantyPeriod> periods, IUnitOfWork uow)
|
||||
{
|
||||
_periods = periods;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<WarrantyPeriodDto>> ListAsync(EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _periods.Query().AsNoTracking();
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
return await q.OrderBy(p => p.Months)
|
||||
.Select(p => new WarrantyPeriodDto(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<WarrantyPeriodDto> CreateAsync(int months, CancellationToken ct = default)
|
||||
{
|
||||
if (months < 1 || months > MaxMonths)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"A warranty period must be between 1 and {MaxMonths} months.", 422);
|
||||
if (WarrantyPeriods.AllowedMonths.Contains(months))
|
||||
throw new ConflictException($"{months} months is already a default warranty period.");
|
||||
|
||||
var existing = await _periods.Query().FirstOrDefaultAsync(p => p.Months == months, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status == EntityStatus.Active)
|
||||
throw new ConflictException($"A {months}-month warranty period already exists.");
|
||||
|
||||
// Unique by length: re-adding a deactivated one brings it back rather than failing.
|
||||
existing.Status = EntityStatus.Active;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(existing);
|
||||
}
|
||||
|
||||
var period = new WarrantyPeriod { Months = months, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
await _periods.AddAsync(period, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(period);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int warrantyPeriodId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var period = await _periods.GetByIdAsync(warrantyPeriodId, ct)
|
||||
?? throw new NotFoundException($"Warranty period {warrantyPeriodId} was not found.");
|
||||
|
||||
period.Status = status;
|
||||
period.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static WarrantyPeriodDto Map(WarrantyPeriod p) => new(p.WarrantyPeriodId, p.Months, p.Status, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
@@ -81,7 +81,11 @@ export default function CustomersPage() {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType])
|
||||
// Branch-wise list, same as Sales / GRN: the header's active branch (a non-admin's own branch);
|
||||
// an Admin on "All Branches" sees every branch's customers.
|
||||
const listBranchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType, listBranchId])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
@@ -90,6 +94,7 @@ export default function CustomersPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
customerType: customerType === "All" ? undefined : customerType,
|
||||
branchId: listBranchId,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
@@ -106,10 +111,11 @@ export default function CustomersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
|
||||
@@ -519,7 +519,7 @@ export default function EmployeesPage() {
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="This employee has login access"
|
||||
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
|
||||
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login credentials? This cannot be undone — they will no longer be able to sign in, and the user will be removed from Settings > Users. Choose "Keep, but Deactivate" to only disable the login.`}
|
||||
confirmLabel="Delete User Access"
|
||||
cancelLabel="Keep, but Deactivate"
|
||||
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Shown inside the dashboard shell (sidebar and header stay put) while the next page's code
|
||||
* loads — including programmatic navigations the top progress bar can't see start.
|
||||
*/
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="ml-auto mt-4 w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
|
||||
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -35,6 +36,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
|
||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||
@@ -115,6 +118,8 @@ export default function ItemDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [imagesEnabled, setImagesEnabled] = useState(false)
|
||||
const [imagesVersion, setImagesVersion] = useState(0)
|
||||
|
||||
function applyItem(data: Item) {
|
||||
setItem(data)
|
||||
@@ -162,6 +167,7 @@ export default function ItemDetailPage() {
|
||||
setTaxRates(tr.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
@@ -254,6 +260,7 @@ export default function ItemDetailPage() {
|
||||
function closeEdit() {
|
||||
if (item) applyItem(item)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -282,6 +289,7 @@ export default function ItemDetailPage() {
|
||||
applyItem(result.data)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
@@ -408,6 +416,20 @@ export default function ItemDetailPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
{/* View-only here; images are added/removed from the Edit dialog. Re-keyed when that
|
||||
dialog closes so this copy picks up what changed there. */}
|
||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||
|
||||
<ItemReorderPanel
|
||||
key={item.itemId}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -828,6 +850,7 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||
Cancel
|
||||
|
||||
@@ -1,375 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Plus, Ruler, Tag } from "lucide-react"
|
||||
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brand, Uom } from "@/types/master-data"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Tab = "brand" | "uom"
|
||||
|
||||
// Large enough to show "all brands" in one grid — the chip layout replaces the
|
||||
// old table's pagination, so we just ask for everything in one page.
|
||||
const ALL_ITEMS_PAGE_SIZE = 500
|
||||
|
||||
export default function BrandsPage() {
|
||||
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
|
||||
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
|
||||
export default function BrandsRedirectPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<BrandsPageContent />
|
||||
<BrandsRedirect />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandsPageContent() {
|
||||
function BrandsRedirect() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
|
||||
function selectTab(next: Tab) {
|
||||
setTab(next)
|
||||
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("brand")}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("uom")}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
UOM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Brand | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
brandsApi
|
||||
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setBrands(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateBrandName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await brandsApi.create({ name })
|
||||
toast.success("Brand created", result.data.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not create brand", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(brand: Brand) {
|
||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(brand.brandId)
|
||||
try {
|
||||
await brandsApi.updateStatus(brand.brandId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update brand status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(brand: Brand) {
|
||||
setEditing(brand)
|
||||
setEditName(brand.name)
|
||||
setEditErrors({})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (!editing) return
|
||||
const nextErrors = validateBrandName(editName)
|
||||
setEditErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
||||
const current = await brandsApi.get(editing.brandId)
|
||||
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
|
||||
toast.success("Brand updated", result.data.name)
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setEditErrors({ name: errorMessage(err) })
|
||||
toast.error("Could not update brand", errorMessage(err))
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New brand name, e.g. Bosch"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New brand name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add Brand
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && brands === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Tag className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No brands yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{brands.map((b) => (
|
||||
<label
|
||||
key={b.brandId}
|
||||
onDoubleClick={() => openEdit(b)}
|
||||
title="Tick to activate/deactivate — double-click to rename"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={b.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(b)}
|
||||
disabled={togglingId === b.brandId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{b.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Edit brand</DialogTitle>
|
||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!editErrors.name}>
|
||||
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="brand-edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
aria-invalid={!!editErrors.name}
|
||||
/>
|
||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
|
||||
{editSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UomPanel() {
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
uomsApi
|
||||
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||
.then((res) => setUoms(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleActive(uom: Uom) {
|
||||
const next = uom.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(uom.uomId)
|
||||
try {
|
||||
await uomsApi.updateStatus(uom.uomId, next)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update UOM status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const nextErrors = validateUomName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const uom = await uomsApi.create({ name })
|
||||
toast.success("UOM created", uom.name)
|
||||
setName("")
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.name) setErrors({ name: fe.name })
|
||||
toast.error("Could not create UOM", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New UOM name, e.g. Box-12"
|
||||
className="h-12 flex-1 text-base"
|
||||
aria-label="New UOM name"
|
||||
aria-invalid={!!errors.name}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||
<Plus className="size-5" />
|
||||
Add UOM
|
||||
</Button>
|
||||
</div>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && uoms === null && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Ruler className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && uoms !== null && uoms.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{uoms.map((u) => (
|
||||
<label
|
||||
key={u.uomId}
|
||||
title="Tick to activate/deactivate"
|
||||
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={u.status === "Active"}
|
||||
onCheckedChange={() => handleToggleActive(u)}
|
||||
disabled={togglingId === u.uomId}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/products/categories?tab=${tab}`)
|
||||
}, [router, tab])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,484 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||
import { Suspense, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Category, ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { BrandPanel } from "@/components/products/BrandPanel"
|
||||
import { CategoryPanel } from "@/components/products/CategoryPanel"
|
||||
import { UomPanel } from "@/components/products/UomPanel"
|
||||
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type Tab = "category" | "brand" | "uom"
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "brand", label: "Brand" },
|
||||
{ value: "uom", label: "UOM" },
|
||||
]
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const BASE_HREF = "/dashboard/products/categories"
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
productConfig().then(setConfig).catch(() => setConfig(null))
|
||||
}, [])
|
||||
|
||||
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
||||
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
||||
// is needed) once the feature is on and the page's categories are known.
|
||||
useEffect(() => {
|
||||
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
||||
setSubCategoryCounts({})
|
||||
return
|
||||
function parseTab(value: string | null): Tab {
|
||||
return value === "brand" || value === "uom" ? value : "category"
|
||||
}
|
||||
let cancelled = false
|
||||
Promise.all(
|
||||
categories.map((c) =>
|
||||
categoriesApi
|
||||
.listSubCategories(c.categoryId, { pageSize: 1 })
|
||||
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
|
||||
.catch(() => [c.categoryId, 0] as const)
|
||||
|
||||
/**
|
||||
* Category, Brand and UOM masters on one page as tabs. The old /brands and /uoms
|
||||
* routes redirect here with `?tab=` so existing links/bookmarks keep working.
|
||||
*/
|
||||
export default function ProductMastersPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ProductMastersPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
).then((entries) => {
|
||||
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [config?.subcategoriesEnabled, categories])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [search, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
categoriesApi
|
||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setCategories(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [search, status, page])
|
||||
function ProductMastersPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [tab, setTab] = useState<Tab>(parseTab(searchParams.get("tab")))
|
||||
|
||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||
|
||||
const sortedCategories = categories
|
||||
? [...categories].sort((a, b) => {
|
||||
const cmp =
|
||||
sortKey === "categoryId"
|
||||
? a.categoryId - b.categoryId
|
||||
: sortKey === "createdAt"
|
||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
: a[sortKey].localeCompare(b[sortKey])
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
: null
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(category: Category) {
|
||||
setEditing(category)
|
||||
setName(category.name)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const nextErrors = validateCategoryName(name)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let result
|
||||
if (editing) {
|
||||
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
|
||||
const current = await categoriesApi.get(editing.categoryId)
|
||||
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
|
||||
} else {
|
||||
result = await categoriesApi.create({ name })
|
||||
}
|
||||
toast.success(editing ? "Category updated" : "Category created", result.data.name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(category: Category) {
|
||||
const next = category.status === "Active" ? "Inactive" : "Active"
|
||||
setDeletingId(category.categoryId)
|
||||
try {
|
||||
await categoriesApi.updateStatus(category.categoryId, next)
|
||||
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update category status", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
function selectTab(next: Tab) {
|
||||
setTab(next)
|
||||
router.replace(next === "category" ? BASE_HREF : `${BASE_HREF}?tab=${next}`, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">Category/Brand/UOM</h1>
|
||||
<p className="text-base text-muted-foreground">Manage item categories, brands and units of measure.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
|
||||
<DialogDescription>Give the category a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
|
||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search categories…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search categories"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && categories === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ListTree className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && categories !== null && categories.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{sortedCategories!.map((c) => (
|
||||
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{c.name}</p>
|
||||
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Subcategories</p>
|
||||
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3">
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedCategories!.map((c) => (
|
||||
<TableRow key={c.categoryId}>
|
||||
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Subcategories are their own resource now, not a nested tree.
|
||||
Hidden while the feature is switched off in Product Configuration. */}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Manage subcategories of ${c.name}`}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${c.name}`}
|
||||
onClick={() => openEditDialog(c)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||
disabled={deletingId === c.categoryId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={c.status === "Active" ? "destructive" : "success"}
|
||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||
description={
|
||||
c.status === "Active"
|
||||
? "The category stays on existing items but cannot take new subcategories or items."
|
||||
: "The category becomes selectable again."
|
||||
}
|
||||
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(c)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
active,
|
||||
order,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
order: SortOrder
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
onClick={() => selectTab(t.value)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "category" && <CategoryPanel />}
|
||||
{tab === "brand" && <BrandPanel />}
|
||||
{tab === "uom" && <UomPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function ItemTypesPage() {
|
||||
} else {
|
||||
await itemTypesApi.create({ name, isMeasurable })
|
||||
}
|
||||
toast.success(editing ? "Item type updated" : "Item type created", name)
|
||||
toast.success(editing ? "Item variant updated" : "Item variant created", name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setIsMeasurable(false)
|
||||
@@ -90,7 +90,7 @@ export default function ItemTypesPage() {
|
||||
load()
|
||||
} catch (err) {
|
||||
setErrors({ name: errorMessage(err) })
|
||||
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
|
||||
toast.error(editing ? "Could not update item variant" : "Could not create item variant", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default function ItemTypesPage() {
|
||||
setTogglingId(itemType.itemTypeId)
|
||||
try {
|
||||
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
|
||||
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
|
||||
toast.success(next === "Inactive" ? "Item variant deactivated" : "Item variant activated", itemType.name)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
@@ -116,7 +116,7 @@ export default function ItemTypesPage() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Variants</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
|
||||
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item's content size.
|
||||
@@ -125,11 +125,11 @@ export default function ItemTypesPage() {
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Variant</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
|
||||
<DialogDescription>Give the item type a name.</DialogDescription>
|
||||
<DialogTitle>{editing ? "Edit item variant" : "New item variant"}</DialogTitle>
|
||||
<DialogDescription>Give the item variant a name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
@@ -188,7 +188,7 @@ export default function ItemTypesPage() {
|
||||
{!error && itemTypes !== null && itemTypes.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<SwatchBook className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No item types yet.</p>
|
||||
<p className="text-base text-muted-foreground">No item variants yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { ImagePlus, Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { itemTypesApi } from "@/lib/api/item-types"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
contentPairErrors,
|
||||
validateVariantContent,
|
||||
validateVariantItemForm,
|
||||
validateItemTypeName,
|
||||
validateVariantPrices,
|
||||
} from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
|
||||
import {
|
||||
Brand,
|
||||
Category,
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
ProductConfig,
|
||||
StockNature,
|
||||
SubCategory,
|
||||
WARRANTY_PERIOD_MONTHS_OPTIONS,
|
||||
Warranty,
|
||||
WarrantyPeriodMonths,
|
||||
} from "@/types/master-data"
|
||||
@@ -43,10 +45,24 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import {
|
||||
ITEM_IMAGE_ACCEPT,
|
||||
ITEM_IMAGE_MAX_COUNT,
|
||||
itemImageProblem,
|
||||
} from "@/components/products/ItemImagesPanel"
|
||||
|
||||
function skuSegment(text: string, maxLen: number): string {
|
||||
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||
@@ -105,6 +121,8 @@ export default function NewItemPage() {
|
||||
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||
/** Form-level unit for size variants — the default unit of every size value added below. */
|
||||
const [measurementUnit, setMeasurementUnit] = useState<MeasureUnit | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -130,6 +148,23 @@ export default function NewItemPage() {
|
||||
// submit, without having to remove and re-add the whole value that produced it.
|
||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
// Inline "custom variant" creation — only offered when config.customVariantsEnabled.
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customName, setCustomName] = useState("")
|
||||
// Ticked ⇒ the custom variant is a size (measurement) dimension, valued in `measurementUnit`.
|
||||
const [customIsSize, setCustomIsSize] = useState(false)
|
||||
const [customError, setCustomError] = useState<string | null>(null)
|
||||
|
||||
// Item images (config.itemImagesEnabled): the items don't exist until submit, so picks are
|
||||
// held locally with object-URL previews and uploaded to every created variant afterwards.
|
||||
const [pendingImages, setPendingImages] = useState<{ file: File; url: string }[]>([])
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const pendingImagesRef = useRef(pendingImages)
|
||||
useEffect(() => {
|
||||
pendingImagesRef.current = pendingImages
|
||||
}, [pendingImages])
|
||||
useEffect(() => () => pendingImagesRef.current.forEach((p) => URL.revokeObjectURL(p.url)), [])
|
||||
|
||||
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
|
||||
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
|
||||
// each row keeps its own flat price in `pricesByKey`.
|
||||
@@ -141,6 +176,15 @@ export default function NewItemPage() {
|
||||
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
||||
// Active rows of the Warranty Periods master (months), offered when config.customWarrantyPeriodsEnabled.
|
||||
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
|
||||
// Inline "custom period" — adds to that master without leaving the form.
|
||||
const [customPeriodOpen, setCustomPeriodOpen] = useState(false)
|
||||
const [customPeriodAmount, setCustomPeriodAmount] = useState("")
|
||||
const [customPeriodUnit, setCustomPeriodUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [customPeriodError, setCustomPeriodError] = useState<string | null>(null)
|
||||
// A length entered via "Custom period" — for this item only, never saved to the master.
|
||||
const [itemOnlyWarrantyMonths, setItemOnlyWarrantyMonths] = useState<number | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
@@ -169,6 +213,11 @@ export default function NewItemPage() {
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
// Optional extra: a failure here just means only the default periods are offered.
|
||||
warrantyPeriodsApi
|
||||
.list({ status: "Active" })
|
||||
.then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months)))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Subcategories are their own resource now — fetched per category rather than filtered
|
||||
@@ -213,6 +262,22 @@ export default function NewItemPage() {
|
||||
config?.sellingPricesEnabled,
|
||||
])
|
||||
|
||||
const warrantyGroups = warrantyPeriodGroups(config, customWarrantyMonths)
|
||||
// Dropdown sections: defaults, the shared presets, then this item's own length (if any and
|
||||
// not already one of the others).
|
||||
const warrantySelectGroups = [
|
||||
{ label: "Default periods", months: warrantyGroups.defaults },
|
||||
{ label: "Custom periods", months: warrantyGroups.custom },
|
||||
{
|
||||
label: "This item only",
|
||||
months:
|
||||
config?.customWarrantyPeriodsEnabled &&
|
||||
itemOnlyWarrantyMonths !== null &&
|
||||
![...warrantyGroups.defaults, ...warrantyGroups.custom].includes(itemOnlyWarrantyMonths)
|
||||
? [itemOnlyWarrantyMonths]
|
||||
: [],
|
||||
},
|
||||
].filter((g) => g.months.length > 0)
|
||||
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||
/**
|
||||
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
|
||||
@@ -234,6 +299,98 @@ export default function NewItemPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function addPendingImages(files: FileList | null) {
|
||||
if (!files) return
|
||||
const room = ITEM_IMAGE_MAX_COUNT - pendingImages.length
|
||||
const picked = Array.from(files)
|
||||
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
|
||||
const accepted: { file: File; url: string }[] = []
|
||||
for (const file of picked.slice(0, Math.max(room, 0))) {
|
||||
const problem = itemImageProblem(file)
|
||||
if (problem) toast.error("Image skipped", problem)
|
||||
else accepted.push({ file, url: URL.createObjectURL(file) })
|
||||
}
|
||||
setPendingImages((prev) => [...prev, ...accepted])
|
||||
}
|
||||
|
||||
function removePendingImage(url: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
setPendingImages((prev) => prev.filter((p) => p.url !== url))
|
||||
}
|
||||
|
||||
function closeCustomPeriod() {
|
||||
setCustomPeriodOpen(false)
|
||||
setCustomPeriodAmount("")
|
||||
setCustomPeriodUnit("months")
|
||||
setCustomPeriodError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a warranty length for THIS item only. Nothing is saved to the Warranty Periods
|
||||
* master — the item just stores the months (the server accepts any 1–600 while custom
|
||||
* periods are on), so it isn't offered the next time the form opens.
|
||||
*/
|
||||
function addCustomWarrantyPeriod() {
|
||||
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setCustomPeriodError(parsed.error)
|
||||
return
|
||||
}
|
||||
setItemOnlyWarrantyMonths(parsed.months)
|
||||
setWarrantyPeriodMonths(parsed.months)
|
||||
closeCustomPeriod()
|
||||
}
|
||||
|
||||
function closeCustomVariant() {
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomIsSize(false)
|
||||
setCustomError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a variant dimension for THIS item only. Nothing is saved to the Item Variants master:
|
||||
* items never reference an item type (the chosen values are baked into each generated
|
||||
* item's name and SKU), so a form-local entry with a negative id is all the builder needs,
|
||||
* and it is gone the next time the form opens.
|
||||
*
|
||||
* Ticked as a size variant it becomes a measurement dimension: values are entered as numbers
|
||||
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
|
||||
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
|
||||
*/
|
||||
function createCustomVariant() {
|
||||
const name = customName.trim() || (customIsSize ? "Size" : "")
|
||||
const invalid = validateItemTypeName(name).name
|
||||
if (invalid) {
|
||||
setCustomError(invalid)
|
||||
return
|
||||
}
|
||||
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
||||
setCustomError(`"${name}" already exists — tick it above instead.`)
|
||||
return
|
||||
}
|
||||
if (customIsSize && !measurementUnit) {
|
||||
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
|
||||
return
|
||||
}
|
||||
// Only one dimension may supply the content size, or a variant would have two.
|
||||
if (customIsSize && measurableCheckedCount > 0) {
|
||||
setCustomError("Only one size variant can be used at a time — untick the other one first.")
|
||||
return
|
||||
}
|
||||
const local: ItemType = {
|
||||
itemTypeId: -Date.now(),
|
||||
name,
|
||||
isMeasurable: customIsSize,
|
||||
status: "Active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
}
|
||||
setItemTypes((prev) => [...(prev ?? []), local])
|
||||
setCheckedItemTypeIds((prev) => [...prev, local.itemTypeId])
|
||||
closeCustomVariant()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add is the real validation gate for a measurement — rejecting here means a half or
|
||||
* duplicate pair never reaches state, so nothing downstream has to defend against one.
|
||||
@@ -255,7 +412,9 @@ export default function NewItemPage() {
|
||||
}
|
||||
|
||||
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
|
||||
const unit = unitByCategory[itemTypeId] ?? null
|
||||
// The form-level Measurement unit, when chosen, is fixed for every size; otherwise the
|
||||
// per-dimension pick.
|
||||
const unit = measurementUnit ?? unitByCategory[itemTypeId] ?? null
|
||||
const qty = Number(raw)
|
||||
|
||||
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
||||
@@ -416,6 +575,7 @@ export default function NewItemPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
let created = 0
|
||||
const createdIds: number[] = []
|
||||
try {
|
||||
const effectiveTrackingMode =
|
||||
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
@@ -430,7 +590,7 @@ export default function NewItemPage() {
|
||||
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
|
||||
for (const variant of variants) {
|
||||
const content = contentForVariant(variant)
|
||||
await itemsApi.create({
|
||||
const { data: createdItem } = await itemsApi.create({
|
||||
sku: variant.sku,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
|
||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||
@@ -463,8 +623,24 @@ export default function NewItemPage() {
|
||||
contentUnit: content?.unit ?? null,
|
||||
})
|
||||
created += 1
|
||||
createdIds.push(createdItem.itemId)
|
||||
}
|
||||
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
||||
|
||||
// Images are best-effort: the items already exist, so a failed upload is reported
|
||||
// (and can be retried from the item page) rather than undoing the creation.
|
||||
if (config?.itemImagesEnabled && pendingImages.length > 0) {
|
||||
let failed = 0
|
||||
for (const itemId of createdIds) {
|
||||
for (const { file } of pendingImages) {
|
||||
await itemsApi.uploadImage(itemId, file).catch(() => {
|
||||
failed += 1
|
||||
})
|
||||
}
|
||||
}
|
||||
if (failed > 0)
|
||||
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
|
||||
}
|
||||
router.push("/dashboard/products")
|
||||
} catch (err) {
|
||||
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
||||
@@ -489,7 +665,7 @@ export default function NewItemPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,7 +678,7 @@ export default function NewItemPage() {
|
||||
{!loading && baseUomId === null && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
||||
No unit of measure exists yet. Items need a base UOM —{" "}
|
||||
<Link href="/dashboard/products/uoms" className="font-semibold underline">
|
||||
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
|
||||
create one first
|
||||
</Link>
|
||||
.
|
||||
@@ -631,6 +807,32 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* The unit size variants are measured in. Picked once here; every size value added
|
||||
below (custom size variant or a measurement Item Variant) starts in this unit. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Measurement unit</Label>
|
||||
<Select<MeasureUnit | "none">
|
||||
value={measurementUnit ?? "none"}
|
||||
onValueChange={(v) => setMeasurementUnit(v && v !== "none" ? v : null)}
|
||||
items={[
|
||||
{ label: "None", value: "none" as const },
|
||||
...CONTENT_UNITS.map((u) => ({ label: UNIT_LABEL[u], value: u })),
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-base">None</SelectItem>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{UNIT_LABEL[u]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Used for size variants (e.g. 500 ml, 1 L).</p>
|
||||
</div>
|
||||
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
|
||||
or a label has none. Litres/kilograms are normalised to ml/g by the server.
|
||||
Hidden once a measurement dimension is in play, because that dimension then
|
||||
@@ -822,19 +1024,116 @@ export default function NewItemPage() {
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={warrantyPeriodMonths}
|
||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
|
||||
label: formatWarrantyPeriod(m),
|
||||
value: m,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||
{warrantySelectGroups.length === 1
|
||||
? warrantySelectGroups[0].months.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{months} months
|
||||
{formatWarrantyPeriod(months)}
|
||||
</SelectItem>
|
||||
))
|
||||
: warrantySelectGroups.map((group, i) => (
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator />}
|
||||
<SelectGroup>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.months.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{formatWarrantyPeriod(months)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||
|
||||
{config?.customWarrantyPeriodsEnabled &&
|
||||
(customPeriodOpen ? (
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Custom warranty period</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For this item only — it isn't added to Warranty Periods or offered on other items.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={customPeriodAmount}
|
||||
onChange={(e) => {
|
||||
setCustomPeriodAmount(e.target.value)
|
||||
setCustomPeriodError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addCustomWarrantyPeriod()
|
||||
} else if (e.key === "Escape") {
|
||||
closeCustomPeriod()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-label="Warranty length"
|
||||
aria-invalid={!!customPeriodError}
|
||||
className="h-10 w-24 text-base"
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={customPeriodUnit}
|
||||
onValueChange={(v) => v && setCustomPeriodUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months" className="text-base">Months</SelectItem>
|
||||
<SelectItem value="years" className="text-base">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" size="sm" onClick={addCustomWarrantyPeriod}>
|
||||
Use
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={closeCustomPeriod}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{customPeriodUnit === "years" &&
|
||||
Number.isInteger(Number(customPeriodAmount)) &&
|
||||
Number(customPeriodAmount) > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
= {Number(customPeriodAmount) * 12} months
|
||||
</span>
|
||||
)}
|
||||
{customPeriodError && <p className="text-sm text-destructive">{customPeriodError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto w-fit px-0"
|
||||
onClick={() => setCustomPeriodOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Custom period
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -842,14 +1141,68 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.itemImagesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Not required — you can skip this and add images later from the item page. JPG, PNG or WebP, up to 5 MB each and {ITEM_IMAGE_MAX_COUNT} per item. They are added to every
|
||||
variant created below; the first image is the main one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5 lg:grid-cols-8">
|
||||
{pendingImages.map((p, index) => (
|
||||
<div key={p.url} className="relative aspect-square overflow-hidden rounded-lg border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local object-URL preview */}
|
||||
<img src={p.url} alt={p.file.name} className="size-full object-cover" />
|
||||
{index === 0 && (
|
||||
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingImage(p.url)}
|
||||
aria-label={`Remove ${p.file.name}`}
|
||||
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingImages.length < ITEM_IMAGE_MAX_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<ImagePlus className="size-6" />
|
||||
<span className="text-xs font-medium">Add images</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept={ITEM_IMAGE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addPendingImages(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||
item-type reference), so this section IS the enforcement. */}
|
||||
{config?.itemTypesEnabled && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
Check the item variants that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
|
||||
measurement out of the box, so the builder silently falls back to one shared
|
||||
@@ -858,7 +1211,7 @@ export default function NewItemPage() {
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Selling the same product in several sizes (500 ml, 1 L)?{" "}
|
||||
<Link href="/dashboard/products/item-types" className="font-medium underline">
|
||||
Mark that item type as a measurement
|
||||
Mark that item variant as a measurement
|
||||
</Link>{" "}
|
||||
and each size will capture its own number and unit, giving every variant its own content size.
|
||||
</p>
|
||||
@@ -886,13 +1239,83 @@ export default function NewItemPage() {
|
||||
/>
|
||||
<span className="text-base font-medium">{t.name}</span>
|
||||
{t.isMeasurable && (
|
||||
<Badge variant="outline" className="text-xs">measurement</Badge>
|
||||
<Badge variant="outline" className="text-xs">size</Badge>
|
||||
)}
|
||||
{t.itemTypeId < 0 && (
|
||||
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-xs text-primary">
|
||||
this item only
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
|
||||
{config?.customVariantsEnabled && !customOpen && (
|
||||
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Custom variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config?.customVariantsEnabled && customOpen && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-4">
|
||||
<div>
|
||||
<Label htmlFor="custom-variant-name" className="text-base">Custom variant</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Used for this item only — it isn't added to Item Variants or offered on other items. Tick
|
||||
Size variant to enter sizes in the Measurement unit above (500 ml, 1 L), each with its own
|
||||
content amount.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
id="custom-variant-name"
|
||||
autoFocus
|
||||
value={customName}
|
||||
onChange={(e) => {
|
||||
setCustomName(e.target.value)
|
||||
setCustomError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
createCustomVariant()
|
||||
} else if (e.key === "Escape") {
|
||||
closeCustomVariant()
|
||||
}
|
||||
}}
|
||||
placeholder={customIsSize ? "Size" : "e.g. Colour"}
|
||||
aria-invalid={!!customError}
|
||||
className="h-11 text-base sm:max-w-64"
|
||||
/>
|
||||
<label
|
||||
className={cn("flex items-center gap-2 text-sm", !measurementUnit && "text-muted-foreground")}
|
||||
title={measurementUnit ? undefined : "Pick a Measurement unit at the top first"}
|
||||
>
|
||||
<Checkbox
|
||||
checked={customIsSize}
|
||||
disabled={!measurementUnit}
|
||||
onCheckedChange={(c) => {
|
||||
setCustomIsSize(c === true)
|
||||
setCustomError(null)
|
||||
}}
|
||||
/>
|
||||
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
|
||||
</label>
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={createCustomVariant}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{customError && <p className="text-sm text-destructive">{customError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.variants ? { message: errors.variants } : undefined,
|
||||
@@ -930,6 +1353,15 @@ export default function NewItemPage() {
|
||||
aria-invalid={!!valueErrors[t.itemTypeId]}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{measurementUnit ? (
|
||||
// Fixed by the form-level Measurement unit — every size is in that unit.
|
||||
<div
|
||||
className="flex h-11 w-24 shrink-0 items-center justify-center rounded-md border bg-muted text-base text-muted-foreground"
|
||||
title="Set by Measurement unit above"
|
||||
>
|
||||
{UNIT_LABEL[measurementUnit]}
|
||||
</div>
|
||||
) : (
|
||||
<Select<MeasureUnit | null>
|
||||
value={unitByCategory[t.itemTypeId] ?? null}
|
||||
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
|
||||
@@ -946,6 +1378,7 @@ export default function NewItemPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
|
||||
@@ -8,17 +8,18 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
ListFilter,
|
||||
ListTree,
|
||||
Package,
|
||||
PackageX,
|
||||
Pencil,
|
||||
Plus,
|
||||
Ruler,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { ItemStockFilter, itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
@@ -26,17 +27,19 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
|
||||
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ColumnsMenu } from "@/components/ui/columns-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
@@ -45,12 +48,23 @@ type SortOrder = "asc" | "desc"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
const COLUMN_OPTIONS = [
|
||||
{ key: "categoryBrand", label: "Category / Brand" },
|
||||
{ key: "stockValue", label: "Stock Value" },
|
||||
{ key: "lastPurchase", label: "Last Purchase" },
|
||||
{ key: "sales30d", label: "Sales (30d)" },
|
||||
{ key: "status", label: "Status" },
|
||||
const STOCK_OPTIONS: { label: string; value: ItemStockFilter | "All" }[] = [
|
||||
{ label: "Any stock level", value: "All" },
|
||||
{ label: "In stock", value: "InStock" },
|
||||
{ label: "Out of stock", value: "OutOfStock" },
|
||||
{ label: "Low stock", value: "LowStock" },
|
||||
]
|
||||
const TRACKING_OPTIONS: { label: string; value: TrackingMode | "All" }[] = [
|
||||
{ label: "Any tracking", value: "All" },
|
||||
{ label: "No tracking", value: "None" },
|
||||
{ label: "Batch", value: "Batch" },
|
||||
{ label: "Serial", value: "Serial" },
|
||||
]
|
||||
const NATURE_OPTIONS: { label: string; value: StockNature | "All" }[] = [
|
||||
{ label: "Any stock nature", value: "All" },
|
||||
{ label: "Stocked", value: "Stocked" },
|
||||
{ label: "Non-stocked", value: "NonStocked" },
|
||||
{ label: "Service", value: "Service" },
|
||||
]
|
||||
|
||||
function formatMoney(n: number) {
|
||||
@@ -70,6 +84,40 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect<T extends string | number>({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { label: string; value: T }[]
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select<T> value={value} onValueChange={(v) => v !== null && onChange(v)} items={options} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={String(o.value)} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemsPage() {
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -79,11 +127,19 @@ export default function ItemsPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
|
||||
const dashboardStats = useDashboardStats()
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | "All">("All")
|
||||
const [brandId, setBrandId] = useState<number | "All">("All")
|
||||
const [stock, setStock] = useState<ItemStockFilter | "All">("All")
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode | "All">("All")
|
||||
const [stockNature, setStockNature] = useState<StockNature | "All">("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -91,7 +147,6 @@ export default function ItemsPage() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [sortKey, setSortKey] = useState<SortKey>("sku")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -100,7 +155,7 @@ export default function ItemsPage() {
|
||||
|
||||
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
|
||||
// pages) — restarts at page 1. Reset during render rather than in an effect.
|
||||
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
|
||||
const resetKey = `${query}|${status}|${categoryId}|${subCategoryId}|${brandId}|${stock}|${trackingMode}|${stockNature}|${isMobile}`
|
||||
const [prevResetKey, setPrevResetKey] = useState(resetKey)
|
||||
if (prevResetKey !== resetKey) {
|
||||
setPrevResetKey(resetKey)
|
||||
@@ -118,6 +173,11 @@ export default function ItemsPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
categoryId: categoryId === "All" ? undefined : categoryId,
|
||||
subCategoryId: subCategoryId === "All" ? undefined : subCategoryId,
|
||||
brandId: brandId === "All" ? undefined : brandId,
|
||||
stock: stock === "All" ? undefined : stock,
|
||||
trackingMode: trackingMode === "All" ? undefined : trackingMode,
|
||||
stockNature: stockNature === "All" ? undefined : stockNature,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
@@ -135,12 +195,51 @@ export default function ItemsPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [page, pageSize, query, status, categoryId, isMobile])
|
||||
}, [page, pageSize, query, status, categoryId, subCategoryId, brandId, stock, trackingMode, stockNature, isMobile])
|
||||
|
||||
// Subcategories only make sense under a chosen category.
|
||||
const subcategoriesOn = !!config?.subcategoriesEnabled && categoryId !== "All"
|
||||
useEffect(() => {
|
||||
if (!subcategoriesOn) return
|
||||
let cancelled = false
|
||||
categoriesApi
|
||||
.listSubCategories(categoryId, { pageSize: 200 })
|
||||
.then((res) => {
|
||||
if (!cancelled) setSubCategories(res.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [subcategoriesOn, categoryId])
|
||||
|
||||
function showMore() {
|
||||
setLoadingMore(true)
|
||||
setPage((p) => p + 1)
|
||||
}
|
||||
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
|
||||
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
itemsApi.overview({ page: 1, pageSize: 1 }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
|
||||
])
|
||||
.then(([total, low, out]) => {
|
||||
if (cancelled) return
|
||||
setItemStats({
|
||||
total: total.pagination.totalItems,
|
||||
lowStock: low.pagination.totalItems,
|
||||
outOfStock: out.pagination.totalItems,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
@@ -156,11 +255,21 @@ export default function ItemsPage() {
|
||||
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
|
||||
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
|
||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
|
||||
|
||||
const showCol = (key: string) => visibleColumns[key] ?? true
|
||||
function toggleColumn(key: string, next: boolean) {
|
||||
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
|
||||
function changeCategory(next: number | "All") {
|
||||
setCategoryId(next)
|
||||
setSubCategoryId("All")
|
||||
setSubCategories([])
|
||||
}
|
||||
|
||||
function clearAdvanced() {
|
||||
setSubCategoryId("All")
|
||||
setBrandId("All")
|
||||
setStock("All")
|
||||
setTrackingMode("All")
|
||||
setStockNature("All")
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
@@ -236,14 +345,14 @@ export default function ItemsPage() {
|
||||
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Ruler className="size-5" />
|
||||
UOMs
|
||||
</Link>
|
||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<ListTree className="size-5" />
|
||||
Categories
|
||||
Category/Brand/UOM
|
||||
</Link>
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
||||
<Download className="size-5" />
|
||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Item
|
||||
@@ -251,50 +360,103 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search SKU or name…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search items"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{itemStats ? (
|
||||
<>
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} />
|
||||
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
|
||||
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
{/* Passed as a string so StatCard shows the full 2dp amount, not its compact "1.2M" form. */}
|
||||
{dashboardStats ? (
|
||||
<StatCard label="Stock value" value={formatMoney(dashboardStats.stockValuationTotal)} icon={CircleDollarSign} />
|
||||
) : (
|
||||
<Skeleton className="h-22.5 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same filter bar as the Sales list, but search on the left and every filter
|
||||
(category, status, "Advanced" popover) grouped on the right. */}
|
||||
<FilterBar
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Search SKU or name…"
|
||||
searchAriaLabel="Search items"
|
||||
advancedActive={advancedCount > 0}
|
||||
onClearAdvanced={clearAdvanced}
|
||||
advanced={
|
||||
<>
|
||||
<FilterSelect label="Stock level" value={stock} onChange={setStock} options={STOCK_OPTIONS} />
|
||||
{config?.brandsEnabled && (
|
||||
<FilterSelect
|
||||
label="Brand"
|
||||
value={brandId}
|
||||
onChange={setBrandId}
|
||||
options={[{ label: "All brands", value: "All" as const }, ...brands.map((b) => ({ label: b.name, value: b.brandId }))]}
|
||||
/>
|
||||
)}
|
||||
{config?.subcategoriesEnabled && (
|
||||
<FilterSelect
|
||||
label="Subcategory"
|
||||
value={subCategoryId}
|
||||
onChange={setSubCategoryId}
|
||||
disabled={categoryId === "All"}
|
||||
placeholder={categoryId === "All" ? "Pick a category first" : undefined}
|
||||
options={[
|
||||
{ label: "All subcategories", value: "All" as const },
|
||||
...subCategories.map((s) => ({ label: s.name, value: s.subCategoryId })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<number | "All">
|
||||
value={categoryId}
|
||||
onValueChange={(v) => setCategoryId(v ?? "All")}
|
||||
onValueChange={(v) => changeCategory(v ?? "All")}
|
||||
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Category">
|
||||
<ListTree className="size-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All categories</SelectItem>
|
||||
<SelectItem value="All">All categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
<SelectItem key={c.categoryId} value={c.categoryId}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
|
||||
<Select<StatusFilter>
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v ?? "All")}
|
||||
items={[
|
||||
{ label: "All statuses", value: "All" as const },
|
||||
{ label: "Active", value: "Active" as const },
|
||||
{ label: "Inactive", value: "Inactive" as const },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
||||
<ListFilter className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
|
||||
<Download className="size-4" />
|
||||
Export{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
@@ -355,7 +517,6 @@ export default function ItemsPage() {
|
||||
<p className="text-sm text-muted-foreground">{item.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showCol("status") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
@@ -365,22 +526,18 @@ export default function ItemsPage() {
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCol("categoryBrand") && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{categoryName(item.categoryId)}
|
||||
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
{showCol("stockValue") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Stock Value</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
|
||||
<span className="font-medium text-foreground">{item.stockQty.toLocaleString()} on hand</span>
|
||||
{item.isLowStock && (
|
||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="size-3" />
|
||||
@@ -388,16 +545,12 @@ export default function ItemsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||
</div>
|
||||
)}
|
||||
{showCol("sales30d") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Sales (30d)</p>
|
||||
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{showCol("lastPurchase") && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last Purchase</p>
|
||||
{item.lastPurchaseDate ? (
|
||||
@@ -409,7 +562,6 @@ export default function ItemsPage() {
|
||||
<p className="text-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
@@ -440,11 +592,11 @@ export default function ItemsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
|
||||
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
|
||||
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
|
||||
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
|
||||
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
|
||||
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -474,16 +626,13 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||
{showCol("categoryBrand") && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
<p className="text-foreground">{categoryName(item.categoryId)}</p>
|
||||
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("stockValue") && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{formatMoney(item.stockValue)}</span>
|
||||
<span className="font-medium">{item.stockQty.toLocaleString()} on hand</span>
|
||||
{item.isLowStock && (
|
||||
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="size-3" />
|
||||
@@ -491,10 +640,8 @@ export default function ItemsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("lastPurchase") && (
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{item.lastPurchaseDate ? (
|
||||
<>
|
||||
@@ -505,9 +652,7 @@ export default function ItemsPage() {
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
|
||||
{showCol("status") && (
|
||||
<TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -519,7 +664,6 @@ export default function ItemsPage() {
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ReactNode, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Package } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProductConfig } from "@/types/master-data"
|
||||
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
@@ -37,18 +38,10 @@ export default function ProductSettingsPage() {
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function toggle(flag: keyof Pick<
|
||||
ProductConfig,
|
||||
| "subcategoriesEnabled"
|
||||
| "brandsEnabled"
|
||||
| "itemTypesEnabled"
|
||||
| "sellingPricesEnabled"
|
||||
| "serialNumbersEnabled"
|
||||
| "batchListsEnabled"
|
||||
| "warrantyPeriodsEnabled"
|
||||
>, next: boolean) {
|
||||
if (!config) return
|
||||
setSaving(flag)
|
||||
/** PUT is a full replacement, so every save sends the whole current config plus `patch`. */
|
||||
async function save(key: keyof ProductConfig, patch: Partial<UpdateProductConfigRequest>, message: string): Promise<boolean> {
|
||||
if (!config) return false
|
||||
setSaving(key)
|
||||
try {
|
||||
const res = await productConfigApi.update(
|
||||
{
|
||||
@@ -59,21 +52,30 @@ export default function ProductSettingsPage() {
|
||||
serialNumbersEnabled: config.serialNumbersEnabled,
|
||||
batchListsEnabled: config.batchListsEnabled,
|
||||
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
|
||||
[flag]: next,
|
||||
customVariantsEnabled: config.customVariantsEnabled,
|
||||
itemImagesEnabled: config.itemImagesEnabled,
|
||||
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
||||
toast.success("Configuration saved", message)
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
load()
|
||||
return false
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(flag: FlagKey, next: boolean) {
|
||||
return save(flag, { [flag]: next }, `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -102,10 +104,42 @@ export default function ProductSettingsPage() {
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||
<ToggleRow
|
||||
label="Product variants"
|
||||
description="Controls the variant builder on item create. Off hides the item-type section."
|
||||
description="Controls the variant builder on item create. Off hides the item-variants section."
|
||||
checked={config.itemTypesEnabled}
|
||||
busy={saving === "itemTypesEnabled"}
|
||||
onChange={(v) => toggle("itemTypesEnabled", v)}
|
||||
onChange={(v) =>
|
||||
v
|
||||
? toggle("itemTypesEnabled", true)
|
||||
: // Custom variants need this on, so switching it off turns that off too.
|
||||
save(
|
||||
"itemTypesEnabled",
|
||||
{ itemTypesEnabled: false, customVariantsEnabled: false },
|
||||
config.customVariantsEnabled
|
||||
? "Product variants and custom variants disabled."
|
||||
: "Product variants disabled.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Allow custom variants"
|
||||
description={
|
||||
config.itemTypesEnabled
|
||||
? "Lets users create a new item variant (e.g. Flavour) right from the item create screen, without visiting Item Variants first."
|
||||
: "Needs Product variants switched on — the custom variant option lives in the item-variants section."
|
||||
}
|
||||
checked={config.customVariantsEnabled}
|
||||
busy={saving === "customVariantsEnabled"}
|
||||
disabled={!config.itemTypesEnabled}
|
||||
onChange={(v) => toggle("customVariantsEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Item images"
|
||||
description="Adds an images area to item create and the item page (JPG, PNG or WebP, up to 10 per item). Off hides it; images already added stay visible."
|
||||
checked={config.itemImagesEnabled}
|
||||
busy={saving === "itemImagesEnabled"}
|
||||
onChange={(v) => toggle("itemImagesEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
@@ -137,7 +171,39 @@ export default function ProductSettingsPage() {
|
||||
description="Allows warranty-tracked items to pick a coverage period."
|
||||
checked={config.warrantyPeriodsEnabled}
|
||||
busy={saving === "warrantyPeriodsEnabled"}
|
||||
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
|
||||
onChange={(v) =>
|
||||
v
|
||||
? toggle("warrantyPeriodsEnabled", true)
|
||||
: // Custom warranty periods need this on, so switching it off turns that off too.
|
||||
save(
|
||||
"warrantyPeriodsEnabled",
|
||||
{ warrantyPeriodsEnabled: false, customWarrantyPeriodsEnabled: false },
|
||||
config.customWarrantyPeriodsEnabled
|
||||
? "Warranty periods and custom warranty periods disabled."
|
||||
: "Warranty periods disabled.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label="Custom warranty periods"
|
||||
description={
|
||||
config.warrantyPeriodsEnabled ? (
|
||||
<>
|
||||
Offers the periods from{" "}
|
||||
<Link href="/dashboard/products/warranty-periods" className="font-medium underline">
|
||||
Products → Warranty Periods
|
||||
</Link>{" "}
|
||||
alongside the default 3, 6, 12 and 18 months.
|
||||
</>
|
||||
) : (
|
||||
"Needs Warranty periods switched on."
|
||||
)
|
||||
}
|
||||
checked={config.customWarrantyPeriodsEnabled}
|
||||
busy={saving === "customWarrantyPeriodsEnabled"}
|
||||
disabled={!config.warrantyPeriodsEnabled}
|
||||
onChange={(v) => toggle("customWarrantyPeriodsEnabled", v)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
@@ -177,21 +243,40 @@ const LABELS: Record<keyof ProductConfig, string> = {
|
||||
serialNumbersEnabled: "Serial numbers",
|
||||
batchListsEnabled: "Batch lists",
|
||||
warrantyPeriodsEnabled: "Warranty periods",
|
||||
customVariantsEnabled: "Custom variants",
|
||||
itemImagesEnabled: "Item images",
|
||||
customWarrantyPeriodsEnabled: "Custom warranty periods",
|
||||
updatedAt: "Updated at",
|
||||
updatedBy: "Updated by",
|
||||
}
|
||||
|
||||
type FlagKey = keyof Pick<
|
||||
ProductConfig,
|
||||
| "subcategoriesEnabled"
|
||||
| "brandsEnabled"
|
||||
| "itemTypesEnabled"
|
||||
| "sellingPricesEnabled"
|
||||
| "serialNumbersEnabled"
|
||||
| "batchListsEnabled"
|
||||
| "warrantyPeriodsEnabled"
|
||||
| "customVariantsEnabled"
|
||||
| "itemImagesEnabled"
|
||||
| "customWarrantyPeriodsEnabled"
|
||||
>
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
busy,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
description: ReactNode
|
||||
checked: boolean
|
||||
busy: boolean
|
||||
disabled?: boolean
|
||||
onChange: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -199,7 +284,7 @@ function ToggleRow({
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={busy}
|
||||
disabled={busy || disabled}
|
||||
aria-label={label}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
|
||||
// to keep old links/bookmarks working.
|
||||
// UOM now lives as a tab on the merged Category/Brand/UOM page — this route stays
|
||||
// only to keep old links/bookmarks working.
|
||||
export default function UomsRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/dashboard/products/brands?tab=uom")
|
||||
router.replace("/dashboard/products/categories?tab=uom")
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Ban, CheckCircle2, Plus, ShieldCheck } from "lucide-react"
|
||||
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit } from "@/lib/warranty"
|
||||
import { WARRANTY_PERIOD_MONTHS_OPTIONS, WarrantyPeriod } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/**
|
||||
* Warranty Periods — custom coverage lengths offered to items on top of the fixed defaults
|
||||
* (3/6/12/18 months), while Product Configuration → "Custom warranty periods" is on.
|
||||
*
|
||||
* Entered in months or years, always stored as months. Items store the months themselves,
|
||||
* so a length can't be edited (add a new one instead) and deactivating one only stops it
|
||||
* being offered — items already using it keep it.
|
||||
*/
|
||||
export default function WarrantyPeriodsPage() {
|
||||
const [periods, setPeriods] = useState<WarrantyPeriod[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [customEnabled, setCustomEnabled] = useState<boolean | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
warrantyPeriodsApi
|
||||
.list()
|
||||
.then(setPeriods)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
// Initial fetch sets state only in callbacks (load() also clears the error, for reloads).
|
||||
useEffect(() => {
|
||||
warrantyPeriodsApi
|
||||
.list()
|
||||
.then(setPeriods)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
productConfig()
|
||||
.then((cfg) => setCustomEnabled(cfg.warrantyPeriodsEnabled && cfg.customWarrantyPeriodsEnabled))
|
||||
.catch(() => setCustomEnabled(null))
|
||||
}, [])
|
||||
|
||||
function openCreateDialog() {
|
||||
setAmount("")
|
||||
setUnit("months")
|
||||
setFormError(null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// Only an ACTIVE duplicate is an error — re-adding an inactive length reactivates it.
|
||||
const activeMonths = (periods ?? []).filter((p) => p.status === "Active").map((p) => p.months)
|
||||
const parsed = parseWarrantyInput(amount, unit, activeMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setFormError(parsed.error)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await warrantyPeriodsApi.create(parsed.months)
|
||||
toast.success("Warranty period added", formatWarrantyPeriod(parsed.months))
|
||||
setOpen(false)
|
||||
load()
|
||||
} catch (err) {
|
||||
setFormError(errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
|
||||
async function handleToggleStatus(period: WarrantyPeriod) {
|
||||
const next = period.status === "Active" ? "Inactive" : "Active"
|
||||
setTogglingId(period.warrantyPeriodId)
|
||||
try {
|
||||
await warrantyPeriodsApi.updateStatus(period.warrantyPeriodId, next)
|
||||
toast.success(
|
||||
next === "Inactive" ? "Warranty period deactivated" : "Warranty period activated",
|
||||
formatWarrantyPeriod(period.months),
|
||||
)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not update status", errorMessage(err))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const years = Number(amount)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Warranty Periods</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Your own warranty lengths, offered on items alongside the defaults. Enter them in months or years — they are
|
||||
saved as months.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg" onClick={openCreateDialog}>
|
||||
<Plus className="size-5" />
|
||||
Add Warranty Period
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New warranty period</DialogTitle>
|
||||
<DialogDescription>How long the warranty lasts.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!formError}>
|
||||
<FieldLabel htmlFor="warranty-period-amount">Length</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="warranty-period-amount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
setFormError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-invalid={!!formError}
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={unit}
|
||||
onValueChange={(v) => v && setUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="w-32 shrink-0" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months">Months</SelectItem>
|
||||
<SelectItem value="years">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{unit === "years" && Number.isInteger(years) && years > 0 && (
|
||||
<span className="text-sm text-muted-foreground">= {years * 12} months</span>
|
||||
)}
|
||||
<FieldError errors={[formError ? { message: formError } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{customEnabled === false && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
|
||||
Custom warranty periods are switched off, so items only offer the defaults. Turn on{" "}
|
||||
<span className="font-medium">Custom warranty periods</span> in{" "}
|
||||
<Link href="/dashboard/products/settings" className="font-medium underline">
|
||||
Product Configuration
|
||||
</Link>{" "}
|
||||
to use these.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Default periods (always offered):</span>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((m) => (
|
||||
<Badge key={m} variant="outline" className="text-muted-foreground">
|
||||
{formatWarrantyPeriod(m)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && periods === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && periods !== null && periods.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No custom warranty periods yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && periods !== null && periods.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{periods.map((p) => (
|
||||
<TableRow key={p.warrantyPeriodId}>
|
||||
<TableCell className="px-3 py-3.5">{p.warrantyPeriodId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{formatWarrantyPeriod(p.months)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={p.status === "Active" ? "default" : "secondary"}>{p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(p.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={p.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||
aria-label={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}`}
|
||||
disabled={togglingId === p.warrantyPeriodId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{p.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant={p.status === "Active" ? "destructive" : "success"}
|
||||
title={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}?`}
|
||||
description={
|
||||
p.status === "Active"
|
||||
? "It stops being offered on items. Items already using it keep their warranty period."
|
||||
: "It is offered on items again."
|
||||
}
|
||||
confirmLabel={p.status === "Active" ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => handleToggleStatus(p)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
|
||||
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
|
||||
// replaced by a locked field, and the server forces it too.
|
||||
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
|
||||
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
|
||||
// treated (and sent) as 0% — the server rejects anything else.
|
||||
const allowDiscounts = grnConfig?.allowDiscounts ?? true
|
||||
const allowVat = grnConfig?.allowVat ?? true
|
||||
function effectiveLine(l: DraftLine): DraftLine {
|
||||
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
|
||||
}
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
// Missing/failed config just means "pick a warehouse" — never block the form on it.
|
||||
grnConfigApi.get().then((r) => r.data).catch(() => null),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
.then(([wh, it, uo, ve, po, config]) => {
|
||||
setGrnConfig(config)
|
||||
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
setHeaderDiscountPct("")
|
||||
setHeaderVatPct("")
|
||||
setHeaderError(null)
|
||||
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
if (!nextPoId) {
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
|
||||
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map((l): DraftLine => {
|
||||
const draft: DraftLine = {
|
||||
@@ -357,16 +375,31 @@ export default function NewGrnPage() {
|
||||
try {
|
||||
const result = await itemsApi.scan(code)
|
||||
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
||||
addItemLine(result.itemId, scannedBatchNo)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setScanError(`No item or lot found for "${code}"`)
|
||||
} else {
|
||||
setScanError(errorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the scan box and the "Add item" search: bumps qty on an existing line for the same
|
||||
// item + batch, else fills the first blank row or appends a new line for it.
|
||||
function addItemLine(itemId: number, batchNo = "") {
|
||||
setLines((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
|
||||
(l) => l.itemId === itemId && l.batchNo.trim() === batchNo.trim()
|
||||
)
|
||||
if (existingIdx >= 0) {
|
||||
const next = [...prev]
|
||||
const merged: DraftLine = {
|
||||
...next[existingIdx],
|
||||
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
||||
batchNo: scannedBatchNo || next[existingIdx].batchNo,
|
||||
batchNo: batchNo || next[existingIdx].batchNo,
|
||||
}
|
||||
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||
return next
|
||||
@@ -377,9 +410,9 @@ export default function NewGrnPage() {
|
||||
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
||||
const draft: DraftLine = {
|
||||
...emptyLine(),
|
||||
itemId: result.itemId,
|
||||
itemId,
|
||||
qty: "1",
|
||||
batchNo: scannedBatchNo,
|
||||
batchNo,
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
}
|
||||
@@ -391,15 +424,6 @@ export default function NewGrnPage() {
|
||||
}
|
||||
return [...prev, filled]
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setScanError(`No item or lot found for "${code}"`)
|
||||
} else {
|
||||
setScanError(errorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
const itemOptions = useMemo<SuggestOption[]>(
|
||||
() =>
|
||||
(items ?? []).map((i) => {
|
||||
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return {
|
||||
value: String(i.itemId),
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
detail: detail || undefined,
|
||||
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
|
||||
badgeTone: "muted",
|
||||
}
|
||||
}),
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||
vatPct: allowVat ? line.vatPct : "0",
|
||||
warranty: item?.warranty ?? null,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||
holdStatus: l.holdStatus,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
@@ -690,6 +732,7 @@ export default function NewGrnPage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
{allowWarehouseSelection ? (
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
@@ -702,6 +745,15 @@ export default function NewGrnPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{(() => {
|
||||
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
|
||||
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -709,7 +761,9 @@ export default function NewGrnPage() {
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||
)}
|
||||
|
||||
{(allowDiscounts || allowVat) && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
{allowDiscounts && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
@@ -723,6 +777,8 @@ export default function NewGrnPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allowVat && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||
<Input
|
||||
@@ -736,10 +792,12 @@ export default function NewGrnPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
Setting a rate here applies it to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
|
||||
<ScanInput
|
||||
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
||||
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...emptyLine(),
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||
<Button
|
||||
type="button"
|
||||
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "lines" && (
|
||||
<>
|
||||
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
|
||||
the last row of the table, under the Item column (same layout as Sales). */}
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="w-full md:hidden"
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
{lines.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
|
||||
No items yet — start typing in "Add item" above, or scan a barcode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked editable cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -949,6 +997,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allowDiscounts && (
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
@@ -965,6 +1014,9 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
@@ -981,6 +1033,8 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -1131,6 +1173,7 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{allowDiscounts && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -1146,6 +1189,9 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -1161,9 +1207,11 @@ export default function NewGrnPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { salesDayEndApi } from "@/lib/api/sales-day-end"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -23,6 +32,51 @@ function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
type SalesDateRange = "All" | "Today" | "7d" | "30d"
|
||||
|
||||
const salesDateRanges: { value: SalesDateRange; label: string; days: number | null }[] = [
|
||||
{ value: "All", label: "All time", days: null },
|
||||
{ value: "Today", label: "Today", days: 1 },
|
||||
{ value: "7d", label: "7 days", days: 7 },
|
||||
{ value: "30d", label: "30 days", days: 30 },
|
||||
]
|
||||
|
||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||
function toLocalIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive range ending today ("7 days" is today plus the 6 days before it) — same as the Sales page. */
|
||||
function salesDateBounds(range: SalesDateRange): { from?: string; to?: string } {
|
||||
const days = salesDateRanges.find((r) => r.value === range)?.days
|
||||
if (!days) return {}
|
||||
const today = new Date()
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toLocalIsoDate(from), to: toLocalIsoDate(today) }
|
||||
}
|
||||
|
||||
/** Past its due date (`invoiceDate`) with money still owed — compared by calendar day, so an
|
||||
* invoice due today is not overdue. Same rule as the Sales page. */
|
||||
function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
if (row.status !== "Posted" || row.totals.balanceAmount <= 0) return false
|
||||
const due = new Date(row.invoiceDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
due.setHours(0, 0, 0, 0)
|
||||
return due < today
|
||||
}
|
||||
|
||||
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
type PaymentFilter = "All" | "Unpaid" | "Paid"
|
||||
|
||||
const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
||||
{ value: "All", label: "All payments" },
|
||||
{ value: "Unpaid", label: "Balance due" },
|
||||
{ value: "Paid", label: "Fully paid" },
|
||||
]
|
||||
|
||||
export default function SalesDayEndPage() {
|
||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
@@ -35,6 +89,49 @@ export default function SalesDayEndPage() {
|
||||
const [report, setReport] = useState<SalesDayEnd | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
|
||||
// business date above): a cashier (null follows the cashier picked above, "All" shows every
|
||||
// cashier's sales) and a date range.
|
||||
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
|
||||
const effectiveSalesCashier = salesCashier ?? cashierUserId
|
||||
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
|
||||
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
||||
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
||||
const [salesSearchInput, setSalesSearchInput] = useState("")
|
||||
const [salesQuery, setSalesQuery] = useState("")
|
||||
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
|
||||
const [salesPage, setSalesPage] = useState(1)
|
||||
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
|
||||
const [salesReload, setSalesReload] = useState(0)
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const salesKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize, salesReload].join("|")
|
||||
const [salesResult, setSalesResult] = useState<{
|
||||
key: string
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
pagination: PaginationMeta | null
|
||||
error: string | null
|
||||
} | null>(null)
|
||||
const current = salesResult?.key === salesKey ? salesResult : null
|
||||
const sales = current?.rows ?? null
|
||||
const salesPagination = current?.pagination ?? null
|
||||
const salesError = current?.error ?? null
|
||||
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
// Read through the loaded rows so the pop-up's Pay button follows payments.
|
||||
const viewRow = viewInvoice ? (sales?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||
|
||||
// Invoice payments are taken here, not on the Sales screens. One invoice at a time via Pay, or
|
||||
// several of one customer ticked together — the generic cash customer's are paid one at a time.
|
||||
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.list({ pageSize: 200 })
|
||||
@@ -62,6 +159,71 @@ export default function SalesDayEndPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cashierUserId, businessDate])
|
||||
|
||||
// Debounced search; a new term starts again from page 1.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setSalesQuery(salesSearchInput.trim())
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [salesSearchInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSalesCashier === null) return
|
||||
salesApi
|
||||
.listInvoices({
|
||||
page: salesPage,
|
||||
pageSize: salesPageSize,
|
||||
status: "Posted",
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
|
||||
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
|
||||
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
|
||||
|
||||
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
|
||||
function reloadSales() {
|
||||
setSelected(new Map())
|
||||
setSalesReload((n) => n + 1)
|
||||
}
|
||||
|
||||
// Any change to what the table lists goes back to page 1 and clears the ticks.
|
||||
function resetSalesView() {
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
function changeCashier(userId: number | null) {
|
||||
setCashierUserId(userId)
|
||||
setSalesCashier(null)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesCashier(value: number | "All") {
|
||||
setSalesCashier(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesDateRange(value: SalesDateRange) {
|
||||
setSalesDateRange(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPayment(value: PaymentFilter) {
|
||||
setSalesPayment(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPageSize(size: number) {
|
||||
setSalesPageSize(size)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
if (!cashierUserId) return
|
||||
setClosing(true)
|
||||
@@ -79,6 +241,26 @@ export default function SalesDayEndPage() {
|
||||
|
||||
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
|
||||
|
||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||
|
||||
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
|
||||
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||
if (selected.has(row.salesInvoiceId)) return null
|
||||
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleSelected(row: SalesInvoiceSummary) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||
else next.set(row.salesInvoiceId, row)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -96,7 +278,7 @@ export default function SalesDayEndPage() {
|
||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Cashier</Label>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
@@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
|
||||
<InvoiceSummaryPanel invoices={report.invoices} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SalesTable
|
||||
rows={sales}
|
||||
error={salesError}
|
||||
cashiers={cashiers}
|
||||
cashierFilter={effectiveSalesCashier}
|
||||
onCashierFilterChange={changeSalesCashier}
|
||||
dateRange={salesDateRange}
|
||||
onDateRangeChange={changeSalesDateRange}
|
||||
paymentFilter={salesPayment}
|
||||
onPaymentFilterChange={changeSalesPayment}
|
||||
search={salesSearchInput}
|
||||
onSearchChange={setSalesSearchInput}
|
||||
pagination={salesPagination}
|
||||
onPageChange={setSalesPage}
|
||||
onPageSizeChange={changeSalesPageSize}
|
||||
onView={setViewInvoice}
|
||||
onPay={(row) => {
|
||||
setPayInvoice(row)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
selected={selected}
|
||||
notSelectableReason={notSelectableReason}
|
||||
onToggle={toggleSelected}
|
||||
onClearSelection={() => setSelected(new Map())}
|
||||
onPaySelected={() => setBulkPayOpen(true)}
|
||||
/>
|
||||
|
||||
{/* The invoice pop-up (View action or row click). Its Pay button follows the reloaded row,
|
||||
and the sheet itself re-reads after a payment via refreshKey. */}
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== null}
|
||||
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||
refreshKey={salesReload}
|
||||
actions={
|
||||
viewRow && viewRow.totals.balanceAmount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayInvoice(viewRow)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* After a payment the table is re-read, so Paid / Remaining come from the server. */}
|
||||
<SalesInvoicePaymentDialog invoice={payInvoice} open={payOpen} onOpenChange={setPayOpen} onPaid={reloadSales} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={() => {}}
|
||||
onPaid={reloadSales}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One page of Approved (Posted) sales invoices. Every filter (cashier or all cashiers, date range,
|
||||
// payment status, search) is applied server-side, so the pager and counts cover all matches.
|
||||
function SalesTable({
|
||||
rows,
|
||||
error,
|
||||
cashiers,
|
||||
cashierFilter,
|
||||
onCashierFilterChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
paymentFilter,
|
||||
onPaymentFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onView,
|
||||
onPay,
|
||||
selected,
|
||||
notSelectableReason,
|
||||
onToggle,
|
||||
onClearSelection,
|
||||
onPaySelected,
|
||||
}: {
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
error: string | null
|
||||
cashiers: ManagedUser[]
|
||||
cashierFilter: number | "All" | null
|
||||
onCashierFilterChange: (value: number | "All") => void
|
||||
dateRange: SalesDateRange
|
||||
onDateRangeChange: (value: SalesDateRange) => void
|
||||
paymentFilter: PaymentFilter
|
||||
onPaymentFilterChange: (value: PaymentFilter) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
pagination: PaginationMeta | null
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onView: (row: SalesInvoiceSummary) => void
|
||||
onPay: (row: SalesInvoiceSummary) => void
|
||||
selected: Map<number, SalesInvoiceSummary>
|
||||
notSelectableReason: (row: SalesInvoiceSummary) => string | null
|
||||
onToggle: (row: SalesInvoiceSummary) => void
|
||||
onClearSelection: () => void
|
||||
onPaySelected: () => void
|
||||
}) {
|
||||
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
const reason = notSelectableReason(row)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={reason !== null}
|
||||
title={reason ?? "Select to pay together"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => onToggle(row)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// View opens the invoice pop-up; Pay shows while a balance is still due.
|
||||
function renderActions(row: SalesInvoiceSummary) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onView(row)}
|
||||
aria-label={`View ${row.invoiceNo}`}
|
||||
title="View invoice"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.totals.balanceAmount > 0 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPay(row)} aria-label={`Pay ${row.invoiceNo}`}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showCashier = cashierFilter === "All"
|
||||
const cashierNameById = new Map(cashiers.map((c) => [c.userId, c.displayName]))
|
||||
const visible = rows ?? []
|
||||
const filtered = paymentFilter !== "All" || search.trim().length > 0
|
||||
|
||||
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
|
||||
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
|
||||
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
|
||||
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Select<number | "All" | null>
|
||||
value={cashierFilter}
|
||||
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
|
||||
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
|
||||
<SelectValue placeholder="Cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All cashiers</SelectItem>
|
||||
{cashiers.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>{c.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<SalesDateRange>
|
||||
value={dateRange}
|
||||
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
|
||||
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salesDateRanges.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<PaymentFilter>
|
||||
value={paymentFilter}
|
||||
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
|
||||
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentFilters.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search invoice or customer"
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && <Skeleton className="h-32 w-full" />}
|
||||
|
||||
{!error && rows !== null && visible.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{filtered ? "No sales match these filters." : "No approved sales in this period."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onPaySelected}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visible.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked rows. */}
|
||||
<div className="flex flex-col gap-1.5 md:hidden">
|
||||
{visible.map((row) => (
|
||||
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
|
||||
{renderSelectBox(row)}
|
||||
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{row.invoiceNo}</span>
|
||||
<span className="block truncate text-muted-foreground">
|
||||
{row.customerSnapshotName}
|
||||
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
|
||||
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
</span>
|
||||
</button>
|
||||
{renderActions(row)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((row) => (
|
||||
<TableRow
|
||||
key={row.salesInvoiceId}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
onView(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
{showCashier && (
|
||||
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"px-3 py-3.5 text-right tabular-nums",
|
||||
row.status !== "Cancelled" && row.totals.balanceAmount > 0 && "font-semibold text-destructive"
|
||||
)}
|
||||
>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderActions(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
pageSizeOptions={SALES_PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoices counts every match; the amounts are for the rows on this page. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
|
||||
<Stat label="Net (this page)" value={netTotal} emphasize />
|
||||
<Stat label="Paid (this page)" value={paidTotal} />
|
||||
<Stat label="Remaining (this page)" value={balanceTotal} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -141,13 +141,8 @@ export default function NewFreeIssuePage() {
|
||||
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
|
||||
setCashierUserId(userRes.items[0]?.userId ?? null)
|
||||
setLines([
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||
},
|
||||
])
|
||||
// No item pre-selected — the user searches for one.
|
||||
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
|
||||
await refreshRows()
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
@@ -167,7 +162,7 @@ export default function NewFreeIssuePage() {
|
||||
}
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
|
||||
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
|
||||
[items]
|
||||
)
|
||||
|
||||
@@ -369,12 +364,29 @@ export default function NewFreeIssuePage() {
|
||||
<FieldGroup key={line.key}>
|
||||
<Field>
|
||||
<FieldLabel>Item</FieldLabel>
|
||||
<Combobox
|
||||
items={itemOptions}
|
||||
value={line.itemId || null}
|
||||
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
|
||||
placeholder="Search item…"
|
||||
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
|
||||
{line.itemId ? (
|
||||
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
aria-label="Item"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>UOM</FieldLabel>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user