Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2646c6cb3 |
@@ -28,14 +28,20 @@ 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)
|
||||
public AuthController(
|
||||
IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit,
|
||||
IUserManagementService userManagement, ICurrentUser currentUser)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
_roles = roles;
|
||||
_audit = audit;
|
||||
_userManagement = userManagement;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -139,6 +145,8 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,8 @@ 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, trackingMode, stockNature, stock, ct));
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, 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,4 +45,5 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,60 +14,11 @@ 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, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
{
|
||||
_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]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,6 +23,12 @@ 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; }
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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,26 +31,6 @@ 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; }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
|
||||
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
|
||||
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
|
||||
/// form defaults to it; when off, a real customer must be picked and the cash customer is
|
||||
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
|
||||
/// </summary>
|
||||
public class SalesConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
public bool AllowCashCustomer { get; set; }
|
||||
|
||||
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
|
||||
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
|
||||
/// off and on again reuses the same customer (and its sales history).</summary>
|
||||
public int? CashCustomerId { get; set; }
|
||||
public Customer? CashCustomer { get; set; }
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
|
||||
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowFreeItems { get; set; } = true;
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
|
||||
/// column); when off, the column is hidden and a discount above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
|
||||
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
|
||||
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
|
||||
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
|
||||
public bool AllowOverStockSale { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
|
||||
/// and every sales invoice (header and lines) is forced onto it server-side (see
|
||||
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
|
||||
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -15,12 +15,6 @@ 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;
|
||||
@@ -37,6 +31,14 @@ 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,6 +14,8 @@ 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;
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,7 +4,8 @@ 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, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
public sealed record BranchConfigDto(
|
||||
bool BranchingEnabled, bool WarehouseBranchingEnabled, bool PurchaseOrderBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
@@ -18,4 +19,8 @@ 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,8 +12,6 @@ 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>
|
||||
@@ -34,7 +32,4 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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,17 +18,13 @@ 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,
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
int OverdueSalesInvoices);
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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,12 +16,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -8,7 +8,12 @@ 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.</summary>
|
||||
/// 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>
|
||||
public sealed record MeResponseDto(
|
||||
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
|
||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
|
||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
|
||||
bool MustChangePassword = false,
|
||||
bool PurchaseOrderBranchingEnabled = true,
|
||||
int? UserId = null);
|
||||
|
||||
@@ -44,14 +44,6 @@ 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; }
|
||||
|
||||
@@ -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,
|
||||
int VendorId, string Code, string Name, string? Terms, string? TaxReg, string? Address, string? Phone,
|
||||
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
public sealed class CreateVendorRequest
|
||||
@@ -14,6 +14,8 @@ 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>
|
||||
@@ -26,6 +28,8 @@ 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,11 +107,14 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
|
||||
// ---- RecoveryManager ------------------------------------------------
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
|
||||
// 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<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
|
||||
public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
|
||||
@@ -149,6 +152,14 @@ 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,6 +21,8 @@ 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();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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,9 +29,7 @@ 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();
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,6 +18,8 @@ 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)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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,7 +46,6 @@ 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);
|
||||
@@ -107,10 +106,7 @@ public static class DataSeeder
|
||||
SellingPricesEnabled = true,
|
||||
SerialNumbersEnabled = true,
|
||||
BatchListsEnabled = true,
|
||||
WarrantyPeriodsEnabled = true,
|
||||
CustomVariantsEnabled = false,
|
||||
ItemImagesEnabled = false,
|
||||
CustomWarrantyPeriodsEnabled = false
|
||||
WarrantyPeriodsEnabled = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -132,18 +128,6 @@ 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,8 +35,6 @@ 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>();
|
||||
@@ -45,7 +43,6 @@ 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>();
|
||||
@@ -198,7 +195,12 @@ 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);
|
||||
modelBuilder.Entity<PurchaseOrder>().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<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
|
||||
|
||||
// Npgsql requires DateTime values written to `timestamp with time zone` columns
|
||||
|
||||
+6
-66
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260930044903_AddSalesConfigAllowOverStockSale")]
|
||||
partial class AddSalesConfigAllowOverStockSale
|
||||
[Migration("20260929045821_AddUserMustChangePassword")]
|
||||
partial class AddUserMustChangePassword
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -4340,53 +4340,6 @@ 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")
|
||||
@@ -6444,6 +6397,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6477,6 +6433,7 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
UserId = 1,
|
||||
DisplayName = "System",
|
||||
MustChangePassword = false,
|
||||
Status = "Active",
|
||||
Username = "system"
|
||||
});
|
||||
@@ -7885,23 +7842,6 @@ 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")
|
||||
+12
-5
@@ -5,25 +5,32 @@
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowOverStockSale : Migration
|
||||
public partial class AddUserMustChangePassword : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowOverStockSale",
|
||||
table: "sales_config",
|
||||
name: "MustChangePassword",
|
||||
table: "users",
|
||||
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: "AllowOverStockSale",
|
||||
table: "sales_config");
|
||||
name: "MustChangePassword",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-61
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260930043658_AddSalesConfigAllowFreeItems")]
|
||||
partial class AddSalesConfigAllowFreeItems
|
||||
[Migration("20260929060347_AddVendorAddressPhone")]
|
||||
partial class AddVendorAddressPhone
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -4340,48 +4340,6 @@ 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")
|
||||
@@ -6439,6 +6397,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6472,6 +6433,7 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
UserId = 1,
|
||||
DisplayName = "System",
|
||||
MustChangePassword = false,
|
||||
Status = "Active",
|
||||
Username = "system"
|
||||
});
|
||||
@@ -6485,6 +6447,10 @@ 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");
|
||||
|
||||
@@ -6508,6 +6474,10 @@ 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()
|
||||
@@ -7880,23 +7850,6 @@ 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,40 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-56
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260929131905_AddSalesConfig")]
|
||||
partial class AddSalesConfig
|
||||
[Migration("20260929062020_AddPurchaseOrderBranchingToggle")]
|
||||
partial class AddPurchaseOrderBranchingToggle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -347,6 +347,11 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("PurchaseOrderBranchingEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
@@ -4340,43 +4345,6 @@ 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")
|
||||
@@ -6434,6 +6402,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6467,6 +6438,7 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
UserId = 1,
|
||||
DisplayName = "System",
|
||||
MustChangePassword = false,
|
||||
Status = "Active",
|
||||
Username = "system"
|
||||
});
|
||||
@@ -6480,6 +6452,10 @@ 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");
|
||||
|
||||
@@ -6503,6 +6479,10 @@ 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()
|
||||
@@ -7875,23 +7855,6 @@ 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
-5
@@ -5,14 +5,14 @@
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigAllowFreeItems : Migration
|
||||
public partial class AddPurchaseOrderBranchingToggle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config",
|
||||
name: "PurchaseOrderBranchingEnabled",
|
||||
table: "branch_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
@@ -22,8 +22,8 @@ namespace ERPCore.Migrations
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowFreeItems",
|
||||
table: "sales_config");
|
||||
name: "PurchaseOrderBranchingEnabled",
|
||||
table: "branch_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8826
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesConfigWarehouseSelection : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config",
|
||||
column: "DefaultWarehouseId",
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_sales_config_warehouses_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_sales_config_DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowWarehouseSelection",
|
||||
table: "sales_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultWarehouseId",
|
||||
table: "sales_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8831
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
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
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_images",
|
||||
columns: table => new
|
||||
{
|
||||
ItemImageId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: false),
|
||||
RelativePath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_images", x => x.ItemImageId);
|
||||
table.ForeignKey(
|
||||
name: "FK_item_images_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_images_ItemId_SortOrder",
|
||||
table: "item_images",
|
||||
columns: new[] { "ItemId", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "item_images");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ItemImagesEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8909
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomWarrantyPeriods : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8939
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveCustomWarrantyPeriodsToMaster : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "warranty_periods",
|
||||
columns: table => new
|
||||
{
|
||||
WarrantyPeriodId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Months = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_warranty_periods", x => x.WarrantyPeriodId);
|
||||
table.CheckConstraint("ck_warranty_periods_months", "\"Months\" BETWEEN 1 AND 600");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Months",
|
||||
table: "warranty_periods",
|
||||
column: "Months",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_warranty_periods_Status",
|
||||
table: "warranty_periods",
|
||||
column: "Status");
|
||||
|
||||
// Carry over any periods already saved on the config (hand-added — EF would have
|
||||
// dropped the column first and lost them). Out-of-range values are skipped rather
|
||||
// than failing the check constraint.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO warranty_periods ("Months", "Status", "CreatedAt")
|
||||
SELECT DISTINCT m, 'Active', now()
|
||||
FROM product_config, unnest("CustomWarrantyPeriodMonths") AS m
|
||||
WHERE m BETWEEN 1 AND 600
|
||||
ON CONFLICT ("Months") DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "CustomWarrantyPeriodMonths",
|
||||
table: "product_config",
|
||||
type: "integer[]",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::integer[]");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE product_config SET "CustomWarrantyPeriodMonths" = COALESCE(
|
||||
(SELECT array_agg("Months" ORDER BY "Months") FROM warranty_periods WHERE "Status" = 'Active'),
|
||||
'{}'::integer[]);
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "warranty_periods");
|
||||
}
|
||||
}
|
||||
}
|
||||
-8944
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
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,6 +344,11 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("PurchaseOrderBranchingEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
@@ -1831,56 +1836,6 @@ 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")
|
||||
@@ -3302,21 +3257,6 @@ 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")
|
||||
@@ -4402,68 +4342,6 @@ 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")
|
||||
@@ -6521,6 +6399,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -6554,6 +6435,7 @@ namespace ERPCore.Migrations
|
||||
{
|
||||
UserId = 1,
|
||||
DisplayName = "System",
|
||||
MustChangePassword = false,
|
||||
Status = "Active",
|
||||
Username = "system"
|
||||
});
|
||||
@@ -6567,6 +6449,10 @@ 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");
|
||||
|
||||
@@ -6590,6 +6476,10 @@ 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()
|
||||
@@ -6720,43 +6610,6 @@ 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")
|
||||
@@ -7309,17 +7162,6 @@ 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")
|
||||
@@ -8010,30 +7852,6 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashCustomerId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CashCustomer");
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
|
||||
@@ -87,8 +87,6 @@ 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>();
|
||||
@@ -97,7 +95,6 @@ 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,25 +1,54 @@
|
||||
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) => _authHex = authHex;
|
||||
public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
|
||||
{
|
||||
_authHex = authHex;
|
||||
_userManagement = userManagement;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ForgotPasswordAsync(request, ct);
|
||||
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<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordAsync(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 ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
|
||||
|
||||
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ 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;
|
||||
|
||||
@@ -110,6 +112,25 @@ 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).
|
||||
@@ -125,5 +146,6 @@ public sealed class BranchConfigService : IBranchConfigService
|
||||
return branch.BranchId;
|
||||
}
|
||||
|
||||
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
private static BranchConfigDto Map(BranchConfig c) => new(
|
||||
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ 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
|
||||
@@ -34,14 +33,11 @@ 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,
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
||||
IReorderService reorder)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
@@ -51,7 +47,6 @@ public sealed class DashboardService : IDashboardService
|
||||
_rfqs = rfqs;
|
||||
_leaveRequests = leaveRequests;
|
||||
_salesInvoices = salesInvoices;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
@@ -89,6 +84,8 @@ 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);
|
||||
@@ -108,50 +105,9 @@ public sealed class DashboardService : IDashboardService
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
.Pagination.TotalItems;
|
||||
|
||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices, 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();
|
||||
pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,18 +285,9 @@ 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,6 +29,14 @@ 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,16 +24,10 @@ 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. Any shortfall layers left by an
|
||||
/// earlier over-stock sale are settled against issuable stock first.
|
||||
/// and ledger costing). Must run inside a UoW transaction.
|
||||
/// </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,
|
||||
bool allowShortfall = false);
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,7 +19,6 @@ 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,4 +9,5 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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,19 +12,6 @@ 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,6 +27,12 @@ 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);
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item photo service. Same upload rules as staff documents — extension allowlist, declared
|
||||
/// content-type cross-check and a size cap, server-authoritative — narrowed to web image
|
||||
/// formats, plus a per-item count cap. Uploads are refused with <c>CONFIG_DISABLED</c> while
|
||||
/// <c>ProductConfig.ItemImagesEnabled</c> is off; listing, viewing and deleting stay open so
|
||||
/// images added earlier remain manageable.
|
||||
/// </summary>
|
||||
public sealed class ItemImageService : IItemImageService
|
||||
{
|
||||
public const int MaxImagesPerItem = 10;
|
||||
public const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".jpg"] = "image/jpeg",
|
||||
[".jpeg"] = "image/jpeg",
|
||||
[".png"] = "image/png",
|
||||
[".webp"] = "image/webp"
|
||||
};
|
||||
|
||||
private readonly IRepository<ItemImage> _images;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IFileStorageService _storage;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemImageService(
|
||||
IRepository<ItemImage> images, IRepository<Item> items, IProductConfigService config,
|
||||
IFileStorageService storage, IUnitOfWork uow)
|
||||
{
|
||||
_images = images;
|
||||
_items = items;
|
||||
_config = config;
|
||||
_storage = storage;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<ItemImageDto>> ListAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
return await _images.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == itemId)
|
||||
.OrderBy(i => i.SortOrder).ThenBy(i => i.ItemImageId)
|
||||
.Select(i => new ItemImageDto(i.ItemImageId, i.ItemId, i.OriginalFileName, i.ContentType, i.SizeBytes, i.SortOrder, i.UploadedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ItemImageDto> UploadAsync(
|
||||
int itemId, Stream content, string fileName, string contentType, int actorUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (!(await _config.GetAsync(ct)).Value.ItemImagesEnabled)
|
||||
throw new DomainException(ErrorCodes.ConfigDisabled,
|
||||
"Item images are disabled in the product configuration.", 422);
|
||||
|
||||
await EnsureItemExistsAsync(itemId, ct);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|
||||
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
|
||||
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
|
||||
$"File type '{extension}'/'{contentType}' is not allowed. Use JPG, PNG or WebP.", 422);
|
||||
|
||||
if (content.Length > MaxSizeBytes)
|
||||
throw new DomainException(ErrorCodes.FileTooLarge,
|
||||
$"Image exceeds the maximum allowed size of {MaxSizeBytes / (1024 * 1024)} MB.", 413);
|
||||
|
||||
var existing = await _images.Query().Where(i => i.ItemId == itemId).Select(i => i.SortOrder).ToListAsync(ct);
|
||||
if (existing.Count >= MaxImagesPerItem)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"An item can have at most {MaxImagesPerItem} images.", 422);
|
||||
|
||||
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(content, fileName, contentType, ct);
|
||||
|
||||
var image = new ItemImage
|
||||
{
|
||||
ItemId = itemId,
|
||||
OriginalFileName = Path.GetFileName(fileName),
|
||||
StoredFileName = storedFileName,
|
||||
RelativePath = relativePath,
|
||||
ContentType = expectedContentType,
|
||||
SizeBytes = sizeBytes,
|
||||
SortOrder = existing.Count == 0 ? 0 : existing.Max() + 1,
|
||||
UploadedBy = actorUserId,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _images.AddAsync(image, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ItemImageDto(image.ItemImageId, image.ItemId, image.OriginalFileName, image.ContentType,
|
||||
image.SizeBytes, image.SortOrder, image.UploadedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, string ContentType)> OpenAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
return (await _storage.OpenReadAsync(image.RelativePath, ct), image.ContentType);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int itemId, int itemImageId, CancellationToken ct = default)
|
||||
{
|
||||
var image = await _images.Query()
|
||||
.FirstOrDefaultAsync(i => i.ItemImageId == itemImageId && i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Image {itemImageId} was not found for item {itemId}.");
|
||||
|
||||
_images.Remove(image);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
// File removed only after the row is gone, so a failed save never orphans a row.
|
||||
await _storage.DeleteAsync(image.RelativePath, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureItemExistsAsync(int itemId, CancellationToken ct)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == itemId, ct))
|
||||
throw new NotFoundException($"Item {itemId} was not found.");
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,6 @@ 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();
|
||||
@@ -128,24 +127,6 @@ 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)
|
||||
@@ -154,16 +135,16 @@ public sealed class ItemService : IItemService
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer (negative = sold beyond stock), every warehouse.
|
||||
// Current FIFO stock value/qty — every open layer, 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))
|
||||
@@ -234,7 +215,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, config, null);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -311,8 +292,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, config, item.WarrantyPeriodMonths);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
@@ -526,33 +506,18 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(
|
||||
Warranty warranty, int? warrantyPeriodMonths, ProductConfigDto config, int? currentMonths)
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
var valid = warrantyPeriodMonths is not null
|
||||
&& (warrantyPeriodMonths == currentMonths
|
||||
|| (config.CustomWarrantyPeriodsEnabled
|
||||
? warrantyPeriodMonths is >= 1 and <= WarrantyPeriodService.MaxMonths
|
||||
: WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)));
|
||||
|
||||
if (!valid)
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
config.CustomWarrantyPeriodsEnabled
|
||||
? $"Warranty period must be between 1 and {WarrantyPeriodService.MaxMonths} months."
|
||||
: $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.",
|
||||
422);
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
@@ -55,13 +55,6 @@ 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;
|
||||
|
||||
@@ -80,7 +73,5 @@ 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.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
|
||||
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
@@ -305,9 +305,16 @@ 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.</summary>
|
||||
/// 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>
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -29,12 +30,13 @@ public sealed class RoleService : IRoleService
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_employees = employees;
|
||||
_users = users;
|
||||
_currentUser = currentUser;
|
||||
_authHex = authHex;
|
||||
_branchConfig = branchConfig;
|
||||
@@ -202,6 +204,22 @@ 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
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration service over the singleton row seeded by DataSeeder. Also owns the
|
||||
/// generic "Cash Customer" record — created the first time cash sales are allowed.
|
||||
/// </summary>
|
||||
public sealed class SalesConfigService : ISalesConfigService
|
||||
{
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
private const string CashCustomerName = "Cash Customer";
|
||||
|
||||
private readonly IRepository<SalesConfig> _config;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public SalesConfigService(
|
||||
IRepository<SalesConfig> config, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IBranchConfigService branchConfig, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
|
||||
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(SalesConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Sales configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Validated before anything is applied — ResolveCashCustomerIdAsync below saves, so a
|
||||
// later throw would otherwise leave the row half-updated.
|
||||
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "Pick a default sales warehouse before turning warehouse selection off.", 422);
|
||||
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||
|
||||
config.AllowCashCustomer =request.AllowCashCustomer!.Value;
|
||||
if (config.AllowCashCustomer && config.CashCustomerId is null)
|
||||
config.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
|
||||
config.AllowFreeItems = request.AllowFreeItems!.Value;
|
||||
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
|
||||
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return await GetAsync(ct);
|
||||
}
|
||||
|
||||
// Reuses an existing CUST-CASH row (e.g. one left behind by a config row reset) before
|
||||
// creating a new one on the deployment's default branch.
|
||||
private async Task<int> ResolveCashCustomerIdAsync(CancellationToken ct)
|
||||
{
|
||||
var existingId = await _customers.Query().AsNoTracking()
|
||||
.Where(c => c.CustomerCode == CashCustomerCode).Select(c => c.CustomerId).FirstOrDefaultAsync(ct);
|
||||
if (existingId != 0) return existingId;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CustomerCode = CashCustomerCode,
|
||||
CustomerType = CustomerType.WalkIn,
|
||||
Name = CashCustomerName,
|
||||
DisplayName = CashCustomerName,
|
||||
CreditLimit = 0m,
|
||||
CreditDays = 0,
|
||||
BranchId = await _branchConfig.ResolveDefaultBranchIdAsync(ct),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customers.AddAsync(customer, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return customer.CustomerId;
|
||||
}
|
||||
|
||||
private static SalesConfigDto Map(SalesConfig c) => new(
|
||||
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
|
||||
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -23,8 +22,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<TaxRate> taxRates,
|
||||
ISalesPricingService pricing,
|
||||
IRepository<SalesConfig> salesConfig)
|
||||
ISalesPricingService pricing)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
@@ -32,20 +30,12 @@ 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)
|
||||
@@ -57,40 +47,6 @@ 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,7 +86,6 @@ 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);
|
||||
|
||||
@@ -123,7 +122,6 @@ 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;
|
||||
@@ -159,22 +157,9 @@ 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,8 +93,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
@@ -125,8 +124,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status,
|
||||
issues.Count == 0 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public async Task<BundleSalePostingCheckDto> CheckBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
@@ -153,8 +151,7 @@ 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 || await _sales.IsOverStockSaleAllowedAsync(ct), issues);
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -263,10 +260,6 @@ 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;
|
||||
@@ -278,7 +271,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, allowShortfall);
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token);
|
||||
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,11 +63,9 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false)
|
||||
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
@@ -78,43 +76,27 @@ 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), 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
|
||||
// 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
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM stock_layers sl
|
||||
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})))
|
||||
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})
|
||||
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 && !allowShortfall)
|
||||
if (available < qtyBase)
|
||||
{
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
@@ -139,40 +121,9 @@ 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,
|
||||
@@ -244,8 +195,7 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var open = await _layers.Query().AsNoTracking()
|
||||
// Negative (over-stock sale) layers included, so valuation qty always matches on-hand.
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining != 0)
|
||||
.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,8 +38,7 @@ 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)
|
||||
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
@@ -125,7 +124,9 @@ public sealed class UserManagementService : IUserManagementService
|
||||
DisplayName = request.FullName.Trim(),
|
||||
Email = request.Email.Trim(),
|
||||
RoleId = role.RoleId,
|
||||
Status = EntityStatus.Active
|
||||
Status = EntityStatus.Active,
|
||||
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
|
||||
MustChangePassword = true
|
||||
};
|
||||
|
||||
await _users.AddAsync(user, ct);
|
||||
@@ -184,6 +185,27 @@ 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);
|
||||
|
||||
@@ -62,6 +62,8 @@ 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,
|
||||
@@ -94,6 +96,8 @@ 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;
|
||||
@@ -120,6 +124,8 @@ 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.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
||||
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"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'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, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
import { ArrowLeft, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -12,23 +12,44 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
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()])
|
||||
@@ -40,6 +61,7 @@ export default function BankAccountsPage() {
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -49,7 +71,7 @@ export default function BankAccountsPage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return accounts
|
||||
return accounts.filter((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
@@ -62,116 +84,201 @@ export default function BankAccountsPage() {
|
||||
(gl?.accountCode ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [accounts, search, glAccountsById])
|
||||
}, [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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
<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>
|
||||
<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" }))}>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 && filtered === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
|
||||
</p>
|
||||
{!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 "{debouncedSearch}"</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 && 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) => {
|
||||
{!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems !== null && pageItems.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pageItems.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<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>
|
||||
<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"
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</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">
|
||||
</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>
|
||||
<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>
|
||||
@@ -180,12 +287,94 @@ export default function BankAccountsPage() {
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{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'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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ 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",
|
||||
@@ -38,6 +39,7 @@ 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([]))
|
||||
@@ -99,10 +101,12 @@ 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>
|
||||
<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>
|
||||
{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">Range</p>
|
||||
<p className="text-base font-medium">
|
||||
|
||||
@@ -57,7 +57,10 @@ 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.
|
||||
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
|
||||
// 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 showBranchPicker = !lockedBranchId
|
||||
|
||||
useEffect(() => {
|
||||
@@ -137,6 +140,7 @@ 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 ? (
|
||||
@@ -163,6 +167,7 @@ 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, BookText, Plus } from "lucide-react"
|
||||
import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -15,152 +15,425 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
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 { 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({
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
}),
|
||||
chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
|
||||
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
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
}, [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])
|
||||
|
||||
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 items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
<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>
|
||||
<p className="text-base text-muted-foreground">Cheque books issued from this company's own supply.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 && books === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-base text-muted-foreground">No cheque books yet.</p>
|
||||
{!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 "{debouncedSearch}"</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 && 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) => {
|
||||
{!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleBooks.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
<div
|
||||
key={b.chequeBookNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(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"
|
||||
>
|
||||
<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])}>
|
||||
<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])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Inbox, Plus } from "lucide-react"
|
||||
import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
|
||||
|
||||
import { receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -14,14 +14,27 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
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 { 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",
|
||||
@@ -30,12 +43,27 @@ 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)
|
||||
|
||||
@@ -43,124 +71,385 @@ 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) setCheques(res.items)
|
||||
if (cancelled) return
|
||||
setCheques(res.items)
|
||||
setTotalItems(res.totalCount)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShowMoreLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
}, [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])
|
||||
|
||||
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 items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
<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>
|
||||
<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" }))}>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 && cheques === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-base text-muted-foreground">No received cheques yet.</p>
|
||||
{!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 "{debouncedSearch}"</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 && 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
|
||||
{!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleCheques.map((c) => (
|
||||
<div
|
||||
key={c.receivedChequeId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openDialog(c)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") openDialog(c)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<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])}>
|
||||
<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])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
HelpCircle,
|
||||
Inbox,
|
||||
Plus,
|
||||
@@ -19,7 +20,7 @@ import { SupportTicketSummary } from "@/types/support"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } 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"
|
||||
@@ -63,6 +64,7 @@ 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("")
|
||||
@@ -87,6 +89,12 @@ 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 {
|
||||
@@ -100,11 +108,12 @@ export default function HelpPage() {
|
||||
const filtered = useMemo(() => {
|
||||
if (!tickets) return []
|
||||
const term = debouncedSearch.toLowerCase()
|
||||
if (!term) return tickets
|
||||
return tickets.filter(
|
||||
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
|
||||
(t) =>
|
||||
(!statusFilter || t.status === statusFilter) &&
|
||||
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
|
||||
)
|
||||
}, [tickets, debouncedSearch])
|
||||
}, [tickets, debouncedSearch, statusFilter])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
@@ -207,16 +216,40 @@ export default function HelpPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<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} />
|
||||
<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")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -264,15 +297,17 @@ export default function HelpPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
|
||||
{!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
|
||||
<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 tickets match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{debouncedSearch ? <>No tickets match "{debouncedSearch}"</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
<Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -294,11 +329,7 @@ export default function HelpPage() {
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pageItems.map((t) => (
|
||||
<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 key={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])}>
|
||||
@@ -323,7 +354,13 @@ export default function HelpPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{totalItems > pageItems.length && (
|
||||
@@ -344,6 +381,7 @@ 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>
|
||||
@@ -371,6 +409,18 @@ 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,13 +167,31 @@ export default function EmployeesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<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="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||
<StatCard
|
||||
label="Terminated"
|
||||
value={stats.terminated}
|
||||
icon={UserMinus}
|
||||
active={statusFilter === "Terminated"}
|
||||
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
@@ -519,7 +537,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 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.`}
|
||||
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
|
||||
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, Plus, Search, X, XCircle } from "lucide-react"
|
||||
import { CheckCircle2, Clock, PenLine, Plus, Search, X, XCircle } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -41,8 +40,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)
|
||||
@@ -91,6 +90,22 @@ 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)
|
||||
@@ -113,7 +128,7 @@ export default function LeaveRequestsPage() {
|
||||
toast.success("Leave request submitted")
|
||||
setOpen(false)
|
||||
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not submit leave request", errorMessage(err))
|
||||
} finally {
|
||||
@@ -126,7 +141,7 @@ export default function LeaveRequestsPage() {
|
||||
try {
|
||||
await leaveRequestsApi.approve(id)
|
||||
toast.success("Leave approved")
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve", errorMessage(err))
|
||||
} finally {
|
||||
@@ -149,7 +164,7 @@ export default function LeaveRequestsPage() {
|
||||
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
|
||||
toast.success("Leave rejected")
|
||||
setRejectTarget(null)
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not reject", errorMessage(err))
|
||||
} finally {
|
||||
@@ -238,7 +253,49 @@ export default function LeaveRequestsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
<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-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
|
||||
<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,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Eye, Plus } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -10,7 +11,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
@@ -210,6 +211,7 @@ 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>
|
||||
@@ -229,6 +231,18 @@ 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,6 +20,7 @@ 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__"
|
||||
@@ -31,6 +32,7 @@ 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)
|
||||
@@ -227,18 +229,20 @@ export default function DepartmentsPage() {
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
@@ -282,18 +286,20 @@ export default function DepartmentsPage() {
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -129,30 +129,30 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20 w-52 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ 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"
|
||||
@@ -55,7 +56,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const poId = Number(params.id)
|
||||
const { isAdmin } = useAuth()
|
||||
const { isAdmin, userId } = useAuth()
|
||||
|
||||
const [po, setPo] = useState<PurchaseOrder | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
@@ -121,6 +122,9 @@ 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
|
||||
}
|
||||
@@ -307,6 +311,19 @@ 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">
|
||||
@@ -346,7 +363,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{po.status === "PendingApproval" && isAdmin && (
|
||||
{canDecide && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
|
||||
<Check className="size-5" />
|
||||
@@ -374,6 +391,13 @@ 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>
|
||||
@@ -485,18 +509,16 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<FieldLabel className="text-sm">Item</FieldLabel>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</div>
|
||||
<Button
|
||||
@@ -593,7 +615,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
@@ -623,18 +645,17 @@ export default function PurchaseOrderDetailPage() {
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
|
||||
@@ -36,6 +36,7 @@ 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"
|
||||
@@ -69,7 +70,9 @@ function NewPurchaseOrderContent() {
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
const rfqId = Number(searchParams.get("rfqId")) || null
|
||||
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
// 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 [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
@@ -92,6 +95,8 @@ 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)
|
||||
@@ -131,6 +136,8 @@ function NewPurchaseOrderContent() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVAddress("")
|
||||
setVPhone("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
@@ -149,6 +156,8 @@ function NewPurchaseOrderContent() {
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
address: vAddress || null,
|
||||
phone: vPhone || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
@@ -232,6 +241,14 @@ 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)))
|
||||
}
|
||||
@@ -240,6 +257,9 @@ 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
|
||||
}
|
||||
@@ -377,6 +397,14 @@ 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} />
|
||||
@@ -468,22 +496,16 @@ function NewPurchaseOrderContent() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -514,7 +536,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
{lineWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
@@ -565,7 +587,7 @@ function NewPurchaseOrderContent() {
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 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>
|
||||
@@ -584,22 +606,17 @@ 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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -615,7 +632,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
{lineWarehouses.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, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -38,7 +38,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const router = useRouter()
|
||||
const stats = useDashboardStats()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
// 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 [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
@@ -90,8 +92,12 @@ 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 vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
return vendorOf(vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
function branchName(branchId: number) {
|
||||
@@ -132,7 +138,14 @@ 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>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
<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>
|
||||
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
@@ -150,7 +163,28 @@ export default function PurchaseOrdersListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
@@ -237,6 +271,9 @@ 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>
|
||||
@@ -250,6 +287,17 @@ 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>
|
||||
@@ -339,6 +387,7 @@ 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>}
|
||||
@@ -363,7 +412,22 @@ export default function PurchaseOrdersListPage() {
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</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>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
@@ -373,7 +437,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).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
|
||||
@@ -78,7 +78,21 @@ export default function RequisitionsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
stats
|
||||
? [
|
||||
{
|
||||
label: "Open Requisitions",
|
||||
value: stats.openRequisitions,
|
||||
icon: Send,
|
||||
active: status === "Submitted",
|
||||
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -36,7 +35,6 @@ 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}`
|
||||
@@ -117,8 +115,6 @@ 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)
|
||||
@@ -166,7 +162,6 @@ export default function ItemDetailPage() {
|
||||
setTaxRates(tr.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
@@ -259,7 +254,6 @@ export default function ItemDetailPage() {
|
||||
function closeEdit() {
|
||||
if (item) applyItem(item)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -288,7 +282,6 @@ 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
|
||||
@@ -388,7 +381,7 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
|
||||
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
|
||||
<StatChip
|
||||
icon={BadgeDollarSign}
|
||||
@@ -415,10 +408,6 @@ 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">
|
||||
@@ -839,7 +828,6 @@ 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,26 +1,375 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Plus, Ruler, Tag } from "lucide-react"
|
||||
|
||||
// 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() {
|
||||
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() {
|
||||
return (
|
||||
<Suspense>
|
||||
<BrandsRedirect />
|
||||
<BrandsPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandsRedirect() {
|
||||
function BrandsPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/products/categories?tab=${tab}`)
|
||||
}, [router, tab])
|
||||
function selectTab(next: Tab) {
|
||||
setTab(next)
|
||||
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
||||
}
|
||||
|
||||
return null
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user