Compare commits

..

9 Commits

Author SHA1 Message Date
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
Sasanka cea433e320 product and sales fixers 2026-09-30 17:06:28 +05:30
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
ImanThiyanga 9fd290e256 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m38s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m1s
2026-09-25 09:03:39 +00:00
ImanThiyanga 20ecfdc0b2 Merge pull request 'fixing recipts in sales' (#90) from sales-changes into Dev
Reviewed-on: #90
2026-09-25 09:03:21 +00:00
ImanThiyanga dd82f5b5c4 Merge branch 'Dev' into sales-changes 2026-09-25 09:03:15 +00:00
ImanThiyanga ce55d647b5 Merge pull request 'fron build failure fix' (#89) from Dev into production
Reviewed-on: #89
2026-09-25 06:58:55 +00:00
ImanThiyanga 268b6562dc Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m50s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m24s
2026-09-25 06:58:48 +00:00
ImanThiyanga b389c01dfe fron build failure fix 2026-09-25 12:28:20 +05:30
165 changed files with 59890 additions and 5279 deletions
@@ -28,20 +28,14 @@ public sealed class AuthController : ControllerBase
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
private readonly IUserManagementService _userManagement;
private readonly ICurrentUser _currentUser;
public AuthController(
IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit,
IUserManagementService userManagement, ICurrentUser currentUser)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
_userManagement = userManagement;
_currentUser = currentUser;
}
/// <summary>
@@ -145,8 +139,6 @@ public sealed class AuthController : ControllerBase
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
// AuthHex accepted the new password — release the first-login forced change.
await _userManagement.ClearMustChangePasswordAsync(_currentUser.AuditUserId, ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -40,8 +40,11 @@ public sealed class ItemsController : ApiControllerBase
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
@@ -45,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
}}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -14,11 +14,60 @@ public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
@@ -0,0 +1,44 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}
@@ -23,12 +23,6 @@ public class BranchConfig
/// single-branch already).</summary>
public bool WarehouseBranchingEnabled { get; set; } = true;
/// <summary>Independent sub-toggle for Purchase Orders. On: each PO belongs to one branch,
/// non-admins only see their own branch's POs, and line warehouses must be in that branch.
/// Off: POs form one shared pool across branches (kept on the default branch, visible to
/// everyone, any warehouse allowed). Meaningless when <see cref="BranchingEnabled"/> is off.</summary>
public bool PurchaseOrderBranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { 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; }
}
+6 -8
View File
@@ -15,6 +15,12 @@ public class User
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const int SystemUserId = 1;
/// <summary>
/// Username prefix of a permanently deleted login whose row is kept only because history
/// (audit log, documents) still references it. Such rows are hidden from user management.
/// </summary>
public const string DeletedUsernamePrefix = "deleted-user-";
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -31,14 +37,6 @@ public class User
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Set when an admin creates the account with an issued (temporary) password
/// (<c>UserManagementService.CreateAsync</c>); surfaced via <c>GET /auth/me</c> so the
/// frontend forces a password change on first login. Cleared by
/// <c>POST /auth/change-password</c>.
/// </summary>
public bool MustChangePassword { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
@@ -14,8 +14,6 @@ public class Vendor
public string Name { get; set; } = string.Empty;
public string? Terms { get; set; }
public string? TaxReg { get; set; }
public string? Address { get; set; }
public string? Phone { get; set; }
public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -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; }
}
@@ -4,8 +4,7 @@ namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(
bool BranchingEnabled, bool WarehouseBranchingEnabled, bool PurchaseOrderBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -19,8 +18,4 @@ public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
[Required] public bool? WarehouseBranchingEnabled { get; set; }
/// <summary>Optional so existing callers that only know the first two flags keep working —
/// omitted means "leave as is".</summary>
public bool? PurchaseOrderBranchingEnabled { 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; }
}
@@ -18,13 +18,17 @@ public sealed record DashboardStatsDto(
decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders,
int FullyReceivedPurchaseOrders,
int PendingGrns,
int OpenRequisitions,
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);
@@ -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; }
}
+2 -7
View File
@@ -8,12 +8,7 @@ namespace ERPCore.Dtos.Rbac;
/// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role. <see cref="MustChangePassword"/>
/// is true for an admin-created account that hasn't replaced its issued password yet —
/// the frontend holds the user on the change-password screen until it is false.</summary>
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
bool MustChangePassword = false,
bool PurchaseOrderBranchingEnabled = true,
int? UserId = null);
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
@@ -44,6 +44,14 @@ public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
/// <summary>The unit price a new line would get if left blank — same rule as on save
/// (SALE_PRICE, else GRN_WEIGHTED_AVG for the warehouse, else FIFO_AVG).</summary>
public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal UnitPrice, string PriceSource);
/// <summary>Issuable stock for one item at one warehouse (on-hand less on-hold), for New Sale's
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
+1 -5
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg, string? Address, string? Phone,
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
@@ -14,8 +14,6 @@ public sealed class CreateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
@@ -28,8 +26,6 @@ public sealed class UpdateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
public int BranchId { get; set; }
}
@@ -107,14 +107,11 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- RecoveryManager ------------------------------------------------
// RecoveryManagerService returns its own { success, message, data } object, which the
// function handler then wraps again as the envelope's `data` — so the payload sits one
// level deeper than for other AuthHex calls (ERP_Auth_Service RecoveryManager.cs).
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
=> Unwrap(await CallAsync<AuthHexEnvelope<ForgotPasswordResponse>>("recovery", "forgotPassword", request, null, ct));
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
@@ -152,14 +149,6 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- Transport --------------------------------------------------------
/// <summary>Unwraps the inner result object that the recovery functions nest inside the envelope's data.</summary>
private static T Unwrap<T>(AuthHexEnvelope<T>? inner)
{
if (inner is null || !inner.Success || inner.Data is null)
throw new DomainException(ErrorCodes.AuthUpstreamError, inner?.Message ?? "Authentication request failed.", 400);
return inner.Data;
}
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
@@ -21,8 +21,6 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// Defaults on so the existing row keeps today's per-branch purchase orders.
builder.Property(c => c.PurchaseOrderBranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -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);
}
}
@@ -18,8 +18,6 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Address).HasMaxLength(500);
builder.Property(v => v.Phone).HasMaxLength(30);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status)
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Custom warranty period master. No relationships — items store months, not a reference.</summary>
public sealed class WarrantyPeriodConfiguration : IEntityTypeConfiguration<WarrantyPeriod>
{
public void Configure(EntityTypeBuilder<WarrantyPeriod> builder)
{
builder.ToTable("warranty_periods", t =>
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600"));
builder.HasKey(p => p.WarrantyPeriodId);
builder.Property(p => p.Months).IsRequired();
builder.HasIndex(p => p.Months).IsUnique();
builder.Property(p => p.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasIndex(p => p.Status);
}
}
@@ -46,6 +46,7 @@ public static class DataSeeder
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
@@ -106,7 +107,10 @@ public static class DataSeeder
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false,
ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false
});
return true;
}
@@ -128,6 +132,18 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Ensures the singleton sales-config row exists. Starts with cash customer sales off, so
/// every sale needs a real customer until an admin opts in from Settings → Configuration → Sales Configuration.
/// </summary>
private static async Task<bool> SeedSalesConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesConfig.AnyAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)) return false;
db.SalesConfig.Add(new SalesConfig { ConfigId = SalesConfig.SingletonId, AllowCashCustomer = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
@@ -35,6 +35,8 @@ public class ErpDbContext : DbContext
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<ItemImage> ItemImages => Set<ItemImage>();
public DbSet<WarrantyPeriod> WarrantyPeriods => Set<WarrantyPeriod>();
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
@@ -43,6 +45,7 @@ public class ErpDbContext : DbContext
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
@@ -195,12 +198,7 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Purchase orders are only scoped while the "divide purchase orders by branch" toggle is
// on — off turns them into one shared pool every branch can see.
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive
|| e.BranchId == _currentBranch.BranchId
|| !Set<Domain.Entities.BranchConfig>().Any(c => c.ConfigId == Domain.Entities.BranchConfig.SingletonId
&& c.BranchingEnabled && c.PurchaseOrderBranchingEnabled));
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
@@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddVendorAddressPhone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "vendors",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Phone",
table: "vendors",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Address",
table: "vendors");
migrationBuilder.DropColumn(
name: "Phone",
table: "vendors");
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260929062020_AddPurchaseOrderBranchingToggle")]
partial class AddPurchaseOrderBranchingToggle
[Migration("20260929131905_AddSalesConfig")]
partial class AddSalesConfig
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -347,11 +347,6 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("PurchaseOrderBranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -4345,6 +4340,43 @@ 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<int?>("CashCustomerId")
.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("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")
@@ -6402,9 +6434,6 @@ namespace ERPCore.Migrations
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId")
.HasColumnType("integer");
@@ -6438,7 +6467,6 @@ namespace ERPCore.Migrations
{
UserId = 1,
DisplayName = "System",
MustChangePassword = false,
Status = "Active",
Username = "system"
});
@@ -6452,10 +6480,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId")
.HasColumnType("integer");
@@ -6479,10 +6503,6 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -7855,6 +7875,23 @@ 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.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -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");
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260929060347_AddVendorAddressPhone")]
partial class AddVendorAddressPhone
[Migration("20260930043658_AddSalesConfigAllowFreeItems")]
partial class AddSalesConfigAllowFreeItems
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -4340,6 +4340,48 @@ 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>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("CashCustomerId")
.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("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")
@@ -6397,9 +6439,6 @@ namespace ERPCore.Migrations
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId")
.HasColumnType("integer");
@@ -6433,7 +6472,6 @@ namespace ERPCore.Migrations
{
UserId = 1,
DisplayName = "System",
MustChangePassword = false,
Status = "Active",
Username = "system"
});
@@ -6447,10 +6485,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId")
.HasColumnType("integer");
@@ -6474,10 +6508,6 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -7850,6 +7880,23 @@ 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.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -5,14 +5,14 @@
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseOrderBranchingToggle : Migration
public partial class AddSalesConfigAllowFreeItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config",
name: "AllowFreeItems",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
@@ -22,8 +22,8 @@ namespace ERPCore.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config");
name: "AllowFreeItems",
table: "sales_config");
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260929045821_AddUserMustChangePassword")]
partial class AddUserMustChangePassword
[Migration("20260930044903_AddSalesConfigAllowOverStockSale")]
partial class AddSalesConfigAllowOverStockSale
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -4340,6 +4340,53 @@ 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>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowOverStockSale")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<int?>("CashCustomerId")
.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("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")
@@ -6397,9 +6444,6 @@ namespace ERPCore.Migrations
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId")
.HasColumnType("integer");
@@ -6433,7 +6477,6 @@ namespace ERPCore.Migrations
{
UserId = 1,
DisplayName = "System",
MustChangePassword = false,
Status = "Active",
Username = "system"
});
@@ -7842,6 +7885,23 @@ 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.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -5,32 +5,25 @@
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUserMustChangePassword : Migration
public partial class AddSalesConfigAllowOverStockSale : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "users",
name: "AllowOverStockSale",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "MustChangePassword",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "users");
name: "AllowOverStockSale",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigWarehouseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowWarehouseSelection",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<int>(
name: "DefaultWarehouseId",
table: "sales_config",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId");
migrationBuilder.AddForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config",
column: "DefaultWarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_config_warehouses_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropIndex(
name: "IX_sales_config_DefaultWarehouseId",
table: "sales_config");
migrationBuilder.DropColumn(
name: "AllowWarehouseSelection",
table: "sales_config");
migrationBuilder.DropColumn(
name: "DefaultWarehouseId",
table: "sales_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigCustomVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CustomVariantsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomVariantsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ItemImagesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "item_images",
columns: table => new
{
ItemImageId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<int>(type: "integer", nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
UploadedBy = table.Column<int>(type: "integer", nullable: false),
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
table.ForeignKey(
name: "FK_item_images_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_item_images_ItemId_SortOrder",
table: "item_images",
columns: new[] { "ItemId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "item_images");
migrationBuilder.DropColumn(
name: "ItemImagesEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCustomWarrantyPeriods : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.AddColumn<bool>(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodsEnabled",
table: "product_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "warranty_periods",
columns: table => new
{
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Months = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Months",
table: "warranty_periods",
column: "Months",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_periods_Status",
table: "warranty_periods",
column: "Status");
// Carry over any periods already saved on the config (hand-added — EF would have
// dropped the column first and lost them). Out-of-range values are skipped rather
// than failing the check constraint.
migrationBuilder.Sql("""
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
SELECT DISTINCT m, 'Active', now()
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
WHERE m BETWEEN 1 AND 600
ON CONFLICT ("Months") DO NOTHING;
""");
migrationBuilder.DropColumn(
name: "CustomWarrantyPeriodMonths",
table: "product_config");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "CustomWarrantyPeriodMonths",
table: "product_config",
type: "integer[]",
nullable: false,
defaultValueSql: "'{}'::integer[]");
migrationBuilder.Sql("""
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
'{}'::integer[]);
""");
migrationBuilder.DropTable(
name: "warranty_periods");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesConfigAllowDiscounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowDiscounts",
table: "sales_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowDiscounts",
table: "sales_config");
}
}
}
@@ -344,11 +344,6 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("PurchaseOrderBranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -1836,6 +1831,56 @@ namespace ERPCore.Migrations
b.ToTable("items", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.Property<int>("ItemImageId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemImageId"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("StoredFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UploadedBy")
.HasColumnType("integer");
b.HasKey("ItemImageId");
b.HasIndex("ItemId", "SortOrder");
b.ToTable("item_images", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.Property<int>("ReorderId")
@@ -3257,6 +3302,21 @@ namespace ERPCore.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("CustomVariantsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("CustomWarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemImagesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("ItemTypesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -4342,6 +4402,68 @@ namespace ERPCore.Migrations
b.ToTable("hr_salary_components", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("AllowCashCustomer")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowDiscounts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowFreeItems")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("AllowOverStockSale")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("AllowWarehouseSelection")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("CashCustomerId")
.HasColumnType("integer");
b.Property<int?>("DefaultWarehouseId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("CashCustomerId");
b.HasIndex("DefaultWarehouseId");
b.HasIndex("UpdatedBy");
b.ToTable("sales_config", null, t =>
{
t.HasCheckConstraint("ck_sales_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Property<int>("SalesDayEndId")
@@ -6399,9 +6521,6 @@ namespace ERPCore.Migrations
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId")
.HasColumnType("integer");
@@ -6435,7 +6554,6 @@ namespace ERPCore.Migrations
{
UserId = 1,
DisplayName = "System",
MustChangePassword = false,
Status = "Active",
Username = "system"
});
@@ -6449,10 +6567,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId")
.HasColumnType("integer");
@@ -6476,10 +6590,6 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -6610,6 +6720,43 @@ namespace ERPCore.Migrations
b.ToTable("warehouses", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b =>
{
b.Property<int>("WarrantyPeriodId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("WarrantyPeriodId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("WarrantyPeriodId");
b.HasIndex("Months")
.IsUnique();
b.HasIndex("Status");
b.ToTable("warranty_periods", null, t =>
{
t.HasCheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{
b.Property<int>("WorkShiftId")
@@ -7162,6 +7309,17 @@ namespace ERPCore.Migrations
b.Navigation("TaxRate");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -7852,6 +8010,30 @@ namespace ERPCore.Migrations
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
.WithMany()
.HasForeignKey("CashCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
.WithMany()
.HasForeignKey("DefaultWarehouseId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CashCustomer");
b.Navigation("DefaultWarehouse");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
+3
View File
@@ -87,6 +87,8 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IItemImageService, ItemImageService>();
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
@@ -95,6 +97,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
@@ -1,54 +1,25 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : IAuthRecoveryService
{
/// <summary>Longer than any AuthHex recovery OTP lives; only needs to outlast the flow.</summary>
private static readonly TimeSpan RecoveryIdentifierTtl = TimeSpan.FromMinutes(30);
private readonly IAuthHexClient _authHex;
private readonly IUserManagementService _userManagement;
private readonly IMemoryCache _cache;
public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
{
_authHex = authHex;
_userManagement = userManagement;
_cache = cache;
}
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
{
var response = await _authHex.ForgotPasswordAsync(request, ct);
// The reset call only carries the reference number — remember who it was for, so a
// successful reset can also release a pending first-login forced password change.
if (!string.IsNullOrEmpty(response.ReferenceNumber))
_cache.Set(CacheKey(response.ReferenceNumber), request.Identifier.Trim(), RecoveryIdentifierTtl);
return response;
}
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct);
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
{
await _authHex.ResetPasswordAsync(request, ct);
var key = CacheKey(request.ReferenceNumber);
if (_cache.TryGetValue(key, out string? identifier) && !string.IsNullOrEmpty(identifier))
{
_cache.Remove(key);
await _userManagement.ClearMustChangePasswordByIdentifierAsync(identifier, ct);
}
}
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct);
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
}
@@ -54,8 +54,6 @@ public sealed class BranchConfigService : IBranchConfigService
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
config.PurchaseOrderBranchingEnabled = poBranching;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -112,25 +110,6 @@ public sealed class BranchConfigService : IBranchConfigService
return await ResolveDefaultBranchIdAsync(ct);
}
public async Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
return (config?.BranchingEnabled ?? true) && (config?.PurchaseOrderBranchingEnabled ?? true);
}
public async Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsPurchaseOrderBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
return await ResolveDefaultBranchIdAsync(ct);
}
// Branching (or warehouse branching specifically) is off — ignore whatever was posted and
// use the deployment's one default branch, creating it on first use (same placeholder
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
@@ -146,6 +125,5 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+49 -5
View File
@@ -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;
}
@@ -84,8 +89,6 @@ public sealed class DashboardService : IDashboardService
// not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
@@ -105,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, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices);
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
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();
}
}
@@ -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
@@ -29,14 +29,6 @@ public interface IBranchConfigService
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>True when Purchase Orders are divided by branch. Never true when
/// <see cref="IsBranchingEnabledAsync"/> is false.</summary>
Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
/// <see cref="IsPurchaseOrderBranchingEnabledAsync"/> — off puts every PO on the default branch.</summary>
Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
/// first use if it doesn't exist yet. Used as a fallback branch for document types that
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
@@ -24,10 +24,16 @@ public interface IFifoCostingService
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction.
/// and ledger costing). Must run inside a UoW transaction. Any shortfall layers left by an
/// earlier over-stock sale are settled against issuable stock first.
/// </summary>
/// <param name="allowShortfall">
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false);
/// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -0,0 +1,16 @@
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>Item photos — upload gated by <c>ProductConfig.ItemImagesEnabled</c>; reads never gated.</summary>
public interface IItemImageService
{
Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default);
Task<ItemImageDto> UploadAsync(
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default);
Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default);
Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default);
}
@@ -19,6 +19,7 @@ public interface IItemService
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
@@ -9,5 +9,4 @@ public interface IProductConfigService
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<ProductConfigDto>> UpdateAsync(
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);}
@@ -0,0 +1,14 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales configuration business logic. Singleton.</summary>
public interface ISalesConfigService
{
Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default);
/// <summary>Switching <c>AllowCashCustomer</c> on creates the cash customer on first use.</summary>
Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -12,6 +12,19 @@ public interface ISalesDomainService
bool requireCashierUser,
CancellationToken ct = default);
/// <summary>Sales Configuration "Allow free items" — gates free qty on sales invoice lines.</summary>
Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow discounts" — gates line discounts on sales invoice lines.</summary>
Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration "Allow selling beyond stock" — lets sales posting drive on-hand negative.</summary>
Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default);
/// <summary>Sales Configuration default sales warehouse: when one is saved, every sales invoice
/// must use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default);
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
@@ -27,12 +27,6 @@ public interface IUserManagementService
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>Clears the first-login forced password change once the user has set their own password.</summary>
Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default);
/// <summary>Same, for a forgot-password reset — resolves the user by email or username.</summary>
Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(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.");
}
}
+46 -11
View File
@@ -114,6 +114,7 @@ public sealed class ItemService : IItemService
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, StockNature? stockNature, ItemStockFilter? stock,
CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
@@ -127,6 +128,24 @@ public sealed class ItemService : IItemService
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
if (stockNature is not null) q = q.Where(i => i.StockNature == stockNature);
// Stock-state filters run as subqueries before paging, so totals and pages stay correct.
var layers = _stockLayers.Query();
switch (stock)
{
case ItemStockFilter.InStock:
q = q.Where(i => layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) > 0);
break;
case ItemStockFilter.OutOfStock:
q = q.Where(i => (layers.Where(l => l.ItemId == i.ItemId).Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= 0);
break;
case ItemStockFilter.LowStock:
q = q.Where(i => i.ReorderSettings.Any(rs =>
(layers.Where(l => l.ItemId == rs.ItemId && l.WarehouseId == rs.WarehouseId)
.Sum(l => (decimal?)l.QtyRemaining) ?? 0) <= rs.ReorderPoint));
break;
}
var total = await q.CountAsync(ct);
var page = await q.OrderBy(i => i.Sku)
@@ -135,16 +154,16 @@ public sealed class ItemService : IItemService
.ToListAsync(ct);
var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer, every warehouse.
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining != 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
@@ -215,7 +234,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths, config, null);
var item = new Item
{
@@ -292,7 +311,8 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var warrantyPeriodMonths = ValidateWarrantyPeriod(
request.Warranty, request.WarrantyPeriodMonths, config, item.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -506,18 +526,33 @@ public sealed class ItemService : IItemService
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// A warranty-tracked item must declare a coverage period. With custom periods off it must
/// be one of <see cref="WarrantyPeriods.AllowedMonths"/>; with them on, any length of
/// 1–<see cref="WarrantyPeriodService.MaxMonths"/> months — the Warranty Periods master is
/// just the shared presets, and an item may also carry a length of its own entered on the
/// item form (never added to the master). On update the item's <paramref name="currentMonths"/>
/// is also accepted, so switching the feature off never blocks editing an item that already
/// uses a custom length. A non-warranty item carries none — any value sent for one is
/// silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
private static int? ValidateWarrantyPeriod(
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
{
if (warranty != Warranty.Warranty) return null;
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
var valid = warrantyPeriodMonths is not null
&& (warrantyPeriodMonths == currentMonths
|| (config.CustomWarrantyPeriodsEnabled
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
if (!valid)
throw new DomainException(
ErrorCodes.Validation,
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
config.CustomWarrantyPeriodsEnabled
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
422);
return warrantyPeriodMonths;
}
@@ -55,6 +55,13 @@ public sealed class ProductConfigService : IProductConfigService
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
// Custom variants live in the item-variants section - switching product variants
// off switches this off too, so it can never be left on by itself.
config.CustomVariantsEnabled = config.ItemTypesEnabled && request.CustomVariantsEnabled!.Value;
config.ItemImagesEnabled = request.ItemImagesEnabled!.Value;
// Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -73,5 +80,7 @@ public sealed class ProductConfigService : IProductConfigService
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -88,7 +88,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId;
@@ -305,16 +305,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.
/// Skipped unless both purchase orders AND warehouses are divided by branch: with PO
/// branching off the PO sits on the default branch, and with warehouse branching off every
/// warehouse is parked on the default branch too (shared by all branches) — either way a
/// branch match is meaningless and would reject every line.</summary>
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{
if (!await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)) return;
if (!await _branchConfig.IsWarehouseBranchingEnabledAsync(ct)) return;
var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
+1 -19
View File
@@ -22,7 +22,6 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
@@ -30,13 +29,12 @@ public sealed class RoleService : IRoleService
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_users = users;
_currentUser = currentUser;
_authHex = authHex;
_branchConfig = branchConfig;
@@ -204,22 +202,6 @@ public sealed class RoleService : IRoleService
}
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
var me = await BuildMeAsync(roleCode, ct) with
{
PurchaseOrderBranchingEnabled = await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)
};
if (!_currentUser.IsAuthenticated) return me;
var mustChangePassword = await _users.Query().AsNoTracking()
.Where(u => u.UserId == _currentUser.AuditUserId)
.Select(u => u.MustChangePassword)
.FirstOrDefaultAsync(ct);
// Local user id lets the frontend spot records the caller created (e.g. self-approval of a PO).
return me with { MustChangePassword = mustChangePassword, UserId = _currentUser.AuditUserId };
}
private async Task<MeResponseDto> BuildMeAsync(string? roleCode, CancellationToken ct)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
@@ -0,0 +1,120 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
/// generic "Cash Customer" record — created the first time cash sales are allowed.
/// </summary>
public sealed class SalesConfigService : ISalesConfigService
{
private const string CashCustomerCode = "CUST-CASH";
private const string CashCustomerName = "Cash Customer";
private readonly IRepository<SalesConfig> _config;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesConfigService(
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
// later throw would otherwise leave the row half-updated.
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
if (request.DefaultWarehouseId is int defaultWarehouseId
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
if (config.AllowCashCustomer && config.CashCustomerId is null)
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
config.AllowFreeItems = request.AllowFreeItems!.Value;
config.AllowDiscounts = request.AllowDiscounts!.Value;
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
// creating a new one on the deployment's default branch.
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
{
var existingId = await _customers.Query().AsNoTracking()
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var customer = new Customer
{
CustomerCode = CashCustomerCode,
CustomerType = CustomerType.WalkIn,
Name = CashCustomerName,
DisplayName = CashCustomerName,
CreditLimit = 0m,
CreditDays = 0,
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _customers.AddAsync(customer, ct);
await _uow.SaveChangesAsync(ct);
return customer.CustomerId;
}
private static SalesConfigDto Map(SalesConfig c) => new(
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
}
+45 -1
View File
@@ -15,6 +15,7 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Item> _items;
private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig;
public SalesDomainService(
IRepository<Customer> customers,
@@ -22,7 +23,8 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<User> users,
IRepository<Item> items,
IRepository<TaxRate> taxRates,
ISalesPricingService pricing)
ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig)
{
_customers = customers;
_warehouses = warehouses;
@@ -30,12 +32,20 @@ public sealed class SalesDomainService : ISalesDomainService
_items = items;
_taxRates = taxRates;
_pricing = pricing;
_salesConfig = salesConfig;
}
public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default)
{
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
throw new NotFoundException($"Customer {customerId} was not found.");
// The generic cash customer is only usable while Sales Configuration allows it.
var cashConfig = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowCashCustomer, c.CashCustomerId })
.FirstOrDefaultAsync(ct);
if (cashConfig is { AllowCashCustomer: false } && cashConfig.CashCustomerId == customerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer sales are turned off in Sales Configuration. Select a customer.", 422);
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
if (requireCashierUser)
@@ -47,6 +57,40 @@ public sealed class SalesDomainService : ISalesDomainService
}
}
// A missing config row means the option predates it — free items stay allowed.
public async Task<bool> AreFreeItemsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowFreeItems)
.FirstOrDefaultAsync(ct) ?? true;
// A missing config row means the option predates it — discounts stay allowed.
public async Task<bool> AreDiscountsAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => (bool?)c.AllowDiscounts)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<int?> GetForcedSalesWarehouseIdAsync(CancellationToken ct = default)
{
var config = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
.FirstOrDefaultAsync(ct);
if (config is null) return null;
// A saved default sales warehouse always wins — sales use only that warehouse.
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
if (config.AllowWarehouseSelection) return null;
// The FK is SetNull on delete, so the default can disappear after it was configured.
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in Sales Configuration but no default sales warehouse is set.", 422);
}
public async Task<bool> IsOverStockSaleAllowedAsync(CancellationToken ct = default) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.AllowOverStockSale)
.FirstOrDefaultAsync(ct);
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
@@ -86,6 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
@@ -122,6 +123,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
{
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct);
request.WarehouseId = await ApplyForcedWarehouseAsync(request.WarehouseId, request.Lines, ct);
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
@@ -157,9 +159,22 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return _mapping.MapInvoice(invoice);
}
// Sales Configuration "Allow warehouse selection" off: whatever the client sent, the header
// and every line use the configured default sales warehouse.
private async Task<int> ApplyForcedWarehouseAsync(int requestedWarehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
{
if (await _sales.GetForcedSalesWarehouseIdAsync(ct) is not int forced) return requestedWarehouseId;
foreach (var line in lines) line.WarehouseId = forced;
return forced;
}
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
{
var lines = new List<SalesInvoiceLine>();
if (requests.Any(r => r.FreeQty > 0m || r.IsFreeIssue) && !await _sales.AreFreeItemsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
@@ -93,7 +93,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
@@ -124,7 +125,8 @@ public sealed class SalesPostingService : ISalesPostingService
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -151,7 +153,8 @@ public sealed class SalesPostingService : ISalesPostingService
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status,
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
}
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -260,6 +263,10 @@ public sealed class SalesPostingService : ISalesPostingService
if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal))
throw new ConflictException(ensureDraftMessage(doc));
// Sales Configuration "Allow selling beyond stock": a short line posts anyway and drives
// on-hand negative instead of failing with STOCK_NEGATIVE_BLOCKED.
var allowShortfall = await _sales.IsOverStockSaleAllowedAsync(ct);
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
@@ -271,7 +278,7 @@ public sealed class SalesPostingService : ISalesPostingService
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
@@ -63,9 +63,11 @@ public sealed class FifoCostingService : IFifoCostingService
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now);
if (batchId is not null)
{
@@ -76,27 +78,43 @@ public sealed class FifoCostingService : IFifoCostingService
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold and rejected (grn line) and expired-batch stock — rejected stock
// sits quarantined until a Purchase Return consumes it directly (see
// PurchaseReturnService), never through this general-issue path. No LINQ is
// composed on top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02), plus any
// shortfall (negative) layers left by an over-stock sale. Excludes on-hold and rejected
// (grn line) and expired-batch stock — rejected stock sits quarantined until a Purchase
// Return consumes it directly (see PurchaseReturnService), never through this
// general-issue path. No LINQ is composed on top of the raw SQL so the FOR UPDATE
// reaches the database intact.
var locked = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId}
AND (sl."QtyRemaining" < 0 OR (sl."QtyRemaining" > 0
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" IN ('OnHold', 'Rejected'))
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})))
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed)
{
foreach (var layer in open)
{
if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
layer.QtyRemaining -= settle;
shortfall.QtyRemaining += settle;
}
}
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase)
if (available < qtyBase && !allowShortfall)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
@@ -121,9 +139,40 @@ public sealed class FifoCostingService : IFifoCostingService
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
// below zero), costed at the item's last known unit cost — the next issue from this
// warehouse settles it against the stock received in the meantime (see above).
if (remaining > 0)
{
var unitCost = await LastKnownUnitCostAsync(itemId, warehouseId, ct);
var shortfall = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
QtyReceived = 0m,
QtyRemaining = -remaining,
UnitCost = unitCost,
ReceiptDate = now
};
await _layers.AddAsync(shortfall, ct);
segments.Add(new ConsumedSegment(shortfall.LayerId, remaining, unitCost));
}
return segments;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{
var layers = _layers.Query().AsNoTracking().Where(l => l.ItemId == itemId && l.QtyReceived > 0);
return await layers.Where(l => l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? await layers.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct)
?? 0m;
}
public async Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
@@ -195,7 +244,8 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
@@ -38,7 +38,8 @@ public sealed class UserManagementService : IUserManagementService
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -124,9 +125,7 @@ public sealed class UserManagementService : IUserManagementService
DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(),
RoleId = role.RoleId,
Status = EntityStatus.Active,
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
MustChangePassword = true
Status = EntityStatus.Active
};
await _users.AddAsync(user, ct);
@@ -185,27 +184,6 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct);
if (user is null || !user.MustChangePassword) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default)
{
var term = identifier.Trim();
var user = await _users.Query()
.FirstOrDefaultAsync(u => u.MustChangePassword
&& ((u.Email != null && EF.Functions.ILike(u.Email, term)) || u.Username == term), ct);
if (user is null) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+1 -7
View File
@@ -62,8 +62,6 @@ public sealed class VendorService : IVendorService
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Address = Normalize(request.Address),
Phone = Normalize(request.Phone),
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
BranchId = branchId,
@@ -96,8 +94,6 @@ public sealed class VendorService : IVendorService
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Address = Normalize(request.Address);
vendor.Phone = Normalize(request.Phone);
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow;
@@ -124,8 +120,6 @@ public sealed class VendorService : IVendorService
await _uow.SaveChangesAsync(ct);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
}
@@ -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);
}
@@ -1,181 +0,0 @@
"use client"
// Forced first-login password change: admin-created accounts land here (via the login page
// and AuthProvider's guard) while GET /auth/me reports mustChangePassword. The server clears
// the flag and ends the session on success, so the user signs in again with the new password.
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Check, Eye, EyeOff } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
function PasswordInput({
id,
label,
value,
onChange,
placeholder,
autoComplete,
invalid,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder: string
autoComplete: string
invalid?: boolean
}) {
const [show, setShow] = useState(false)
return (
<div>
<label htmlFor={id} className="block text-sm font-semibold text-foreground">{label}</label>
<div className="relative mt-2">
<Input
id={id}
type={show ? "text" : "password"}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder={placeholder}
aria-invalid={invalid}
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{show ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
)
}
export default function ChangePasswordPage() {
const router = useRouter()
const [current, setCurrent] = useState("")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const meetsLength = password.length >= 8 && password.length <= 20
const hasUpper = /[A-Z]/.test(password)
const hasNumber = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
const differsFromCurrent = password.length > 0 && password !== current
const passwordsMatch = password === confirm && password.length > 0
const allValid = current.length > 0 && meetsLength && hasUpper && hasNumber && hasSpecial && differsFromCurrent && passwordsMatch
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
if (!allValid) return
setSubmitError(null)
setSubmitting(true)
try {
await authApi.changePassword(current, password)
clearStoredUser()
router.replace("/login?passwordChanged=1")
} catch (err) {
setSubmitError(errorMessage(err))
setSubmitting(false)
}
}
async function signOut() {
await authApi.logout().catch(() => {})
clearStoredUser()
router.replace("/login")
}
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">First sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Set your password</h1>
<p className="mt-3 text-base text-muted-foreground">
Your account was created with a temporary password. Choose a new one to continue.
</p>
</div>
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<PasswordInput
id="current"
label="Temporary Password"
value={current}
onChange={setCurrent}
placeholder="Enter the password you signed in with"
autoComplete="current-password"
/>
<PasswordInput
id="password"
label="New Password"
value={password}
onChange={setPassword}
placeholder="Enter your new password"
autoComplete="new-password"
/>
<div>
<PasswordInput
id="confirm"
label="Confirm Password"
value={confirm}
onChange={setConfirm}
placeholder="Confirm your new password"
autoComplete="new-password"
invalid={!passwordsMatch && confirm.length > 0}
/>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
<PasswordRequirement met={differsFromCurrent} label="Different from the temporary password" />
</div>
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Saving…" : "Change password"}
</Button>
<div className="text-center">
<button type="button" onClick={signOut} className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Sign out
</button>
</div>
</form>
</AuthShell>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
@@ -12,44 +12,23 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { FilterBar } from "@/components/ui/filter-bar"
import { Field, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Input } from "@/components/ui/input"
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"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size,
// same convention as the other list pages even though this one pages client-side.
const MOBILE_PAGE_SIZE_STEP = 10
export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => setPage(1), [debouncedSearch, accountType])
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
// There's no server pagination on this endpoint (it returns the full union in one array), so
// page/pageSize below are applied client-side over whatever this call returns.
useEffect(() => {
let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
@@ -61,7 +40,6 @@ export default function BankAccountsPage() {
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
return () => {
cancelled = true
}
@@ -71,7 +49,7 @@ export default function BankAccountsPage() {
const filtered = useMemo(() => {
if (!accounts) return null
const q = debouncedSearch.toLowerCase()
const q = search.trim().toLowerCase()
if (!q) return accounts
return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId)
@@ -84,201 +62,116 @@ export default function BankAccountsPage() {
(gl?.accountCode ?? "").toLowerCase().includes(q)
)
})
}, [accounts, debouncedSearch, glAccountsById])
const stats = useMemo(() => {
if (!accounts) return null
return {
total: accounts.length,
bank: accounts.filter((a) => a.accountType === CashBankAccountType.Bank).length,
cash: accounts.filter((a) => a.accountType === CashBankAccountType.Cash).length,
}
}, [accounts])
const totalItems = filtered?.length ?? 0
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
const pageItems = filtered?.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) ?? null
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
setShowMoreLoading(false)
}
}, [accounts, search, glAccountsById])
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 className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
{accounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{accounts.length}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Account
</Link>
</div>
<div className="flex flex-wrap gap-2">
{stats ? (
<>
<StatCard
label="Total Accounts"
value={stats.total}
icon={Wallet}
active={accountType === "Both"}
onClick={() => setAccountType("Both")}
/>
<StatCard
label="Bank Accounts"
value={stats.bank}
icon={Banknote}
active={accountType === CashBankAccountType.Bank}
onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
/>
<StatCard
label="Cash Accounts"
value={stats.cash}
icon={Wallet}
active={accountType === CashBankAccountType.Cash}
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
/>
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, bank/type, account no. or currency…"
className="h-14 w-full pl-11 text-base"
aria-label="Search cash/bank accounts"
/>
</div>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger className="h-14! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search name, bank/type, account no. or currency…"
advancedActive={accountType !== "Both"}
onClearAdvanced={() => setAccountType("Both")}
advanced={
<Field>
<FieldLabel htmlFor="bank-filter-type">Type</FieldLabel>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Both">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && pageItems === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
{!error && filtered === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && pageItems !== null && pageItems.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No accounts match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different name, bank or account number.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
{!error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Wallet className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
<p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Account
</Link>
<p className="text-base text-muted-foreground">
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
</p>
</div>
)}
{!error && pageItems !== null && pageItems.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((a) => {
{!error && filtered !== null && filtered.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<div key={`${a.accountType}-${a.accountId}`} 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-semibold text-foreground">{a.accountName}</p>
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
</div>
<TableRow key={`${a.accountType}-${a.accountId}`}>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-16 shrink-0 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bank / Cash type</p>
<p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Currency</p>
<p className="font-medium text-foreground">{a.currencyCode}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">GL account</p>
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
<Tooltip>
<TooltipTrigger
render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
@@ -287,94 +180,12 @@ export default function BankAccountsPage() {
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</div>
</div>
</TableCell>
</TableRow>
)
})}
{totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
</TableBody>
</Table>
)}
</div>
)
@@ -18,7 +18,6 @@ import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
import { useAuth } from "@/components/auth/AuthProvider"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
@@ -39,7 +38,6 @@ export default function ChequeBookDetailPage() {
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const { branchingEnabled } = useAuth()
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
@@ -101,12 +99,10 @@ export default function ChequeBookDetailPage() {
<p className="text-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium">
@@ -57,10 +57,7 @@ export default function NewChequeBookPage() {
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
// still needs *some* branch id regardless of our toggle.
// With branching off the field is hidden entirely, but GL still needs a branch id — use the
// deployment's default branch (BR-000, the one the backend files everything under then).
const defaultBranchId = (branches.find((b) => b.code === "BR-000") ?? branches[0])?.branchId ?? null
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : defaultBranchId
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
const showBranchPicker = !lockedBranchId
useEffect(() => {
@@ -140,7 +137,6 @@ export default function NewChequeBookPage() {
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
{showBranchPicker ? (
@@ -167,7 +163,6 @@ export default function NewChequeBookPage() {
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)}
<Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -15,425 +15,152 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { buttonVariants } from "@/components/ui/button"
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 = ChequeBookStatus | "All"
interface StatusCounts {
total: number
active: number
completed: number
cancelled: number
}
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function ChequeBooksPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([
chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
chequeBooksApi.list({
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
])
.then(([result, banks]) => {
if (cancelled) return
setBooks(result.items)
setTotalItems(result.totalCount)
setBankAccounts(banks)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => {
cancelled = true
}
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ChequeBookStatus) =>
chequeBooksApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ChequeBookStatus.Active),
countFor(ChequeBookStatus.Completed),
countFor(ChequeBookStatus.Cancelled),
]).then(([total, active, completed, cancelled]) => setStatusCounts({ total, active, completed, cancelled }))
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
}, [status, activeBranchId, branchingEnabled])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleBooks = useMemo(() => {
if (!books) return null
const q = debouncedSearch.toLowerCase()
if (!q) return books
return books.filter((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
const branch = branchesById.get(b.branchId)
return (
b.chequeBookNo.toLowerCase().includes(q) ||
(bank?.accountName ?? "").toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [books, debouncedSearch, bankAccountsById, branchesById])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
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 className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
<p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div>
</div>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Cheque Book
</Link>
</div>
<div className="flex flex-wrap gap-2">
{statusCounts ? (
<>
<StatCard
label="Total Cheque Books"
value={statusCounts.total}
icon={BookText}
active={status === "All"}
onClick={() => setStatus("All")}
/>
<StatCard
label="Active"
value={statusCounts.active}
icon={CheckCircle2}
active={status === ChequeBookStatus.Active}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
/>
<StatCard
label="Completed"
value={statusCounts.completed}
icon={BookCheck}
active={status === ChequeBookStatus.Completed}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
/>
<StatCard
label="Cancelled"
value={statusCounts.cancelled}
icon={BookX}
active={status === ChequeBookStatus.Cancelled}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search cheque book no., bank or branch…"
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && visibleBooks === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
{!error && books === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && visibleBooks !== null && visibleBooks.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cheque books match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque book no., bank or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
{!error && books !== null && books.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
{!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
</div>
{!hasFilters && (
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Cheque Book
</Link>
)}
<p className="text-base text-muted-foreground">No cheque books yet.</p>
</div>
)}
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleBooks.map((b) => {
{!error && books !== null && books.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{books.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<div
<TableRow
key={b.chequeBookNo}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
className="cursor-pointer"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bank account</p>
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Range</p>
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
</div>
<div>
<p className="text-muted-foreground">Leaves</p>
<p className="font-medium text-foreground">{b.totalLeaves}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</div>
</TableCell>
</TableRow>
)
})}
{totalItems > visibleBooks.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleBooks.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className="font-semibold text-foreground hover:underline"
>
{b.chequeBookNo}
</Link>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
)}
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
</TableBody>
</Table>
)}
</div>
)
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -14,27 +14,14 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { buttonVariants } from "@/components/ui/button"
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"
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All"
interface StatusCounts {
total: number
received: number
deposited: number
cleared: number
returned: number
cancelled: number
}
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
@@ -43,27 +30,12 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -71,385 +43,124 @@ export default function ReceivedChequesPage() {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => {
let cancelled = false
receivedChequesApi
.list({
page,
pageSize,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
if (cancelled) return
setCheques(res.items)
setTotalItems(res.totalCount)
if (!cancelled) setCheques(res.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => {
cancelled = true
}
}, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ReceivedChequeStatus) =>
receivedChequesApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ReceivedChequeStatus.Received),
countFor(ReceivedChequeStatus.Deposited),
countFor(ReceivedChequeStatus.Cleared),
countFor(ReceivedChequeStatus.Returned),
countFor(ReceivedChequeStatus.Cancelled),
]).then(([total, received, deposited, cleared, returned, cancelled]) =>
setStatusCounts({ total, received, deposited, cleared, returned, cancelled })
)
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
}, [status, activeBranchId, branchingEnabled])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleCheques = useMemo(() => {
if (!cheques) return null
const q = debouncedSearch.toLowerCase()
if (!q) return cheques
return cheques.filter((c) => {
const branch = c.branchId ? branchesById.get(c.branchId) : null
return (
c.chequeNo.toLowerCase().includes(q) ||
c.receivedFromName.toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [cheques, debouncedSearch, branchesById])
function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated)
}
function openDialog(c: ReceivedCheque) {
setSelected(c)
setDialogOpen(true)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
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 className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div>
</div>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Received Cheque
</Link>
</div>
<div className="flex flex-wrap gap-2">
{statusCounts ? (
<>
<StatCard
label="Total Received"
value={statusCounts.total}
icon={Inbox}
active={status === "All"}
onClick={() => setStatus("All")}
/>
<StatCard
label="Received"
value={statusCounts.received}
icon={Wallet}
active={status === ReceivedChequeStatus.Received}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
/>
<StatCard
label="Deposited"
value={statusCounts.deposited}
icon={Banknote}
active={status === ReceivedChequeStatus.Deposited}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
/>
<StatCard
label="Cleared"
value={statusCounts.cleared}
icon={CheckCircle2}
active={status === ReceivedChequeStatus.Cleared}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
/>
<StatCard
label="Returned"
value={statusCounts.returned}
icon={Undo2}
active={status === ReceivedChequeStatus.Returned}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
/>
</>
) : (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && visibleCheques === null && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
{!error && cheques === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && visibleCheques !== null && visibleCheques.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No cheques match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque no., name or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
{!error && cheques !== null && cheques.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Inbox className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
{!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
</div>
{!hasFilters && (
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
New Received Cheque
</Link>
)}
<p className="text-base text-muted-foreground">No received cheques yet.</p>
</div>
)}
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleCheques.map((c) => (
<div
{!error && cheques !== null && cheques.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cheques.map((c) => (
<TableRow
key={c.receivedChequeId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
className="cursor-pointer"
onClick={() => {
setSelected(c)
setDialogOpen(true)
}}
onKeyDown={(e) => {
if (e.key === "Enter") openDialog(c)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Received from</p>
<p className="font-medium text-foreground">{c.receivedFromName}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.receivedFromType}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</div>
</TableCell>
</TableRow>
))}
{totalItems > visibleCheques.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleCheques.map((c) => (
<TableRow
key={c.receivedChequeId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
}}
>
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
</TableBody>
</Table>
)}
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
+21 -71
View File
@@ -5,7 +5,6 @@ import Link from "next/link"
import {
CheckCircle2,
Clock,
Eye,
HelpCircle,
Inbox,
Plus,
@@ -20,7 +19,7 @@ import { SupportTicketSummary } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -64,7 +63,6 @@ export default function HelpPage() {
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState("")
@@ -89,12 +87,6 @@ export default function HelpPage() {
return () => clearTimeout(t)
}, [search])
useEffect(() => setPage(1), [statusFilter])
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
setStatusFilter((s) => (s === status ? null : status))
}
const stats = useMemo(() => {
if (!tickets) return null
return {
@@ -108,12 +100,11 @@ export default function HelpPage() {
const filtered = useMemo(() => {
if (!tickets) return []
const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter(
(t) =>
(!statusFilter || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
)
}, [tickets, debouncedSearch, statusFilter])
}, [tickets, debouncedSearch])
const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -216,40 +207,16 @@ export default function HelpPage() {
)}
</div>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard
label="Total tickets"
value={stats.total}
icon={HelpCircle}
active={statusFilter === null}
onClick={() => setStatusFilter(null)}
/>
<StatCard
label="Open"
value={stats.open}
icon={Inbox}
active={statusFilter === "Open"}
onClick={() => toggleStatusFilter("Open")}
/>
<StatCard
label="In Progress"
value={stats.inProgress}
icon={Clock}
active={statusFilter === "InProgress"}
onClick={() => toggleStatusFilter("InProgress")}
/>
<StatCard
label="Resolved"
value={stats.resolved}
icon={CheckCircle2}
active={statusFilter === "Resolved"}
onClick={() => toggleStatusFilter("Resolved")}
/>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
<StatCard label="Open" value={stats.open} icon={Inbox} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
)}
</div>
@@ -297,17 +264,15 @@ export default function HelpPage() {
</div>
)}
{!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">
{debouncedSearch ? <>No tickets match &quot;{debouncedSearch}&quot;</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
</p>
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
<p className="text-base font-medium text-foreground">No tickets match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
</div>
<Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
Clear filters
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
@@ -329,7 +294,11 @@ export default function HelpPage() {
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => (
<div key={t.ticketId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<Link
key={t.ticketId}
href={`/dashboard/help/tickets/${t.ticketId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
@@ -354,13 +323,7 @@ export default function HelpPage() {
</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3">
<Link href={`/dashboard/help/tickets/${t.ticketId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Eye className="size-4" />
View
</Link>
</div>
</div>
</Link>
))}
{totalItems > pageItems.length && (
@@ -381,7 +344,6 @@ export default function HelpPage() {
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -409,18 +371,6 @@ export default function HelpPage() {
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ticket ${t.subject}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -167,31 +167,13 @@ export default function EmployeesPage() {
</Link>
</div>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard
label="Total employees"
value={stats.total}
icon={UsersIcon}
active={statusFilter === undefined}
onClick={() => setStatusFilter(undefined)}
/>
<StatCard
label="Active"
value={stats.active}
icon={UserCheck}
active={statusFilter === "Active"}
onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))}
/>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard
label="Terminated"
value={stats.terminated}
icon={UserMinus}
active={statusFilter === "Terminated"}
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
/>
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -537,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) }}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, Clock, PenLine, Plus, Search, X, XCircle } from "lucide-react"
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -10,6 +10,7 @@ import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -40,8 +41,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
}
export default function LeaveRequestsPage() {
const stats = useDashboardStats()
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
@@ -90,22 +91,6 @@ export default function LeaveRequestsPage() {
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
// Counts for the stat cards — one cheap pageSize:1 call per status. Each call falls back
// to 0 on its own so one bad request doesn't blank out the other tiles.
function loadCounts() {
Promise.all(
STATUSES.map((s) =>
leaveRequestsApi
.list({ page: 1, pageSize: 1, status: s })
.then((res) => res.pagination.totalItems)
.catch(() => 0)
)
).then((values) => {
setStatusCounts(Object.fromEntries(STATUSES.map((s, i) => [s, values[i]])) as Record<LeaveRequestStatus, number>)
})
}
useEffect(loadCounts, [])
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
@@ -128,7 +113,7 @@ export default function LeaveRequestsPage() {
toast.success("Leave request submitted")
setOpen(false)
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
load(); loadCounts()
load()
} catch (err) {
toast.error("Could not submit leave request", errorMessage(err))
} finally {
@@ -141,7 +126,7 @@ export default function LeaveRequestsPage() {
try {
await leaveRequestsApi.approve(id)
toast.success("Leave approved")
load(); loadCounts()
load()
} catch (err) {
toast.error("Could not approve", errorMessage(err))
} finally {
@@ -164,7 +149,7 @@ export default function LeaveRequestsPage() {
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
toast.success("Leave rejected")
setRejectTarget(null)
load(); loadCounts()
load()
} catch (err) {
toast.error("Could not reject", errorMessage(err))
} finally {
@@ -253,49 +238,7 @@ export default function LeaveRequestsPage() {
</DialogContent>
</Dialog>
<KpiRow
tiles={
statusCounts
? [
{
label: "Draft",
value: statusCounts.Draft,
icon: PenLine,
active: statusFilter === "Draft",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Draft" ? ALL_STATUS : "Draft")) },
},
{
label: "Pending Leave",
value: statusCounts.Submitted,
icon: Clock,
active: statusFilter === "Submitted",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
},
{
label: "Approved",
value: statusCounts.Approved,
icon: CheckCircle2,
active: statusFilter === "Approved",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Approved" ? ALL_STATUS : "Approved")) },
},
{
label: "Rejected",
value: statusCounts.Rejected,
icon: XCircle,
active: statusFilter === "Rejected",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Rejected" ? ALL_STATUS : "Rejected")) },
},
{
label: "Cancelled",
value: statusCounts.Cancelled,
icon: X,
active: statusFilter === "Cancelled",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Cancelled" ? ALL_STATUS : "Cancelled")) },
},
]
: null
}
/>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs">
@@ -155,7 +155,7 @@ export default function PayrollRunDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
@@ -1,9 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Eye, Plus } from "lucide-react"
import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -11,7 +10,7 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -211,7 +210,6 @@ export default function PayrollRunsPage() {
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -231,18 +229,6 @@ export default function PayrollRunsPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label={`View payroll run ${r.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -20,7 +20,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { useAuth } from "@/components/auth/AuthProvider"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__"
@@ -32,7 +31,6 @@ export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const { branchingEnabled } = useAuth()
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
@@ -229,20 +227,18 @@ export default function DepartmentsPage() {
</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<Field>
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
@@ -286,20 +282,18 @@ export default function DepartmentsPage() {
</SelectContent>
</Select>
</Field>
{branchingEnabled && (
<Field>
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<Field>
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</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-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
@@ -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>
)
}
+8 -8
View File
@@ -129,30 +129,30 @@ export default function DashboardPage() {
</div>
)}
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
</Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
</Link>
</>
) : (
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20 w-52 rounded-2xl" />)
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)}
</div>
@@ -29,7 +29,6 @@ import {
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -56,7 +55,7 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const poId = Number(params.id)
const { isAdmin, userId } = useAuth()
const { isAdmin } = useAuth()
const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null)
@@ -122,9 +121,6 @@ export default function PurchaseOrderDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poId])
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
@@ -311,19 +307,6 @@ export default function PurchaseOrderDetailPage() {
// a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
// Mirrors PurchaseOrderService.LoadForApprovalDecisionAsync: only an Admin who didn't
// create the PO may approve/reject it. Explain why instead of silently hiding the buttons.
const isOwnPo = userId !== null && po.createdBy === userId
const approvalBlockedReason =
po.status !== "PendingApproval"
? null
: !isAdmin
? "This purchase order is awaiting approval. Only an Admin can approve or reject it."
: isOwnPo
? "You created this purchase order, so you can't approve or reject it yourself. Another Admin must review it."
: null
const canDecide = po.status === "PendingApproval" && approvalBlockedReason === null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -363,7 +346,7 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog>
</>
)}
{canDecide && (
{po.status === "PendingApproval" && isAdmin && (
<>
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" />
@@ -391,13 +374,6 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
{approvalBlockedReason && (
<div role="status" className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<p>{approvalBlockedReason}</p>
</div>
)}
{showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
@@ -509,16 +485,18 @@ export default function PurchaseOrderDetailPage() {
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
openDownward
aria-invalid={!!errors.itemId}
/>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
<Button
@@ -615,7 +593,7 @@ export default function PurchaseOrderDetailPage() {
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -645,17 +623,18 @@ export default function PurchaseOrderDetailPage() {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
popupClassName="min-w-96"
openDownward
aria-invalid={!!errors.itemId}
/>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
@@ -36,7 +36,6 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
@@ -70,9 +69,7 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null
// The PO's branch follows the purchase-order sub-toggle — off means no branch is asked for
// and the server files the PO on the default branch.
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, purchaseOrderBranchingEnabled: branchingEnabled, warehouseBranchingEnabled } = useAuth()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
@@ -95,8 +92,6 @@ function NewPurchaseOrderContent() {
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vAddress, setVAddress] = useState("")
const [vPhone, setVPhone] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
@@ -136,8 +131,6 @@ function NewPurchaseOrderContent() {
setVName("")
setVTerms("")
setVTaxReg("")
setVAddress("")
setVPhone("")
setVCurrency("LKR")
setVErrors({})
}
@@ -156,8 +149,6 @@ function NewPurchaseOrderContent() {
name: vName,
terms: vTerms || null,
taxReg: vTaxReg || null,
address: vAddress || null,
phone: vPhone || null,
currency: vCurrency,
})
await loadVendors()
@@ -241,14 +232,6 @@ function NewPurchaseOrderContent() {
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
// Line warehouses must sit in the PO's branch only when warehouses are divided by branch
// too (mirrors PurchaseOrderService.EnsureLinesInBranchAsync); otherwise every warehouse
// is shared and all of them are offered.
const lineWarehouses =
branchingEnabled && warehouseBranchingEnabled && effectiveBranchId
? (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)
: (warehouses ?? [])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -257,9 +240,6 @@ function NewPurchaseOrderContent() {
setLines((prev) => prev.filter((l) => l.key !== key))
}
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = (items ?? []).map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
@@ -397,14 +377,6 @@ function NewPurchaseOrderContent() {
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field>
<FieldLabel htmlFor="po-v-address">Address (optional)</FieldLabel>
<Input id="po-v-address" value={vAddress} onChange={(e) => setVAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-phone">Phone (optional)</FieldLabel>
<Input id="po-v-phone" type="tel" value={vPhone} onChange={(e) => setVPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
</Field>
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
@@ -496,16 +468,22 @@ function NewPurchaseOrderContent() {
</div>
) : (
<>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
openDownward
aria-invalid={!!errors.itemId}
/>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -536,7 +514,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{lineWarehouses.map((w) => (
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
@@ -587,7 +565,7 @@ function NewPurchaseOrderContent() {
<Table className="table-fixed text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -606,17 +584,22 @@ function NewPurchaseOrderContent() {
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
popupClassName="min-w-96"
openDownward
aria-invalid={!!errors.itemId}
/>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
@@ -632,7 +615,7 @@ function NewPurchaseOrderContent() {
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{lineWarehouses.map((w) => (
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -38,9 +38,7 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats()
// Every branch picker/column/filter on this screen follows the purchase-order sub-toggle,
// not the global one — off means POs are one shared pool across branches.
const { isAdmin, activeBranchId, purchaseOrderBranchingEnabled: branchingEnabled } = useAuth()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([])
@@ -92,12 +90,8 @@ export default function PurchaseOrdersListPage() {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function vendorOf(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)
}
function vendorCode(vendorId: number) {
return vendorOf(vendorId)?.code ?? `#${vendorId}`
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
function branchName(branchId: number) {
@@ -138,14 +132,7 @@ export default function PurchaseOrdersListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
{pagination && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pagination.totalItems}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
@@ -163,28 +150,7 @@ export default function PurchaseOrdersListPage() {
</div>
</div>
<KpiRow
tiles={
stats
? [
{
label: "Pending Approval",
value: stats.pendingApprovalPurchaseOrders,
icon: Clock,
active: status === "PendingApproval",
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")),
},
{
label: "Fully Received",
value: stats.fullyReceivedPurchaseOrders,
icon: PackageCheck,
active: status === "FullyReceived",
onClick: () => setStatus((s) => (s === "FullyReceived" ? "All" : "FullyReceived")),
},
]
: null
}
/>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
@@ -271,9 +237,6 @@ export default function PurchaseOrdersListPage() {
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="truncate text-sm text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</div>
<PoStatusBadge status={po.status} />
</div>
@@ -287,17 +250,6 @@ export default function PurchaseOrdersListPage() {
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Details</p>
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
<p className="font-medium text-foreground">—</p>
)}
</div>
{isAdmin && branchingEnabled && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
@@ -387,7 +339,6 @@ export default function PurchaseOrdersListPage() {
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
@@ -412,22 +363,7 @@ export default function PurchaseOrdersListPage() {
{po.docNo}
</Link>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="text-sm font-medium text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-sm text-muted-foreground">
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p>{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="whitespace-nowrap">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
"—"
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell>
<PoStatusBadge status={po.status} />
</TableCell>
@@ -437,7 +373,7 @@ export default function PurchaseOrdersListPage() {
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell>
)}
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
@@ -78,21 +78,7 @@ export default function RequisitionsListPage() {
</Link>
</div>
<KpiRow
tiles={
stats
? [
{
label: "Open Requisitions",
value: stats.openRequisitions,
icon: Send,
active: status === "Submitted",
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
},
]
: null
}
/>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -35,6 +36,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -115,6 +117,8 @@ export default function ItemDetailPage() {
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [imagesEnabled, setImagesEnabled] = useState(false)
const [imagesVersion, setImagesVersion] = useState(0)
function applyItem(data: Item) {
setItem(data)
@@ -162,6 +166,7 @@ export default function ItemDetailPage() {
setTaxRates(tr.items)
})
.catch(() => {})
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
@@ -254,6 +259,7 @@ export default function ItemDetailPage() {
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
setImagesVersion((v) => v + 1)
}
async function handleSave() {
@@ -282,6 +288,7 @@ export default function ItemDetailPage() {
applyItem(result.data)
setEtag(result.etag)
setEditOpen(false)
setImagesVersion((v) => v + 1)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -381,7 +388,7 @@ export default function ItemDetailPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
@@ -408,6 +415,10 @@ export default function ItemDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
{/* View-only here; images are added/removed from the Edit dialog. Re-keyed when that
dialog closes so this copy picks up what changed there. */}
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -828,6 +839,7 @@ export default function ItemDetailPage() {
</Select>
</div>
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
@@ -1,375 +1,26 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Uom } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
type Tab = "brand" | "uom"
// Large enough to show "all brands" in one grid — the chip layout replaces the
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
export default function BrandsPage() {
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
export default function BrandsRedirectPage() {
return (
<Suspense>
<BrandsPageContent />
<BrandsRedirect />
</Suspense>
)
}
function BrandsPageContent() {
function BrandsRedirect() {
const router = useRouter()
const searchParams = useSearchParams()
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
const [tab, setTab] = useState<Tab>(initialTab)
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
}
useEffect(() => {
router.replace(`/dashboard/products/categories?tab=${tab}`)
}, [router, tab])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
</div>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
<button
type="button"
onClick={() => selectTab("brand")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Brand
</button>
<button
type="button"
onClick={() => selectTab("uom")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
UOM
</button>
</div>
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
</div>
)
}
function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
function load() {
setError(null)
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setTogglingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
function openEdit(brand: Brand) {
setEditing(brand)
setEditName(brand.name)
setEditErrors({})
setEditOpen(true)
}
async function handleEditSubmit() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
toast.success("Brand updated", result.data.name)
setEditOpen(false)
setEditing(null)
load()
} catch (err) {
setEditErrors({ name: errorMessage(err) })
toast.error("Could not update brand", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No brands yet.</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{brands.map((b) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
className="shrink-0"
/>
<span className="truncate font-medium">{b.name}</span>
</label>
))}
</div>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(uom: Uom) {
const next = uom.status === "Active" ? "Inactive" : "Active"
setTogglingId(uom.uomId)
try {
await uomsApi.updateStatus(uom.uomId, next)
load()
} catch (err) {
toast.error("Could not update UOM status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && uoms === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{uoms.map((u) => (
<label
key={u.uomId}
title="Tick to activate/deactivate"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
className="shrink-0"
/>
<span className="truncate font-medium">{u.name}</span>
</label>
))}
</div>
)}
</div>
)
return null
}

Some files were not shown because too many files have changed in this diff Show More