Merge pull request 'sales & grn fixers' (#94) from sales-&-GRN-fixers into production
Reviewed-on: #94
This commit was merged in pull request #94.
This commit is contained in:
@@ -23,8 +23,9 @@ public sealed class CustomersController : ApiControllerBase
|
|||||||
[FromQuery] PageQuery query,
|
[FromQuery] PageQuery query,
|
||||||
[FromQuery] EntityStatus? status,
|
[FromQuery] EntityStatus? status,
|
||||||
[FromQuery] CustomerType? customerType,
|
[FromQuery] CustomerType? customerType,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
[FromQuery] bool includeCashCustomer = false)
|
||||||
|
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
|
||||||
|
|
||||||
[HttpGet("{customerId:int}")]
|
[HttpGet("{customerId:int}")]
|
||||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||||
|
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/grn-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||||
|
public sealed class GrnConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGrnConfigService _config;
|
||||||
|
|
||||||
|
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||||
|
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,16 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||||
|
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||||
|
[HttpGet("stock-summary")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||||
|
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
// 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.
|
// same resolution the server applies on save.
|
||||||
[HttpGet("suggested-price")]
|
[HttpGet("suggested-price")]
|
||||||
@@ -143,6 +153,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||||
|
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("payments/bulk")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||||
|
|
||||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GRN (goods receipt) configuration — a singleton row, same shape as <see cref="SalesConfig"/>.
|
||||||
|
/// Controls which warehouse goods are received into.
|
||||||
|
/// </summary>
|
||||||
|
public class GrnConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New GRN shows
|
||||||
|
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||||
|
public bool AllowWarehouseSelection { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Default receiving warehouse. Whenever set, New GRN shows only this warehouse (locked)
|
||||||
|
/// and every GRN is forced onto it server-side (see GrnConfigService.GetForcedWarehouseIdAsync).
|
||||||
|
/// Required while <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||||
|
public int? DefaultWarehouseId { get; set; }
|
||||||
|
public Warehouse? DefaultWarehouse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When on (default), GRN lines may carry a discount % (the New GRN "Disc %" column and
|
||||||
|
/// header rate); when off, they are hidden and a discount above zero is rejected server-side
|
||||||
|
/// (see GrnService.CreateAsync).</summary>
|
||||||
|
public bool AllowDiscounts { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When on (default), GRN lines carry VAT (entered, or the item's tax rate when left
|
||||||
|
/// empty); when off, the VAT columns are hidden, every line is received at 0% VAT, and a VAT
|
||||||
|
/// above zero is rejected server-side (see GrnService.CreateAsync).</summary>
|
||||||
|
public bool AllowVat { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>GRN configuration resource.</summary>
|
||||||
|
public sealed record GrnConfigDto(
|
||||||
|
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, bool AllowDiscounts, bool AllowVat,
|
||||||
|
DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
|
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||||
|
/// (same reasoning as <see cref="UpdateSalesConfigRequest"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateGrnConfigRequest
|
||||||
|
{
|
||||||
|
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||||
|
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||||
|
public int? DefaultWarehouseId { get; set; }
|
||||||
|
[Required] public bool? AllowDiscounts { get; set; }
|
||||||
|
[Required] public bool? AllowVat { get; set; }
|
||||||
|
}
|
||||||
@@ -133,5 +133,6 @@ public sealed class ReorderSettingInput
|
|||||||
|
|
||||||
public sealed class UpdateReorderRequest
|
public sealed class UpdateReorderRequest
|
||||||
{
|
{
|
||||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
/// <summary>The complete set — an empty list removes every rule for the item.</summary>
|
||||||
|
[Required] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ public sealed class CreateSalesInvoicePaymentRequest
|
|||||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
[Required] public long GlBankAccountId { get; set; }
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
[StringLength(100)] public string? Reference { get; set; }
|
[StringLength(100)] public string? Reference { get; set; }
|
||||||
|
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||||
|
public DateOnly? PaymentDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
|
||||||
|
public sealed class BulkSalesInvoicePaymentLine
|
||||||
|
{
|
||||||
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
|
||||||
|
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateBulkSalesInvoicePaymentRequest
|
||||||
|
{
|
||||||
|
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
|
||||||
|
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
|
||||||
|
[StringLength(100)] public string? Reference { get; set; }
|
||||||
|
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||||
|
public DateOnly? PaymentDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record SalesInvoicePostingIssueDto(
|
public sealed record SalesInvoicePostingIssueDto(
|
||||||
@@ -52,6 +74,10 @@ public sealed record SalesSuggestedPriceDto(int ItemId, int WarehouseId, decimal
|
|||||||
/// per-line shortage warning. <c>IsStocked</c> false = non-stock item, never limited.</summary>
|
/// 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 record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
|
||||||
|
|
||||||
|
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
|
||||||
|
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
|
||||||
|
public sealed record SalesItemStockDto(int ItemId, decimal Available);
|
||||||
|
|
||||||
public sealed class CreateSalesInvoiceLineRequest
|
public sealed class CreateSalesInvoiceLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[Required] public int ItemId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the singleton GRN-configuration row. The check constraint is what makes
|
||||||
|
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<GrnConfig> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("grn_config", t =>
|
||||||
|
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.SingletonId}"));
|
||||||
|
|
||||||
|
builder.HasKey(c => c.ConfigId);
|
||||||
|
|
||||||
|
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||||
|
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||||
|
|
||||||
|
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||||
|
// instead of being swallowed by the database default.
|
||||||
|
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.DefaultWarehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(c => c.UpdatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UpdatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ public static class DataSeeder
|
|||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||||
|
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
dirty |= await SeedSalesStockAsync(db, ct);
|
dirty |= await SeedSalesStockAsync(db, ct);
|
||||||
@@ -144,6 +145,16 @@ public static class DataSeeder
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
|
||||||
|
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
|
||||||
|
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
|
||||||
|
|
||||||
|
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public class ErpDbContext : DbContext
|
|||||||
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
||||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||||
|
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
|
||||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGrnConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "grn_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
AllowWarehouseSelection = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
DefaultWarehouseId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_grn_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_grn_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_grn_config_warehouses_DefaultWarehouseId",
|
||||||
|
column: x => x.DefaultWarehouseId,
|
||||||
|
principalTable: "warehouses",
|
||||||
|
principalColumn: "WarehouseId",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_grn_config_DefaultWarehouseId",
|
||||||
|
table: "grn_config",
|
||||||
|
column: "DefaultWarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_grn_config_UpdatedBy",
|
||||||
|
table: "grn_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "grn_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGrnConfigDiscountsVat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowDiscounts",
|
||||||
|
table: "grn_config",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowVat",
|
||||||
|
table: "grn_config",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowDiscounts",
|
||||||
|
table: "grn_config");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowVat",
|
||||||
|
table: "grn_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("grns", (string)null);
|
b.ToTable("grns", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ConfigId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowDiscounts")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowVat")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowWarehouseSelection")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultWarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("UpdatedBy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ConfigId");
|
||||||
|
|
||||||
|
b.HasIndex("DefaultWarehouseId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedBy");
|
||||||
|
|
||||||
|
b.ToTable("grn_config", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("GrnLineId")
|
b.Property<int>("GrnLineId")
|
||||||
@@ -7211,6 +7258,23 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultWarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("DefaultWarehouse");
|
||||||
|
|
||||||
|
b.Navigation("UpdatedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
|||||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||||
|
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||||
|
|||||||
@@ -16,19 +16,37 @@ public sealed class CustomerService : ICustomerService
|
|||||||
private readonly IRepository<Customer> _customers;
|
private readonly IRepository<Customer> _customers;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
|
private readonly IRepository<SalesConfig> _salesConfig;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
|
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||||
|
private const string CashCustomerCode = "CUST-CASH";
|
||||||
|
|
||||||
|
public CustomerService(
|
||||||
|
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||||
|
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
|
_salesConfig = salesConfig;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
|
public async Task<PagedResponse<CustomerDto>> ListAsync(
|
||||||
|
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _customers.Query().AsNoTracking();
|
var q = _customers.Query().AsNoTracking();
|
||||||
|
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||||
|
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||||
|
if (!includeCashCustomer)
|
||||||
|
{
|
||||||
|
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||||
|
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||||
|
.Select(c => c.CashCustomerId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
{
|
{
|
||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>GRN configuration service over the singleton row seeded by DataSeeder.</summary>
|
||||||
|
public sealed class GrnConfigService : IGrnConfigService
|
||||||
|
{
|
||||||
|
private readonly IRepository<GrnConfig> _config;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public GrnConfigService(
|
||||||
|
IRepository<GrnConfig> config, IRepository<Warehouse> warehouses, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultWarehouse)
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||||
|
|
||||||
|
return new ETagged<GrnConfigDto>(Map(config), config.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||||
|
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.GetByIdAsync(GrnConfig.SingletonId, ct)
|
||||||
|
?? throw new NotFoundException("GRN configuration has not been seeded.");
|
||||||
|
|
||||||
|
if (config.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||||
|
|
||||||
|
var allowWarehouseSelection = request.AllowWarehouseSelection!.Value;
|
||||||
|
if (!allowWarehouseSelection && request.DefaultWarehouseId is null)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Pick a default receiving warehouse before turning warehouse selection off.", 422);
|
||||||
|
if (request.DefaultWarehouseId is int defaultWarehouseId
|
||||||
|
&& !await _warehouses.Query().AnyAsync(w => w.WarehouseId == defaultWarehouseId, ct))
|
||||||
|
throw new NotFoundException($"Warehouse {defaultWarehouseId} was not found.");
|
||||||
|
|
||||||
|
config.AllowWarehouseSelection = allowWarehouseSelection;
|
||||||
|
config.DefaultWarehouseId = request.DefaultWarehouseId;
|
||||||
|
config.AllowDiscounts = request.AllowDiscounts!.Value;
|
||||||
|
config.AllowVat = request.AllowVat!.Value;
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
config.UpdatedBy = _currentUser.AuditUserId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.Query().AsNoTracking()
|
||||||
|
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||||
|
.Select(c => new { c.AllowWarehouseSelection, c.DefaultWarehouseId })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (config is null) return null;
|
||||||
|
// A saved default receiving warehouse always wins — GRNs use only that warehouse.
|
||||||
|
if (config.DefaultWarehouseId is int defaultWarehouseId) return defaultWarehouseId;
|
||||||
|
if (config.AllowWarehouseSelection) return null;
|
||||||
|
// The FK is SetNull on delete, so the default can disappear after it was configured.
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Warehouse selection is turned off in GRN Configuration but no default receiving warehouse is set.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var config = await _config.Query().AsNoTracking()
|
||||||
|
.Where(c => c.ConfigId == GrnConfig.SingletonId)
|
||||||
|
.Select(c => new { c.AllowDiscounts, c.AllowVat })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return config is null ? (true, true) : (config.AllowDiscounts, config.AllowVat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GrnConfigDto Map(GrnConfig c) => new(
|
||||||
|
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.AllowDiscounts, c.AllowVat, c.UpdatedAt, c.UpdatedBy);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ public sealed class GrnService : IGrnService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
|
private readonly IGrnConfigService _grnConfig;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
@@ -52,7 +53,7 @@ public sealed class GrnService : IGrnService
|
|||||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
|
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_grns = grns;
|
_grns = grns;
|
||||||
@@ -70,6 +71,7 @@ public sealed class GrnService : IGrnService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
|
_grnConfig = grnConfig;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
@@ -118,6 +120,10 @@ public sealed class GrnService : IGrnService
|
|||||||
|
|
||||||
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// GRN Configuration default receiving warehouse: whatever the client sent, the GRN uses it.
|
||||||
|
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
|
||||||
|
request.WarehouseId = forcedWarehouseId;
|
||||||
|
|
||||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||||
|
|
||||||
@@ -144,6 +150,12 @@ public sealed class GrnService : IGrnService
|
|||||||
vendorId = request.VendorId.Value;
|
vendorId = request.VendorId.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
|
||||||
|
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
|
||||||
|
if (!allowVat && request.Lines.Any(l => l.VatPct > 0m))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "VAT is turned off in GRN Configuration.", 422);
|
||||||
|
|
||||||
var lines = new List<GrnLine>();
|
var lines = new List<GrnLine>();
|
||||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||||
|
|
||||||
@@ -187,7 +199,8 @@ public sealed class GrnService : IGrnService
|
|||||||
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
|
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
|
||||||
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
|
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
|
||||||
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
||||||
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
|
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
|
||||||
|
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||||
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
||||||
: 0m);
|
: 0m);
|
||||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
|
|
||||||
public interface ICustomerService
|
public interface ICustomerService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
|
||||||
|
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default);
|
||||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>GRN configuration business logic. Singleton.</summary>
|
||||||
|
public interface IGrnConfigService
|
||||||
|
{
|
||||||
|
Task<ETagged<GrnConfigDto>> GetAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<GrnConfigDto>> UpdateAsync(
|
||||||
|
UpdateGrnConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>GRN Configuration default receiving warehouse: when one is saved, every GRN must
|
||||||
|
/// use it. Null only when none is saved and "Allow warehouse selection" is on.</summary>
|
||||||
|
Task<int?> GetForcedWarehouseIdAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Whether GRN lines may carry a discount and VAT (both default to allowed).</summary>
|
||||||
|
Task<(bool AllowDiscounts, bool AllowVat)> GetLineOptionsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -6,5 +6,7 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
public interface ISalesInvoicePaymentService
|
public interface ISalesInvoicePaymentService
|
||||||
{
|
{
|
||||||
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||||
|
/// <summary>One customer, several invoices, one GL journal entry; returns a payment per invoice/account split.</summary>
|
||||||
|
Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public interface IStockService
|
|||||||
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
|
||||||
|
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
{
|
{
|
||||||
private readonly IRepository<SalesInvoice> _invoices;
|
private readonly IRepository<SalesInvoice> _invoices;
|
||||||
private readonly IRepository<SalesInvoicePayment> _payments;
|
private readonly IRepository<SalesInvoicePayment> _payments;
|
||||||
|
private readonly IRepository<SalesConfig> _salesConfig;
|
||||||
private readonly IGeneralLedgerService _gl;
|
private readonly IGeneralLedgerService _gl;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly string _glAccountsReceivableCode;
|
private readonly string _glAccountsReceivableCode;
|
||||||
|
|
||||||
public SalesInvoicePaymentService(
|
public SalesInvoicePaymentService(
|
||||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
|
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_payments = payments;
|
_payments = payments;
|
||||||
|
_salesConfig = salesConfig;
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
|
|
||||||
var actor = _currentUser.AuditUserId;
|
var actor = _currentUser.AuditUserId;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||||
|
|
||||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||||
// the whole payment back with no partial local state.
|
// the whole payment back with no partial local state.
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = paymentDay,
|
||||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||||
Reference = invoice.InvoiceNo,
|
Reference = invoice.InvoiceNo,
|
||||||
Narration = $"Payment against invoice {invoice.InvoiceNo}",
|
Narration = $"Payment against invoice {invoice.InvoiceNo}",
|
||||||
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
{
|
{
|
||||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||||
Amount = request.Amount,
|
Amount = request.Amount,
|
||||||
PaymentDate = now,
|
PaymentDate = paymentDate,
|
||||||
GlBankAccountId = account.AccountId,
|
GlBankAccountId = account.AccountId,
|
||||||
BankAccountName = account.AccountName,
|
BankAccountName = account.AccountName,
|
||||||
Reference = request.Reference,
|
Reference = request.Reference,
|
||||||
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
return Map(payment);
|
return Map(payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = request.SalesInvoiceIds.Distinct().ToList();
|
||||||
|
if (ids.Count == 0 || request.Lines.Count == 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
|
||||||
|
|
||||||
|
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
|
||||||
|
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
|
||||||
|
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
|
||||||
|
|
||||||
|
// The generic cash customer's invoices are paid one at a time, never grouped.
|
||||||
|
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||||
|
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||||
|
.Select(c => c.CashCustomerId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
|
||||||
|
|
||||||
|
foreach (var inv in invoices)
|
||||||
|
{
|
||||||
|
if (inv.Status != SalesInvoiceStatus.Posted)
|
||||||
|
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
|
||||||
|
if (inv.BalanceAmount <= 0)
|
||||||
|
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = request.Lines.Sum(l => l.Amount);
|
||||||
|
var totalBalance = invoices.Sum(i => i.BalanceAmount);
|
||||||
|
if (total > totalBalance)
|
||||||
|
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
|
||||||
|
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
|
||||||
|
|
||||||
|
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||||
|
var lines = request.Lines.Select(l => (
|
||||||
|
Line: l,
|
||||||
|
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
|
||||||
|
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
||||||
|
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
||||||
|
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
|
||||||
|
var invIdx = 0;
|
||||||
|
var invRemaining = ordered[0].BalanceAmount;
|
||||||
|
foreach (var (line, account) in lines)
|
||||||
|
{
|
||||||
|
var lineRemaining = line.Amount;
|
||||||
|
while (lineRemaining > 0)
|
||||||
|
{
|
||||||
|
var take = Math.Min(lineRemaining, invRemaining);
|
||||||
|
allocations.Add((ordered[invIdx], account, take));
|
||||||
|
lineRemaining -= take;
|
||||||
|
invRemaining -= take;
|
||||||
|
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
||||||
|
invRemaining = ordered[++invIdx].BalanceAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actor = _currentUser.AuditUserId;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||||
|
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
|
||||||
|
var narration = $"Payment against invoices {paidNos}";
|
||||||
|
if (narration.Length > 250) narration = narration[..247] + "...";
|
||||||
|
|
||||||
|
var payments = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||||
|
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||||
|
var glLines = lines
|
||||||
|
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||||
|
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
||||||
|
.ToList();
|
||||||
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = paymentDay,
|
||||||
|
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||||
|
Reference = request.Reference ?? ordered[0].InvoiceNo,
|
||||||
|
Narration = narration,
|
||||||
|
Lines = glLines
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
var entities = new List<SalesInvoicePayment>();
|
||||||
|
foreach (var (invoice, account, amount) in allocations)
|
||||||
|
{
|
||||||
|
var entity = new SalesInvoicePayment
|
||||||
|
{
|
||||||
|
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||||
|
Amount = amount,
|
||||||
|
PaymentDate = paymentDate,
|
||||||
|
GlBankAccountId = account.AccountId,
|
||||||
|
BankAccountName = account.AccountName,
|
||||||
|
Reference = request.Reference,
|
||||||
|
GlJournalNo = posted.JournalNo,
|
||||||
|
CreatedBy = actor,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
await _payments.AddAsync(entity, token);
|
||||||
|
invoice.PaidAmount += amount;
|
||||||
|
invoice.BalanceAmount -= amount;
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return payments.Select(Map).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
||||||
|
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
|
||||||
|
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
|
||||||
|
/// that day's start.
|
||||||
|
/// </summary>
|
||||||
|
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(now);
|
||||||
|
var day = requested ?? today;
|
||||||
|
if (day > today.AddDays(1))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||||
|
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ public sealed class SalesPricingService : ISalesPricingService
|
|||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sales unit prices are money — always 2dp, whichever source (flat, GRN weighted avg, FIFO avg,
|
||||||
|
// manual) produced them, so the suggested-price endpoint and saved lines agree.
|
||||||
public async Task<SalesPriceResolution> ResolveAsync(
|
public async Task<SalesPriceResolution> ResolveAsync(
|
||||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var resolved = await ResolveUnroundedAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
|
||||||
|
return resolved with { UnitPrice = Math.Round(resolved.UnitPrice, 2, MidpointRounding.AwayFromZero) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SalesPriceResolution> ResolveUnroundedAsync(
|
||||||
|
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
|
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
|
||||||
?? throw new InvalidOperationException($"Item {itemId} was not found.");
|
?? throw new InvalidOperationException($"Item {itemId} was not found.");
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ public sealed class StockService : IStockService
|
|||||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same figure as <see cref="GetOnHandAsync"/>'s Available (all layers, negative ones included,
|
||||||
|
/// less held OnHold/Rejected GRN stock), for every item at the warehouse in one grouped query.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||||
|
=> await _layers.Query().AsNoTracking()
|
||||||
|
.Where(l => l.WarehouseId == warehouseId
|
||||||
|
&& !(l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected)))
|
||||||
|
.GroupBy(l => l.ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||||
|
|
||||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ export default function CustomersPage() {
|
|||||||
useEffect(load, [query, status, customerType, page, pageSize])
|
useEffect(load, [query, status, customerType, page, pageSize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||||
|
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||||
|
|||||||
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{layout.arrangement !== "highlight" && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-muted-foreground">Subtotal</div>
|
|
||||||
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
|
||||||
<div className="text-muted-foreground">VAT</div>
|
|
||||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
|
||||||
<div className="text-muted-foreground">Grand total</div>
|
|
||||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto border-y">
|
<div className="overflow-x-auto border-y">
|
||||||
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||||
|
{layout.arrangement !== "highlight" && (
|
||||||
|
<div className="ml-auto mt-4 w-full max-w-xs">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div>
|
||||||
|
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
|
||||||
|
<div className="text-muted-foreground">VAT</div>
|
||||||
|
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||||
|
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
|
||||||
|
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<InvoicePrintFooter company={company} template={template} />
|
<InvoicePrintFooter company={company} template={template} />
|
||||||
|
|
||||||
<div className="mt-6 border-t pt-5">
|
<div className="mt-6 border-t pt-5">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { toast } from "@/components/ui/toast"
|
|||||||
import LineChart from "@/components/ui/line-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
import BarChart from "@/components/ui/bar-chart"
|
||||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||||
|
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||||
|
|
||||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||||
@@ -419,6 +420,16 @@ export default function ItemDetailPage() {
|
|||||||
dialog closes so this copy picks up what changed there. */}
|
dialog closes so this copy picks up what changed there. */}
|
||||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||||
|
|
||||||
|
<ItemReorderPanel
|
||||||
|
key={item.itemId}
|
||||||
|
itemId={item.itemId}
|
||||||
|
stockNature={item.stockNature}
|
||||||
|
settings={item.reorder}
|
||||||
|
warehouses={warehouses}
|
||||||
|
uomName={uomName(item.baseUomId)}
|
||||||
|
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
CircleDollarSign,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
ListTree,
|
ListTree,
|
||||||
Package,
|
Package,
|
||||||
|
PackageX,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -25,6 +27,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { downloadCsv } from "@/lib/csv-export"
|
import { downloadCsv } from "@/lib/csv-export"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
|
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ import { FilterBar } from "@/components/ui/filter-bar"
|
|||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
@@ -123,6 +127,8 @@ export default function ItemsPage() {
|
|||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
|
||||||
|
const dashboardStats = useDashboardStats()
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
@@ -211,6 +217,29 @@ export default function ItemsPage() {
|
|||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
setPage((p) => p + 1)
|
setPage((p) => p + 1)
|
||||||
}
|
}
|
||||||
|
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
|
||||||
|
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all([
|
||||||
|
itemsApi.overview({ page: 1, pageSize: 1 }),
|
||||||
|
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
|
||||||
|
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
|
||||||
|
])
|
||||||
|
.then(([total, low, out]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setItemStats({
|
||||||
|
total: total.pagination.totalItems,
|
||||||
|
lowStock: low.pagination.totalItems,
|
||||||
|
outOfStock: out.pagination.totalItems,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||||
@@ -331,6 +360,24 @@ export default function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{itemStats ? (
|
||||||
|
<>
|
||||||
|
<StatCard label="Total items" value={itemStats.total} icon={Package} />
|
||||||
|
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
|
||||||
|
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||||
|
)}
|
||||||
|
{/* Passed as a string so StatCard shows the full 2dp amount, not its compact "1.2M" form. */}
|
||||||
|
{dashboardStats ? (
|
||||||
|
<StatCard label="Stock value" value={formatMoney(dashboardStats.stockValuationTotal)} icon={CircleDollarSign} />
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-22.5 rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Same filter bar as the Sales list, but search on the left and every filter
|
{/* Same filter bar as the Sales list, but search on the left and every filter
|
||||||
(category, status, "Advanced" popover) grouped on the right. */}
|
(category, status, "Advanced" popover) grouped on the right. */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||||
import { ApiError } from "@/lib/api-client"
|
import { ApiError } from "@/lib/api-client"
|
||||||
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
|
||||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||||
import { Branch } from "@/types/hrm"
|
import { Branch } from "@/types/hrm"
|
||||||
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
|
|||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
|
||||||
|
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
|
||||||
|
// replaced by a locked field, and the server forces it too.
|
||||||
|
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
|
||||||
|
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
|
||||||
|
// treated (and sent) as 0% — the server rejects anything else.
|
||||||
|
const allowDiscounts = grnConfig?.allowDiscounts ?? true
|
||||||
|
const allowVat = grnConfig?.allowVat ?? true
|
||||||
|
function effectiveLine(l: DraftLine): DraftLine {
|
||||||
|
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
|
||||||
|
}
|
||||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||||
const [poId, setPoId] = useState<number | null>(null)
|
const [poId, setPoId] = useState<number | null>(null)
|
||||||
const [poLoading, setPoLoading] = useState(false)
|
const [poLoading, setPoLoading] = useState(false)
|
||||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
const [lines, setLines] = useState<DraftLine[]>([])
|
||||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||||
|
|
||||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||||
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
|
|||||||
uomsApi.list(),
|
uomsApi.list(),
|
||||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||||
|
// Missing/failed config just means "pick a warehouse" — never block the form on it.
|
||||||
|
grnConfigApi.get().then((r) => r.data).catch(() => null),
|
||||||
])
|
])
|
||||||
.then(([wh, it, uo, ve, po]) => {
|
.then(([wh, it, uo, ve, po, config]) => {
|
||||||
|
setGrnConfig(config)
|
||||||
|
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setUoms(uo.items)
|
setUoms(uo.items)
|
||||||
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
|
|||||||
setMode(next)
|
setMode(next)
|
||||||
setPoId(null)
|
setPoId(null)
|
||||||
setVendorId(null)
|
setVendorId(null)
|
||||||
setLines([emptyLine()])
|
setLines([])
|
||||||
setHeaderDiscountPct("")
|
setHeaderDiscountPct("")
|
||||||
setHeaderVatPct("")
|
setHeaderVatPct("")
|
||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
|
|||||||
async function handleSelectPo(nextPoId: number | null) {
|
async function handleSelectPo(nextPoId: number | null) {
|
||||||
setPoId(nextPoId)
|
setPoId(nextPoId)
|
||||||
if (!nextPoId) {
|
if (!nextPoId) {
|
||||||
setLines([emptyLine()])
|
setLines([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
|
|||||||
setLines([])
|
setLines([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
|
||||||
|
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||||
setLines(
|
setLines(
|
||||||
openLines.map((l): DraftLine => {
|
openLines.map((l): DraftLine => {
|
||||||
const draft: DraftLine = {
|
const draft: DraftLine = {
|
||||||
@@ -357,40 +375,7 @@ export default function NewGrnPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await itemsApi.scan(code)
|
const result = await itemsApi.scan(code)
|
||||||
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
||||||
setLines((prev) => {
|
addItemLine(result.itemId, scannedBatchNo)
|
||||||
const existingIdx = prev.findIndex(
|
|
||||||
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
|
|
||||||
)
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
const next = [...prev]
|
|
||||||
const merged: DraftLine = {
|
|
||||||
...next[existingIdx],
|
|
||||||
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
|
||||||
batchNo: scannedBatchNo || next[existingIdx].batchNo,
|
|
||||||
}
|
|
||||||
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
// Fill the first still-blank row (itemId never picked) instead of piling on a new
|
|
||||||
// one below it — the untouched starter row, or one left empty after its item was
|
|
||||||
// cleared, would otherwise sit there forever while every scan adds another line.
|
|
||||||
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
|
||||||
const draft: DraftLine = {
|
|
||||||
...emptyLine(),
|
|
||||||
itemId: result.itemId,
|
|
||||||
qty: "1",
|
|
||||||
batchNo: scannedBatchNo,
|
|
||||||
discountPct: headerDiscountPct.trim() || "0",
|
|
||||||
vatPct: headerVatPct.trim() || "0",
|
|
||||||
}
|
|
||||||
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
|
||||||
if (blankIdx >= 0) {
|
|
||||||
const next = [...prev]
|
|
||||||
next[blankIdx] = { ...filled, key: next[blankIdx].key }
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return [...prev, filled]
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
setScanError(`No item or lot found for "${code}"`)
|
setScanError(`No item or lot found for "${code}"`)
|
||||||
@@ -402,6 +387,45 @@ export default function NewGrnPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by the scan box and the "Add item" search: bumps qty on an existing line for the same
|
||||||
|
// item + batch, else fills the first blank row or appends a new line for it.
|
||||||
|
function addItemLine(itemId: number, batchNo = "") {
|
||||||
|
setLines((prev) => {
|
||||||
|
const existingIdx = prev.findIndex(
|
||||||
|
(l) => l.itemId === itemId && l.batchNo.trim() === batchNo.trim()
|
||||||
|
)
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
const next = [...prev]
|
||||||
|
const merged: DraftLine = {
|
||||||
|
...next[existingIdx],
|
||||||
|
qty: String((Number(next[existingIdx].qty) || 0) + 1),
|
||||||
|
batchNo: batchNo || next[existingIdx].batchNo,
|
||||||
|
}
|
||||||
|
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
// Fill the first still-blank row (itemId never picked) instead of piling on a new
|
||||||
|
// one below it — the untouched starter row, or one left empty after its item was
|
||||||
|
// cleared, would otherwise sit there forever while every scan adds another line.
|
||||||
|
const blankIdx = prev.findIndex((l) => l.itemId === null)
|
||||||
|
const draft: DraftLine = {
|
||||||
|
...emptyLine(),
|
||||||
|
itemId,
|
||||||
|
qty: "1",
|
||||||
|
batchNo,
|
||||||
|
discountPct: headerDiscountPct.trim() || "0",
|
||||||
|
vatPct: headerVatPct.trim() || "0",
|
||||||
|
}
|
||||||
|
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
||||||
|
if (blankIdx >= 0) {
|
||||||
|
const next = [...prev]
|
||||||
|
next[blankIdx] = { ...filled, key: next[blankIdx].key }
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return [...prev, filled]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((l) => {
|
prev.map((l) => {
|
||||||
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
|
|||||||
return items?.find((i) => i.itemId === itemId) ?? null
|
return items?.find((i) => i.itemId === itemId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemOptions = useMemo<SuggestOption[]>(
|
||||||
|
() =>
|
||||||
|
(items ?? []).map((i) => {
|
||||||
|
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||||
|
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
return {
|
||||||
|
value: String(i.itemId),
|
||||||
|
label: `${i.sku} — ${i.name}`,
|
||||||
|
detail: detail || undefined,
|
||||||
|
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
|
||||||
|
badgeTone: "muted",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[items, uoms],
|
||||||
|
)
|
||||||
|
|
||||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((l) => {
|
prev.map((l) => {
|
||||||
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
|
|||||||
itemId: line.itemId,
|
itemId: line.itemId,
|
||||||
qty: line.qty,
|
qty: line.qty,
|
||||||
unitCost: line.unitCost,
|
unitCost: line.unitCost,
|
||||||
discountPct: line.discountPct,
|
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||||
vatPct: line.vatPct,
|
vatPct: allowVat ? line.vatPct : "0",
|
||||||
warranty: item?.warranty ?? null,
|
warranty: item?.warranty ?? null,
|
||||||
warrantyNumbers: line.warrantyNumbers,
|
warrantyNumbers: line.warrantyNumbers,
|
||||||
})
|
})
|
||||||
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
|
|||||||
binId: l.binId,
|
binId: l.binId,
|
||||||
qty: Number(l.qty),
|
qty: Number(l.qty),
|
||||||
unitCost: Number(l.unitCost),
|
unitCost: Number(l.unitCost),
|
||||||
discountPct: Number(l.discountPct) || 0,
|
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||||
vatPct: Number(l.vatPct) || 0,
|
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||||
holdStatus: l.holdStatus,
|
holdStatus: l.holdStatus,
|
||||||
warrantyNumbers:
|
warrantyNumbers:
|
||||||
item?.warranty === "Warranty"
|
item?.warranty === "Warranty"
|
||||||
@@ -690,18 +732,28 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Warehouse</Label>
|
<Label className="text-base">Warehouse</Label>
|
||||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
{allowWarehouseSelection ? (
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||||
<SelectValue placeholder="Select warehouse" />
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select warehouse" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{visibleWarehouses.map((w) => (
|
<SelectContent>
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
{visibleWarehouses.map((w) => (
|
||||||
{w.code} — {w.name}
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
</SelectItem>
|
{w.code} — {w.name}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
|
||||||
|
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||||
|
{(() => {
|
||||||
|
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
|
||||||
|
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -709,37 +761,43 @@ export default function NewGrnPage() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
{(allowDiscounts || allowVat) && (
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||||
<Label className="text-base">Whole-GRN discount %</Label>
|
{allowDiscounts && (
|
||||||
<Input
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
type="number"
|
<Label className="text-base">Whole-GRN discount %</Label>
|
||||||
min="0"
|
<Input
|
||||||
max="100"
|
type="number"
|
||||||
step="any"
|
min="0"
|
||||||
value={headerDiscountPct}
|
max="100"
|
||||||
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
step="any"
|
||||||
placeholder="Applies to every line"
|
value={headerDiscountPct}
|
||||||
className="h-11 text-base"
|
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
||||||
/>
|
placeholder="Applies to every line"
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allowVat && (
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label className="text-base">Whole-GRN VAT %</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="any"
|
||||||
|
value={headerVatPct}
|
||||||
|
onChange={(e) => applyHeaderVatPct(e.target.value)}
|
||||||
|
placeholder="Applies to every line"
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground sm:max-w-64">
|
||||||
|
Setting a rate here applies it to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
)}
|
||||||
<Label className="text-base">Whole-GRN VAT %</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
value={headerVatPct}
|
|
||||||
onChange={(e) => applyHeaderVatPct(e.target.value)}
|
|
||||||
placeholder="Applies to every line"
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground sm:max-w-64">
|
|
||||||
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
|
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
|
||||||
<ScanInput
|
<ScanInput
|
||||||
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
|
|||||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||||
{mode === "po" && (
|
{mode === "po" && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
|
||||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setLines((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
...emptyLine(),
|
|
||||||
discountPct: headerDiscountPct.trim() || "0",
|
|
||||||
vatPct: headerVatPct.trim() || "0",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
Add line
|
|
||||||
</Button>
|
|
||||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||||
|
|
||||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
{!poLoading && lineTab === "lines" && (
|
||||||
<>
|
<>
|
||||||
|
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
|
||||||
|
the last row of the table, under the Item column (same layout as Sales). */}
|
||||||
|
<SuggestInput
|
||||||
|
options={itemOptions}
|
||||||
|
onSelect={(v) => addItemLine(Number(v))}
|
||||||
|
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||||
|
onEnterWithoutMatch={handleGrnScan}
|
||||||
|
browsable
|
||||||
|
keepOpenOnSelect
|
||||||
|
placeholder="Add item — type SKU or name…"
|
||||||
|
emptyText="No items match."
|
||||||
|
className="w-full md:hidden"
|
||||||
|
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||||
|
aria-label="Add item"
|
||||||
|
/>
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
|
||||||
|
No items yet — start typing in "Add item" above, or scan a barcode.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile: stacked editable cards. */}
|
{/* Mobile: stacked editable cards. */}
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
{lines.map((line) => {
|
{lines.map((line) => {
|
||||||
const item = itemFor(line.itemId)
|
const item = itemFor(line.itemId)
|
||||||
const errors = lineErrors[line.key] ?? {}
|
const errors = lineErrors[line.key] ?? {}
|
||||||
const c = computeLine(line)
|
const c = computeLine(effectiveLine(line))
|
||||||
return (
|
return (
|
||||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{item?.warranty === "Warranty" && (
|
{item?.warranty === "Warranty" && (
|
||||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -949,38 +997,44 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
{allowDiscounts && (
|
||||||
<Label className="text-sm">Disc %</Label>
|
<div>
|
||||||
<Input
|
<Label className="text-sm">Disc %</Label>
|
||||||
type="number"
|
<Input
|
||||||
min="0"
|
type="number"
|
||||||
max="100"
|
min="0"
|
||||||
step="any"
|
max="100"
|
||||||
inputMode="decimal"
|
step="any"
|
||||||
value={line.discountPct}
|
inputMode="decimal"
|
||||||
aria-invalid={!!errors.discountPct}
|
value={line.discountPct}
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
aria-invalid={!!errors.discountPct}
|
||||||
disabled={headerDiscountPct.trim() !== ""}
|
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||||
className="h-11 text-sm"
|
disabled={headerDiscountPct.trim() !== ""}
|
||||||
/>
|
className="h-11 text-sm"
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
/>
|
||||||
</div>
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
<div>
|
</div>
|
||||||
<Label className="text-sm">VAT %</Label>
|
|
||||||
<Input
|
)}
|
||||||
type="number"
|
{allowVat && (
|
||||||
min="0"
|
<div>
|
||||||
max="100"
|
<Label className="text-sm">VAT %</Label>
|
||||||
step="any"
|
<Input
|
||||||
inputMode="decimal"
|
type="number"
|
||||||
value={line.vatPct}
|
min="0"
|
||||||
aria-invalid={!!errors.vatPct}
|
max="100"
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
step="any"
|
||||||
disabled={headerVatPct.trim() !== ""}
|
inputMode="decimal"
|
||||||
className="h-11 text-sm"
|
value={line.vatPct}
|
||||||
/>
|
aria-invalid={!!errors.vatPct}
|
||||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||||
</div>
|
disabled={headerVatPct.trim() !== ""}
|
||||||
|
className="h-11 text-sm"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
|||||||
<Table className="text-sm">
|
<Table className="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{item?.warranty === "Warranty" && (
|
{item?.warranty === "Warranty" && (
|
||||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -1131,39 +1173,45 @@ export default function NewGrnPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{allowDiscounts && (
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="any"
|
step="any"
|
||||||
value={line.discountPct}
|
value={line.discountPct}
|
||||||
aria-invalid={!!errors.discountPct}
|
aria-invalid={!!errors.discountPct}
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||||
disabled={headerDiscountPct.trim() !== ""}
|
disabled={headerDiscountPct.trim() !== ""}
|
||||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||||
className="h-11 text-sm"
|
className="h-11 text-sm"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
)}
|
||||||
|
{allowVat && (
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="any"
|
step="any"
|
||||||
value={line.vatPct}
|
value={line.vatPct}
|
||||||
aria-invalid={!!errors.vatPct}
|
aria-invalid={!!errors.vatPct}
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||||
disabled={headerVatPct.trim() !== ""}
|
disabled={headerVatPct.trim() !== ""}
|
||||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||||
className="h-11 text-sm"
|
className="h-11 text-sm"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
{(() => {
|
{(() => {
|
||||||
const c = computeLine(line)
|
const c = computeLine(effectiveLine(line))
|
||||||
return (
|
return (
|
||||||
<div className="flex h-11 flex-col justify-center">
|
<div className="flex h-11 flex-col justify-center">
|
||||||
<span>{c.lineTotal.toFixed(2)}</span>
|
<span>{c.lineTotal.toFixed(2)}</span>
|
||||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<SuggestInput
|
||||||
|
options={itemOptions}
|
||||||
|
onSelect={(v) => addItemLine(Number(v))}
|
||||||
|
onEnterWithoutMatch={handleGrnScan}
|
||||||
|
browsable
|
||||||
|
keepOpenOnSelect
|
||||||
|
placeholder="Search item — SKU or name…"
|
||||||
|
emptyText="No items match."
|
||||||
|
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||||
|
aria-label="Add item"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||||
|
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
|||||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { CompanyProfile } from "@/types/company-profile"
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
@@ -103,7 +103,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
customersApi.list({ pageSize: 200 }),
|
// Includes the cash customer: a cash sale's draft must still show (and keep) it in the picker.
|
||||||
|
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
@@ -232,7 +233,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveLineUnitPrice(line: Line): number | null {
|
function resolveLineUnitPrice(line: Line): number | null {
|
||||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
if (line.unitPrice !== null && line.unitPrice !== undefined) return roundPrice(Number(line.unitPrice))
|
||||||
return getSuggestedUnitPrice(items, line.itemId)
|
return getSuggestedUnitPrice(items, line.itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +387,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
|
<div className="grid gap-6 py-5 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||||
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||||
<div className="text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
|
||||||
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
|
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
|
||||||
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -399,23 +399,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||||
</div>
|
</div>
|
||||||
{layout.arrangement !== "highlight" && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-muted-foreground">Subtotal</div>
|
|
||||||
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
|
||||||
<div className="text-muted-foreground">Discount</div>
|
|
||||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
|
||||||
<div className="text-muted-foreground">Free qty</div>
|
|
||||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
|
||||||
<div className="text-muted-foreground">Tax</div>
|
|
||||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
|
||||||
<div className="text-muted-foreground">Net payable</div>
|
|
||||||
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto border-y">
|
<div className="overflow-x-auto border-y">
|
||||||
@@ -452,6 +435,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Totals sit under the lines, right-aligned — where an invoice's totals are read. The
|
||||||
|
"highlight" layout shows Net payable as a banner up top instead. */}
|
||||||
|
{layout.arrangement !== "highlight" && (
|
||||||
|
<div className="flex justify-end py-5">
|
||||||
|
<div className="w-full max-w-xs">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div>
|
||||||
|
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||||
|
<div className="text-muted-foreground">Tax</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||||
|
<div className="border-t pt-2 text-muted-foreground">Net payable</div>
|
||||||
|
<div className="border-t pt-2 text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||||
<div className="py-5">
|
<div className="py-5">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||||
@@ -610,7 +615,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={line.unitPrice ?? ""}
|
value={line.unitPrice ?? ""}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ import { FieldError } from "@/components/ui/field"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { ApiError } from "@/lib/api-client"
|
import { ApiError } from "@/lib/api-client"
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { bundleApi } from "@/lib/api/bundles"
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -110,6 +111,8 @@ const lkr = new Intl.NumberFormat("en-LK", {
|
|||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const qtyFormat = new Intl.NumberFormat("en-LK", { maximumFractionDigits: 3 })
|
||||||
|
|
||||||
function NewSalesInvoiceContent() {
|
function NewSalesInvoiceContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -160,6 +163,17 @@ function NewSalesInvoiceContent() {
|
|||||||
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
||||||
[customers, salesConfig],
|
[customers, salesConfig],
|
||||||
)
|
)
|
||||||
|
// The dropdown rows carry extra customer details, so pass explicit trigger labels — otherwise
|
||||||
|
// the Select wrapper derives them from the rows and the closed trigger shows the details too.
|
||||||
|
const customerSelectItems = useMemo(() => {
|
||||||
|
const items = regularCustomers.map((c) => ({
|
||||||
|
value: String(c.customerId),
|
||||||
|
label: `${c.customerCode} — ${c.displayName ?? c.name}`,
|
||||||
|
}))
|
||||||
|
if (cashCustomerId !== null)
|
||||||
|
items.unshift({ value: String(cashCustomerId), label: salesConfig?.cashCustomerName ?? "Cash Customer" })
|
||||||
|
return items
|
||||||
|
}, [regularCustomers, cashCustomerId, salesConfig])
|
||||||
|
|
||||||
function selectCustomer(nextCustomerId: number | null) {
|
function selectCustomer(nextCustomerId: number | null) {
|
||||||
setCustomerId(nextCustomerId)
|
setCustomerId(nextCustomerId)
|
||||||
@@ -358,7 +372,7 @@ function NewSalesInvoiceContent() {
|
|||||||
let price = resolvedPrices[key]
|
let price = resolvedPrices[key]
|
||||||
if (price === undefined) {
|
if (price === undefined) {
|
||||||
try {
|
try {
|
||||||
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
|
price = roundPrice((await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice)
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -436,15 +450,78 @@ function NewSalesInvoiceContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issuable stock per item at the sale's warehouse, for the "Add item" dropdown. Reloaded when the
|
||||||
|
// warehouse changes; null until loaded (or with no warehouse), shown as "Stock: —" then. Falls back
|
||||||
|
// to the stock enquiry list (all pages) if the sales stock-summary endpoint isn't reachable.
|
||||||
|
const [stockByItem, setStockByItem] = useState<Map<number, number> | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!warehouseId) return
|
||||||
|
let cancelled = false
|
||||||
|
async function load(whId: number): Promise<Map<number, number>> {
|
||||||
|
try {
|
||||||
|
const rows = await salesApi.getStockSummary(whId)
|
||||||
|
return new Map(rows.map((r) => [r.itemId, r.available]))
|
||||||
|
} catch {
|
||||||
|
// Older backend without stock-summary: try the stock enquiry list (needs the "stock"
|
||||||
|
// permission), else ask the per-item sales endpoint for each stocked item.
|
||||||
|
const map = new Map<number, number>()
|
||||||
|
try {
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const res = await stockApi.onHandList({ warehouseId: whId, page, pageSize: 200 })
|
||||||
|
res.items.forEach((r) => map.set(r.itemId, r.available))
|
||||||
|
if (res.items.length < 200) break
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
} catch {
|
||||||
|
const queue = items.filter((i) => i.stockNature === "Stocked").map((i) => i.itemId)
|
||||||
|
const worker = async () => {
|
||||||
|
for (let id = queue.shift(); id !== undefined && !cancelled; id = queue.shift()) {
|
||||||
|
const a = await salesApi.getStockAvailability(id, whId).catch(() => null)
|
||||||
|
if (a) map.set(id, a.available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: 6 }, worker))
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load(warehouseId)
|
||||||
|
.then((map) => !cancelled && setStockByItem(map))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
setStockByItem(null)
|
||||||
|
}
|
||||||
|
}, [warehouseId, items])
|
||||||
|
|
||||||
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
|
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
|
||||||
// template values "bundle:" so one handler can tell them apart on select.
|
// template values "bundle:" so one handler can tell them apart on select. Each item row carries a
|
||||||
|
// second line with its available stock and key details (UOM, batch/serial tracking, warranty, tax).
|
||||||
const itemOptions = useMemo(
|
const itemOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.map((i) => {
|
items.map((i) => {
|
||||||
const price = getSuggestedUnitPrice(items, i.itemId)
|
const price = getSuggestedUnitPrice(items, i.itemId)
|
||||||
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
|
const stocked = i.stockNature === "Stocked"
|
||||||
|
const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
|
||||||
|
const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name
|
||||||
|
const detail = [
|
||||||
|
uom,
|
||||||
|
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
|
||||||
|
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
|
||||||
|
i.taxRateName ? `Tax: ${i.taxRateName}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
return {
|
||||||
|
value: `item:${i.itemId}`,
|
||||||
|
label: `${i.sku} — ${i.name}`,
|
||||||
|
hint: price !== null ? lkr.format(price) : undefined,
|
||||||
|
detail: detail || undefined,
|
||||||
|
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
|
||||||
|
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[items],
|
[items, uoms, stockByItem],
|
||||||
)
|
)
|
||||||
|
|
||||||
const bundleTemplateOptions = useMemo(
|
const bundleTemplateOptions = useMemo(
|
||||||
@@ -612,7 +689,7 @@ function NewSalesInvoiceContent() {
|
|||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
|
||||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
unitPrice: line.unitPrice === null ? null : roundPrice(Number(line.unitPrice)),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
|
||||||
@@ -661,7 +738,11 @@ function NewSalesInvoiceContent() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Customer</Label>
|
<Label className="text-base">Customer</Label>
|
||||||
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => selectCustomer(v ? Number(v) : null)}>
|
<Select
|
||||||
|
value={customerId ? String(customerId) : ""}
|
||||||
|
onValueChange={(v) => selectCustomer(v ? Number(v) : null)}
|
||||||
|
items={customerSelectItems}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="Select customer" />
|
<SelectValue placeholder="Select customer" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -671,11 +752,17 @@ function NewSalesInvoiceContent() {
|
|||||||
{salesConfig?.cashCustomerName ?? "Cash Customer"}
|
{salesConfig?.cashCustomerName ?? "Cash Customer"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
{regularCustomers.map((c) => (
|
{regularCustomers.map((c) => {
|
||||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
const details = [c.customerType, c.phone, c.email, c.city].filter(Boolean).join(" · ")
|
||||||
{c.customerCode} — {c.displayName ?? c.name}
|
return (
|
||||||
</SelectItem>
|
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||||
))}
|
<div className="flex flex-col">
|
||||||
|
<span>{c.customerCode} — {c.displayName ?? c.name}</span>
|
||||||
|
{details && <span className="text-sm text-muted-foreground">{details}</span>}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,10 +1014,10 @@ function NewSalesInvoiceContent() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="0.01"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={line.unitPrice ?? ""}
|
value={line.unitPrice ?? ""}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||||
{...navProps(line.key, "price")}
|
{...navProps(line.key, "price")}
|
||||||
className="h-11 text-sm tabular-nums"
|
className="h-11 text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
@@ -1085,9 +1172,9 @@ function NewSalesInvoiceContent() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="0.01"
|
||||||
value={line.unitPrice ?? ""}
|
value={line.unitPrice ?? ""}
|
||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||||
{...navProps(line.key, "price")}
|
{...navProps(line.key, "price")}
|
||||||
className="h-11 text-right text-sm tabular-nums"
|
className="h-11 text-right text-sm tabular-nums"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,16 +2,32 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import {
|
||||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
AlertTriangle,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
CircleDollarSign,
|
||||||
|
FileText,
|
||||||
|
ListFilter,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Printer,
|
||||||
|
Receipt,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { FilterBar } from "@/components/ui/filter-bar"
|
import { FilterBar } from "@/components/ui/filter-bar"
|
||||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Field, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -21,6 +37,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
@@ -30,6 +47,22 @@ import { Warehouse } from "@/types/master-data"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type StatusFilter = SalesInvoiceStatus | "All"
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
|
||||||
|
type SortOrder = "asc" | "desc"
|
||||||
|
|
||||||
|
/** Same clickable sort header as the Items table. */
|
||||||
|
function SortableHeader({ label, active, order, onClick, alignRight }: { label: string; active: boolean; order: SortOrder; onClick: () => void; alignRight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={cn("flex items-center gap-1 hover:text-foreground", alignRight && "ml-auto")} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
{active ? (
|
||||||
|
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
@@ -69,8 +102,23 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
|||||||
{ value: "Cancelled", label: "Cancelled" },
|
{ value: "Cancelled", label: "Cancelled" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Past its due date (the "Due" column, `invoiceDate`) with money still owed — Draft and Cancelled
|
||||||
|
* invoices are never overdue. Compared by calendar day, so an invoice due today is not overdue. */
|
||||||
|
function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||||
|
if (row.status === "Draft" || row.status === "Cancelled" || row.totals.balanceAmount <= 0) return false
|
||||||
|
const due = new Date(row.invoiceDate)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
due.setHours(0, 0, 0, 0)
|
||||||
|
return due < today
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money still owed on a live invoice — shown in red. A cancelled invoice owes nothing. */
|
||||||
|
function hasRemaining(row: SalesInvoiceSummary): boolean {
|
||||||
|
return row.status !== "Cancelled" && row.totals.balanceAmount > 0
|
||||||
|
}
|
||||||
|
|
||||||
export default function SalesInvoicesPage() {
|
export default function SalesInvoicesPage() {
|
||||||
const router = useRouter()
|
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
const stats = useDashboardStats()
|
const stats = useDashboardStats()
|
||||||
|
|
||||||
@@ -89,9 +137,23 @@ export default function SalesInvoicesPage() {
|
|||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||||
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
|
// The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through
|
||||||
|
// `rows` so its buttons follow payments/confirms; viewRefresh reloads the sheet after those.
|
||||||
|
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||||
|
const [viewRefresh, setViewRefresh] = useState(0)
|
||||||
|
const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||||
|
|
||||||
|
// Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash
|
||||||
|
// customer's invoices can't be ticked — they're paid one at a time.
|
||||||
|
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||||
|
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||||
|
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||||
|
// While invoices are ticked the list is narrowed to their customer (overriding the Customer
|
||||||
|
// filter); clearing the selection falls back to whatever filter was chosen.
|
||||||
|
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||||
|
const listCustomerId = selectedCustomerId ?? customerId
|
||||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
@@ -104,11 +166,13 @@ export default function SalesInvoicesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setPageSize(rowsPerPage)
|
setPageSize(rowsPerPage)
|
||||||
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
|
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||||
|
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||||
|
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,7 +183,7 @@ export default function SalesInvoicesPage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
status: status === "All" ? undefined : status,
|
status: status === "All" ? undefined : status,
|
||||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
customerId,
|
customerId: listCustomerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
...dateRangeBounds(dateRange),
|
...dateRangeBounds(dateRange),
|
||||||
})
|
})
|
||||||
@@ -129,7 +193,7 @@ export default function SalesInvoicesPage() {
|
|||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
.finally(() => setShowMoreLoading(false))
|
||||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
|
||||||
|
|
||||||
function handleShowMore() {
|
function handleShowMore() {
|
||||||
setShowMoreLoading(true)
|
setShowMoreLoading(true)
|
||||||
@@ -142,6 +206,7 @@ export default function SalesInvoicesPage() {
|
|||||||
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
||||||
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
||||||
toast.success("Invoice confirmed", posted.invoiceNo)
|
toast.success("Invoice confirmed", posted.invoiceNo)
|
||||||
|
setViewRefresh((n) => n + 1)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not confirm invoice", errorMessage(err))
|
toast.error("Could not confirm invoice", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -150,6 +215,7 @@ export default function SalesInvoicesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
|
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
|
||||||
|
setViewRefresh((n) => n + 1)
|
||||||
setRows(
|
setRows(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
prev?.map((r) =>
|
prev?.map((r) =>
|
||||||
@@ -162,6 +228,77 @@ export default function SalesInvoicesPage() {
|
|||||||
: r
|
: r
|
||||||
) ?? prev
|
) ?? prev
|
||||||
)
|
)
|
||||||
|
// Keep a ticked invoice's balance current, and untick it once nothing is left to pay.
|
||||||
|
setSelected((prev) => {
|
||||||
|
const sel = prev.get(payment.salesInvoiceId)
|
||||||
|
if (!sel) return prev
|
||||||
|
const next = new Map(prev)
|
||||||
|
const balanceAmount = sel.totals.balanceAmount - payment.amount
|
||||||
|
if (balanceAmount <= 0) next.delete(payment.salesInvoiceId)
|
||||||
|
else next.set(payment.salesInvoiceId, { ...sel, totals: { ...sel.totals, paidAmount: sel.totals.paidAmount + payment.amount, balanceAmount } })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBulkConfirmed(salesInvoiceId: number) {
|
||||||
|
const asPosted = (r: SalesInvoiceSummary) => (r.salesInvoiceId === salesInvoiceId ? { ...r, status: "Posted" as const } : r)
|
||||||
|
setRows((prev) => prev?.map(asPosted) ?? prev)
|
||||||
|
setSelected((prev) => {
|
||||||
|
const sel = prev.get(salesInvoiceId)
|
||||||
|
return sel ? new Map(prev).set(salesInvoiceId, asPosted(sel)) : prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBulkPaid(payments: SalesInvoicePayment[]) {
|
||||||
|
payments.forEach((p) => handlePaid(p, "Posted"))
|
||||||
|
// Partly-paid ones would stay ticked via handlePaid; a finished payment clears the whole selection.
|
||||||
|
setSelected(new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||||
|
|
||||||
|
/** Null when the row can be ticked, else why not (shown as the checkbox's tooltip). */
|
||||||
|
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||||
|
if (selected.has(row.salesInvoiceId)) return null
|
||||||
|
if (row.status === "Cancelled") return "Cancelled invoices can't be paid"
|
||||||
|
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||||
|
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||||
|
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(row: SalesInvoiceSummary) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||||
|
else next.set(row.salesInvoiceId, row)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||||
|
const reason = notSelectableReason(row)
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(row.salesInvoiceId)}
|
||||||
|
disabled={reason !== null}
|
||||||
|
title={reason ?? "Select to pay together"}
|
||||||
|
aria-label={`Select ${row.invoiceNo}`}
|
||||||
|
onCheckedChange={() => toggleSelected(row)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
|
||||||
|
// server's order.
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||||
|
else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortOrder("asc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
@@ -171,14 +308,43 @@ export default function SalesInvoicesPage() {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
[rows, query]
|
[rows, query]
|
||||||
)
|
)
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
if (!sortKey) return visibleRows
|
||||||
|
const value = (r: SalesInvoiceSummary): string | number => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case "invoiceNo": return r.invoiceNo
|
||||||
|
case "customer": return r.customerSnapshotName
|
||||||
|
case "date": return new Date(r.createdAt).getTime()
|
||||||
|
case "due": return new Date(r.invoiceDate).getTime()
|
||||||
|
case "net": return r.totals.grandTotal
|
||||||
|
case "paid": return r.totals.paidAmount
|
||||||
|
case "remaining": return r.status === "Cancelled" ? 0 : r.totals.balanceAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...visibleRows].sort((a, b) => {
|
||||||
|
const va = value(a), vb = value(b)
|
||||||
|
const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb))
|
||||||
|
return sortOrder === "asc" ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}, [visibleRows, sortKey, sortOrder])
|
||||||
|
|
||||||
|
// Header "select all": ticks every tickable row on screen, but only when they're all one customer
|
||||||
|
// (e.g. once a selection has narrowed the list) — invoices of different customers never mix.
|
||||||
|
const tickable = sortedRows.filter((r) => notSelectableReason(r) === null)
|
||||||
|
const canSelectAll = tickable.length > 0 && new Set(tickable.map((r) => r.customerId)).size === 1
|
||||||
|
const allTicked = tickable.length > 0 && tickable.every((r) => selected.has(r.salesInvoiceId))
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
const printRange = dateRangeBounds(dateRange)
|
|
||||||
const printHref =
|
|
||||||
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
|
|
||||||
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -188,15 +354,6 @@ export default function SalesInvoicesPage() {
|
|||||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||||
<Link
|
|
||||||
href={printHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
|
|
||||||
>
|
|
||||||
<Printer className="size-5" />
|
|
||||||
Print batch
|
|
||||||
</Link>
|
|
||||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
New Sale
|
New Sale
|
||||||
@@ -235,7 +392,11 @@ export default function SalesInvoicesPage() {
|
|||||||
<>
|
<>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
|
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
|
||||||
<Select value={customerId ? String(customerId) : "All"} onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}>
|
<Select
|
||||||
|
value={listCustomerId ? String(listCustomerId) : "All"}
|
||||||
|
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
|
||||||
|
disabled={selectedCustomerId !== null}
|
||||||
|
>
|
||||||
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="All">All customers</SelectItem>
|
<SelectItem value="All">All customers</SelectItem>
|
||||||
@@ -291,6 +452,28 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 border-b bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||||
|
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||||
|
<span className="font-semibold tabular-nums">{formatAmount(selectedBalance)}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
Showing only this customer's invoices — clear the selection to see all.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
|
||||||
|
<CircleDollarSign className="size-4" />
|
||||||
|
Pay selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
{!error && rows === null && (
|
{!error && rows === null && (
|
||||||
@@ -319,17 +502,22 @@ export default function SalesInvoicesPage() {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
setViewInvoice(row)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
if (e.key === "Enter") setViewInvoice(row)
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<span className="pt-1" data-no-row-nav>
|
||||||
|
{renderSelectBox(row)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
||||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
<SalesInvoiceStatusBadge status={row.status} />
|
<SalesInvoiceStatusBadge status={row.status} />
|
||||||
@@ -343,13 +531,12 @@ export default function SalesInvoicesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Date / Due</p>
|
<p className="text-muted-foreground">Date / Due</p>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
|
{new Date(row.createdAt).toLocaleDateString()} /{" "}
|
||||||
|
<span className={cn(isOverdue(row) && "text-destructive")}>
|
||||||
|
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">Lines</p>
|
|
||||||
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Gross / Discount</p>
|
<p className="text-muted-foreground">Gross / Discount</p>
|
||||||
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
|
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
|
||||||
@@ -358,36 +545,54 @@ export default function SalesInvoicesPage() {
|
|||||||
<p className="text-muted-foreground">Net</p>
|
<p className="text-muted-foreground">Net</p>
|
||||||
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Paid / Remaining</p>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{row.totals.paidAmount.toFixed(2)} /{" "}
|
||||||
|
<span className={cn(hasRemaining(row) && "font-semibold text-destructive")}>
|
||||||
|
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewInvoice(row)}
|
||||||
|
aria-label={`Print ${row.invoiceNo}`}
|
||||||
|
title="Print"
|
||||||
|
>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
Print
|
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
disabled={confirmingId === row.salesInvoiceId}
|
disabled={confirmingId === row.salesInvoiceId}
|
||||||
onClick={() => handleConfirm(row)}
|
onClick={() => handleConfirm(row)}
|
||||||
|
aria-label={`Confirm ${row.invoiceNo}`}
|
||||||
|
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-4" />
|
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPayDialogInvoice(row)
|
setPayDialogInvoice(row)
|
||||||
setPayDialogOpen(true)
|
setPayDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Pay ${row.invoiceNo}`}
|
||||||
|
title="Pay"
|
||||||
>
|
>
|
||||||
<CircleDollarSign className="size-4" />
|
<CircleDollarSign className="size-4" />
|
||||||
Pay
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,45 +607,83 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: table. */}
|
{/* Desktop: table. */}
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
|
<TableHead className="h-12 w-10 px-3">
|
||||||
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
|
<Checkbox
|
||||||
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
checked={canSelectAll && allTicked}
|
||||||
<TableHead className="h-12 px-4 text-sm">Due</TableHead>
|
disabled={!canSelectAll}
|
||||||
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
onCheckedChange={(c) => toggleAll(c === true)}
|
||||||
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
|
aria-label="Select all"
|
||||||
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
|
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
|
||||||
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
|
/>
|
||||||
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Invoice" active={sortKey === "invoiceNo"} order={sortOrder} onClick={() => toggleSort("invoiceNo")} /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Customer" active={sortKey === "customer"} order={sortOrder} onClick={() => toggleSort("customer")} /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Net" active={sortKey === "net"} order={sortOrder} onClick={() => toggleSort("net")} alignRight /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Paid" active={sortKey === "paid"} order={sortOrder} onClick={() => toggleSort("paid")} alignRight /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Remaining" active={sortKey === "remaining"} order={sortOrder} onClick={() => toggleSort("remaining")} alignRight /></TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleRows.map((row) => (
|
{sortedRows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.salesInvoiceId}
|
key={row.salesInvoiceId}
|
||||||
className="cursor-pointer hover:bg-muted/40"
|
className="cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
setViewInvoice(row)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableCell className="px-4 py-3.5 font-medium">
|
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||||
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
{renderSelectBox(row)}
|
||||||
{row.invoiceNo}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">
|
||||||
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
<div className="flex items-center gap-3">
|
||||||
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
<Receipt className="size-4 text-primary" />
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
</div>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
|
<div>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<Link
|
||||||
<TableCell className="px-4 py-3.5">
|
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||||
|
className="hover:underline"
|
||||||
|
data-no-row-nav
|
||||||
|
onClick={(e) => {
|
||||||
|
// Plain click opens the pop-up; Ctrl/⌘-click still opens the full page in a new tab.
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
setViewInvoice(row)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.invoiceNo}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs font-normal text-muted-foreground">{row.invoiceType}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||||
|
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||||
|
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Gross {row.totals.subtotal.toFixed(2)}
|
||||||
|
{row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className={cn("px-3 py-3.5 text-right tabular-nums", hasRemaining(row) && "font-semibold text-destructive")}>
|
||||||
|
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<SalesInvoiceStatusBadge status={row.status} />
|
<SalesInvoiceStatusBadge status={row.status} />
|
||||||
{row.status !== "Draft" && (
|
{row.status !== "Draft" && (
|
||||||
@@ -448,13 +691,13 @@ export default function SalesInvoicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setPrintInvoice(row)}
|
onClick={() => setViewInvoice(row)}
|
||||||
aria-label={`Print ${row.invoiceNo}`}
|
aria-label={`Print ${row.invoiceNo}`}
|
||||||
title="Print"
|
title="Print"
|
||||||
>
|
>
|
||||||
@@ -463,26 +706,29 @@ export default function SalesInvoicesPage() {
|
|||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
disabled={confirmingId === row.salesInvoiceId}
|
disabled={confirmingId === row.salesInvoiceId}
|
||||||
onClick={() => handleConfirm(row)}
|
onClick={() => handleConfirm(row)}
|
||||||
|
aria-label={`Confirm ${row.invoiceNo}`}
|
||||||
|
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-4" />
|
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPayDialogInvoice(row)
|
setPayDialogInvoice(row)
|
||||||
setPayDialogOpen(true)
|
setPayDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Pay ${row.invoiceNo}`}
|
||||||
|
title="Pay"
|
||||||
>
|
>
|
||||||
<CircleDollarSign className="size-4" />
|
<CircleDollarSign className="size-4" />
|
||||||
Pay
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -494,7 +740,7 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div className="hidden border-t px-4 py-4 md:block">
|
<div className="hidden px-4 pb-4 md:block">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
@@ -508,11 +754,53 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
|
<SalesInvoiceBulkPaymentDialog
|
||||||
|
invoices={[...selected.values()]}
|
||||||
|
open={bulkPayOpen}
|
||||||
|
onOpenChange={setBulkPayOpen}
|
||||||
|
onConfirmed={handleBulkConfirmed}
|
||||||
|
onPaid={handleBulkPaid}
|
||||||
|
/>
|
||||||
<SalesInvoicePrintDialog
|
<SalesInvoicePrintDialog
|
||||||
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||||
invoiceNo={printInvoice?.invoiceNo}
|
invoiceNo={viewRow?.invoiceNo}
|
||||||
open={printInvoice !== null}
|
open={viewRow !== null}
|
||||||
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||||
|
refreshKey={viewRefresh}
|
||||||
|
actions={
|
||||||
|
viewRow && (
|
||||||
|
<>
|
||||||
|
{viewRow.status === "Draft" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={confirmingId === viewRow.salesInvoiceId}
|
||||||
|
onClick={() => handleConfirm(viewRow)}
|
||||||
|
aria-label={`Confirm ${viewRow.invoiceNo}`}
|
||||||
|
title="Confirm"
|
||||||
|
>
|
||||||
|
{confirmingId === viewRow.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setPayDialogInvoice(viewRow)
|
||||||
|
setPayDialogOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||||
|
title="Pay"
|
||||||
|
>
|
||||||
|
<CircleDollarSign className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function SalesReturnsListPage() {
|
|||||||
|
|
||||||
useEffect(load, [page, pageSize, query])
|
useEffect(load, [page, pageSize, query])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
Promise.all([customersApi.list({ pageSize: 200, includeCashCustomer: true }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||||
.then(([c, w, rc]) => {
|
.then(([c, w, rc]) => {
|
||||||
setCustomers(c.items)
|
setCustomers(c.items)
|
||||||
setWarehouses(w.items)
|
setWarehouses(w.items)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
import { Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
@@ -26,6 +26,12 @@ const cards = [
|
|||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
description: "Allow cash customer sales or require picking a customer",
|
description: "Allow cash customer sales or require picking a customer",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "GRN Configuration",
|
||||||
|
href: "/dashboard/settings/grn-configuration",
|
||||||
|
icon: PackageCheck,
|
||||||
|
description: "Warehouse selection and the default receiving warehouse",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Invoice Template",
|
title: "Invoice Template",
|
||||||
href: "/dashboard/settings/invoice-template",
|
href: "/dashboard/settings/invoice-template",
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Settings } from "lucide-react"
|
||||||
|
|
||||||
|
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
import { GrnConfig, UpdateGrnConfigRequest } from "@/types/grn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GRN Configuration — singleton goods-receipt options (same pattern as Sales Configuration).
|
||||||
|
* "Allow warehouse selection" + default receiving warehouse: a saved default pins New GRN to that
|
||||||
|
* one warehouse (server-forced too); with none saved, the picker shows unless selection is off.
|
||||||
|
* "Allow discounts" / "Allow VAT": off hides those columns (and whole-GRN rates) on New GRN and
|
||||||
|
* receives every line at 0% — the server rejects anything else.
|
||||||
|
*/
|
||||||
|
export default function GrnConfigurationPage() {
|
||||||
|
const [config, setConfig] = useState<GrnConfig | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setError(null)
|
||||||
|
grnConfigApi
|
||||||
|
.get()
|
||||||
|
.then((res) => {
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const savedDefault = warehouses.find((w) => w.warehouseId === config?.defaultWarehouseId)
|
||||||
|
const savedDefaultInList = savedDefault !== undefined
|
||||||
|
const savedDefaultLabel = savedDefault
|
||||||
|
? `${savedDefault.code} — ${savedDefault.name}`
|
||||||
|
: (config?.defaultWarehouseName ?? `Warehouse #${config?.defaultWarehouseId}`)
|
||||||
|
|
||||||
|
// PUT is a full replacement — send every flag, overriding just the one changed.
|
||||||
|
async function saveFlag(patch: Partial<UpdateGrnConfigRequest>, message: string) {
|
||||||
|
if (!config) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await grnConfigApi.update(
|
||||||
|
{
|
||||||
|
allowWarehouseSelection: config.allowWarehouseSelection,
|
||||||
|
defaultWarehouseId: config.defaultWarehouseId,
|
||||||
|
allowDiscounts: config.allowDiscounts,
|
||||||
|
allowVat: config.allowVat,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
etag ?? "",
|
||||||
|
)
|
||||||
|
setConfig(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
toast.success("Configuration saved", message)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save configuration", errorMessage(err))
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">GRN Configuration</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Choose how goods are received in this deployment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||||
|
|
||||||
|
{!error && config && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Warehouse</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowWarehouseSelection}
|
||||||
|
onCheckedChange={(next) => {
|
||||||
|
// Turning selection off needs somewhere for receipts to go — the server rejects it too.
|
||||||
|
if (!next && !config.defaultWarehouseId) {
|
||||||
|
toast.error("Pick a default warehouse first", "Choose the default receiving warehouse below, then turn this off.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveFlag({ allowWarehouseSelection: next }, `Warehouse selection ${next ? "enabled" : "disabled"}.`)
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow warehouse selection"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow warehouse selection</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: with no default warehouse saved below, New GRN shows a Warehouse picker.
|
||||||
|
Off: a default warehouse must be saved. Whenever a default is saved, New GRN
|
||||||
|
shows only that warehouse.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
|
||||||
|
<span className="text-base font-medium text-foreground">Default receiving warehouse</span>
|
||||||
|
<Select
|
||||||
|
value={config.defaultWarehouseId ? String(config.defaultWarehouseId) : ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return
|
||||||
|
const picked = warehouses.find((w) => w.warehouseId === Number(v))
|
||||||
|
saveFlag({ defaultWarehouseId: Number(v) }, `Default receiving warehouse: ${picked?.name ?? v}.`)
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder={config.defaultWarehouseName ?? "Select warehouse"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
|
||||||
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* The warehouse list can be branch-scoped — keep the saved default selectable
|
||||||
|
(and labelled in the trigger) even when it isn't in this user's list. */}
|
||||||
|
{config.defaultWarehouseId && !savedDefaultInList && (
|
||||||
|
<SelectItem value={String(config.defaultWarehouseId)} className="text-base">
|
||||||
|
{config.defaultWarehouseName ?? `Warehouse #${config.defaultWarehouseId}`}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.defaultWarehouseId ? (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
New GRN shows only: <span className="font-medium">{savedDefaultLabel}</span>
|
||||||
|
</span>
|
||||||
|
{/* Clearing brings the picker back — only possible while selection is allowed. */}
|
||||||
|
{config.allowWarehouseSelection && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => saveFlag({ defaultWarehouseId: null }, "Default receiving warehouse cleared.")}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Not set — New GRN lets the user pick a warehouse. Required to turn warehouse
|
||||||
|
selection off.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<Settings className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowDiscounts}
|
||||||
|
onCheckedChange={(next) => saveFlag({ allowDiscounts: next }, `Discounts ${next ? "allowed" : "not allowed"}.`)}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow discounts"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow discounts</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: GRN lines show a Disc % column, plus a whole-GRN discount % that applies to
|
||||||
|
every line. Off: both are hidden and goods are received without discounts.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 py-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.allowVat}
|
||||||
|
onCheckedChange={(next) => saveFlag({ allowVat: next }, `VAT ${next ? "allowed" : "not allowed"}.`)}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label="Allow VAT"
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-medium text-foreground">Allow VAT</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
On: GRN lines show a VAT % column (pre-filled from the item's tax rate), plus a
|
||||||
|
whole-GRN VAT %. Off: both are hidden and every line is received at 0% VAT, even
|
||||||
|
for items that have a tax rate.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.updatedAt && (
|
||||||
|
<p className="pt-2 text-sm text-muted-foreground">
|
||||||
|
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||||
|
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
|||||||
<div><span className="text-muted-foreground">Grand total: </span><span className={layout.totalsHighlight}>{formatAmount(invoice.totals.grandTotal)}</span></div>
|
<div><span className="text-muted-foreground">Grand total: </span><span className={layout.totalsHighlight}>{formatAmount(invoice.totals.grandTotal)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`grid ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"} ${layout.infoGrid}`}>
|
<div className={`grid md:grid-cols-2 ${layout.infoGrid}`}>
|
||||||
<div className={layout.card}>
|
<div className={layout.card}>
|
||||||
<div className={layout.cardLabel}>Vendor</div>
|
<div className={layout.cardLabel}>Vendor</div>
|
||||||
<div className={layout.cardValue}>{invoice.vendorSnapshotName}</div>
|
<div className={layout.cardValue}>{invoice.vendorSnapshotName}</div>
|
||||||
@@ -111,16 +111,6 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
|||||||
<div className="mt-2 text-sm text-muted-foreground">Vendor invoice date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
|
<div className="mt-2 text-sm text-muted-foreground">Vendor invoice date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
<div className="mt-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
{layout.arrangement !== "highlight" && (
|
|
||||||
<div className={layout.card}>
|
|
||||||
<div className={layout.cardLabel}>Totals</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
|
||||||
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
|
||||||
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -149,6 +139,18 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||||
|
{layout.arrangement !== "highlight" && (
|
||||||
|
<div className={`ml-auto mt-4 w-full max-w-xs ${layout.card}`}>
|
||||||
|
<div className={layout.cardLabel}>Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||||
|
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<InvoicePrintFooter company={company} template={template} />
|
<InvoicePrintFooter company={company} template={template} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
customersApi.list({ pageSize: 200 }),
|
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
uomsApi.list({ pageSize: 200 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
@@ -125,7 +125,6 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
|||||||
<div className={layout.card}>
|
<div className={layout.card}>
|
||||||
<div className={layout.cardLabel}>Customer</div>
|
<div className={layout.cardLabel}>Customer</div>
|
||||||
<div className={layout.cardValue}>{invoice.customerSnapshotName}</div>
|
<div className={layout.cardValue}>{invoice.customerSnapshotName}</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
|
||||||
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { ItemReorderSetting, StockNature } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
type Row = { key: number; warehouseId: string; reorderPoint: string; reorderQty: string }
|
||||||
|
|
||||||
|
const toRows = (settings: ItemReorderSetting[]): Row[] =>
|
||||||
|
settings.map((s, i) => ({ key: i, warehouseId: String(s.warehouseId), reorderPoint: String(s.reorderPoint), reorderQty: String(s.reorderQty) }))
|
||||||
|
|
||||||
|
interface ItemReorderPanelProps {
|
||||||
|
itemId: number
|
||||||
|
stockNature: StockNature
|
||||||
|
/** The item's saved rules — the panel edits a copy and calls `onSaved` with the result. */
|
||||||
|
settings: ItemReorderSetting[]
|
||||||
|
warehouses: { warehouseId: number; code: string; name: string }[]
|
||||||
|
uomName: string
|
||||||
|
onSaved: (settings: ItemReorderSetting[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-warehouse reorder rules (FR-MD-05): when available stock at a warehouse drops to or below
|
||||||
|
* its reorder point, the item shows on Stock → Reorder Alerts with the reorder qty as the
|
||||||
|
* suggested requisition. A warehouse without a row is never checked. Saving replaces the whole
|
||||||
|
* set (PUT /items/{id}/reorder), so removing a row and saving deletes that rule.
|
||||||
|
*/
|
||||||
|
export function ItemReorderPanel({ itemId, stockNature, settings, warehouses, uomName, onSaved }: ItemReorderPanelProps) {
|
||||||
|
const [rows, setRows] = useState<Row[]>(() => toRows(settings))
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const savedKey = JSON.stringify(settings.map((s) => [s.warehouseId, s.reorderPoint, s.reorderQty]).sort())
|
||||||
|
const currentKey = JSON.stringify(rows.map((r) => [Number(r.warehouseId), Number(r.reorderPoint), Number(r.reorderQty)]).sort())
|
||||||
|
const dirty = savedKey !== currentKey
|
||||||
|
const usedWarehouses = new Set(rows.map((r) => r.warehouseId))
|
||||||
|
|
||||||
|
function update(key: number, patch: Partial<Row>) {
|
||||||
|
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
// Start on the first warehouse that doesn't have a rule yet.
|
||||||
|
const free = warehouses.find((w) => !usedWarehouses.has(String(w.warehouseId)))
|
||||||
|
const key = Math.max(-1, ...rows.map((r) => r.key)) + 1
|
||||||
|
setRows((prev) => [...prev, { key, warehouseId: free ? String(free.warehouseId) : "", reorderPoint: "", reorderQty: "" }])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const next: Record<string, string> = {}
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.warehouseId) next[`wh-${r.key}`] = "Select a warehouse"
|
||||||
|
else if (seen.has(r.warehouseId)) next[`wh-${r.key}`] = "This warehouse already has a rule"
|
||||||
|
seen.add(r.warehouseId)
|
||||||
|
const point = Number(r.reorderPoint)
|
||||||
|
if (r.reorderPoint === "" || Number.isNaN(point) || point < 0) next[`pt-${r.key}`] = "0 or more"
|
||||||
|
const qty = Number(r.reorderQty)
|
||||||
|
if (r.reorderQty === "" || Number.isNaN(qty) || qty <= 0) next[`qty-${r.key}`] = "More than 0"
|
||||||
|
}
|
||||||
|
setErrors(next)
|
||||||
|
if (Object.keys(next).length > 0) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await itemsApi.updateReorder(itemId, {
|
||||||
|
settings: rows.map((r) => ({ warehouseId: Number(r.warehouseId), reorderPoint: Number(r.reorderPoint), reorderQty: Number(r.reorderQty) })),
|
||||||
|
})
|
||||||
|
setRows(toRows(res.settings))
|
||||||
|
onSaved(res.settings)
|
||||||
|
toast.success("Reorder settings saved", res.settings.length === 0 ? "No warehouse is watched for this item now." : `${res.settings.length} warehouse rule${res.settings.length === 1 ? "" : "s"}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save reorder settings", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="size-4.5 text-warning" />
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When available stock at a warehouse falls to or below its reorder point, this item appears on Stock → Reorder Alerts
|
||||||
|
with the reorder qty as the suggested requisition. Warehouses without a rule aren't watched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stockNature !== "Stocked" && (
|
||||||
|
<p className="rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||||
|
This is a {stockNature === "Service" ? "service" : "non-stocked"} item — it never holds stock, so reorder rules won't trigger.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed py-6 text-center text-sm text-muted-foreground">No reorder rules yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="hidden grid-cols-[1fr_9rem_9rem_2.25rem] gap-2 text-sm font-medium sm:grid">
|
||||||
|
<span>Warehouse</span>
|
||||||
|
<span>Reorder point ({uomName})</span>
|
||||||
|
<span>Reorder qty ({uomName})</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{rows.map((r, idx) => (
|
||||||
|
<div key={r.key} className="grid grid-cols-1 items-start gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_9rem_9rem_2.25rem] sm:rounded-none sm:border-0 sm:p-0">
|
||||||
|
<div>
|
||||||
|
<Select<string>
|
||||||
|
value={r.warehouseId}
|
||||||
|
onValueChange={(v) => update(r.key, { warehouseId: v ?? "" })}
|
||||||
|
items={warehouses.map((w) => ({ value: String(w.warehouseId), label: `${w.code} — ${w.name}` }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" aria-label={`Warehouse, rule ${idx + 1}`} aria-invalid={!!errors[`wh-${r.key}`]}>
|
||||||
|
<SelectValue placeholder="Select warehouse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} disabled={usedWarehouses.has(String(w.warehouseId)) && r.warehouseId !== String(w.warehouseId)}>
|
||||||
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors[`wh-${r.key}`] ? { message: errors[`wh-${r.key}`] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
placeholder="Reorder point"
|
||||||
|
value={r.reorderPoint}
|
||||||
|
onChange={(e) => update(r.key, { reorderPoint: e.target.value })}
|
||||||
|
aria-label={`Reorder point, rule ${idx + 1}`}
|
||||||
|
aria-invalid={!!errors[`pt-${r.key}`]}
|
||||||
|
className="tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors[`pt-${r.key}`] ? { message: errors[`pt-${r.key}`] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
placeholder="Reorder qty"
|
||||||
|
value={r.reorderQty}
|
||||||
|
onChange={(e) => update(r.key, { reorderQty: e.target.value })}
|
||||||
|
aria-label={`Reorder qty, rule ${idx + 1}`}
|
||||||
|
aria-invalid={!!errors[`qty-${r.key}`]}
|
||||||
|
className="tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors[`qty-${r.key}`] ? { message: errors[`qty-${r.key}`] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setRows((prev) => prev.filter((x) => x.key !== r.key))}
|
||||||
|
aria-label={`Remove rule ${idx + 1}`}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addRow} disabled={rows.length >= warehouses.length}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add warehouse rule
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{dirty && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => { setRows(toRows(settings)); setErrors({}) }} disabled={saving}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { formatAmount } from "@/lib/format"
|
||||||
|
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
|
||||||
|
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. */
|
||||||
|
export type PaymentLine = { key: number; glBankAccountId: string; amount: string }
|
||||||
|
|
||||||
|
export const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
|
|
||||||
|
/** Today's date in the user's own timezone, as `YYYY-MM-DD` (what `<input type="date">` uses). */
|
||||||
|
export function localToday(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentLinesTotal(lines: PaymentLine[]): number {
|
||||||
|
return round2(lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-line errors (`account-<key>` / `amount-<key>`) plus `lines` for the total, keyed for the editor. */
|
||||||
|
export function validatePaymentLines(lines: PaymentLine[], balance: number): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
lines.forEach((l) => {
|
||||||
|
const amount = Number(l.amount)
|
||||||
|
if (!l.glBankAccountId) errors[`account-${l.key}`] = "Select a payment type"
|
||||||
|
if (!l.amount || Number.isNaN(amount) || amount <= 0) errors[`amount-${l.key}`] = "Enter a valid amount"
|
||||||
|
})
|
||||||
|
const total = paymentLinesTotal(lines)
|
||||||
|
if (lines.length === 0) errors.lines = "Add at least one payment line"
|
||||||
|
else if (total > balance) errors.lines = `Total ${formatAmount(total)} exceeds the balance (${formatAmount(balance)})`
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentLinesEditorProps {
|
||||||
|
lines: PaymentLine[]
|
||||||
|
onChange: (lines: PaymentLine[]) => void
|
||||||
|
accounts: CashAndBankAccountDto[] | null
|
||||||
|
/** What is owed — the fixed "Total"; Paid is the lines' sum and Remaining the difference. */
|
||||||
|
balance: number
|
||||||
|
errors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment type + paid amount rows (add/remove), with a Total / Paid amount / Remaining summary. */
|
||||||
|
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors }: PaymentLinesEditorProps) {
|
||||||
|
const paid = paymentLinesTotal(lines)
|
||||||
|
const remaining = round2(balance - paid)
|
||||||
|
|
||||||
|
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
||||||
|
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
// A new line starts with whatever is still unpaid, so a two-way split is one edit.
|
||||||
|
const key = Math.max(-1, ...lines.map((l) => l.key)) + 1
|
||||||
|
onChange([...lines, { key, glBankAccountId: "", amount: remaining > 0 ? remaining.toFixed(2) : "" }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="grid grid-cols-[1fr_8rem_8rem] gap-2 text-sm font-medium">
|
||||||
|
<span>Payment type</span>
|
||||||
|
<span>Paid amount</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<div key={line.key} className="grid grid-cols-[1fr_8rem_8rem] items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<Select<string> value={line.glBankAccountId} onValueChange={(v) => updateLine(line.key, { glBankAccountId: v ?? "" })}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full text-base"
|
||||||
|
aria-label={`Payment type, line ${idx + 1}`}
|
||||||
|
aria-invalid={!!errors[`account-${line.key}`]}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={accounts === null ? "Loading…" : "Select cash/bank"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(accounts ?? []).map((a) => (
|
||||||
|
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||||
|
{a.accountName} ({a.accountType})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={line.amount}
|
||||||
|
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
||||||
|
aria-label={`Paid amount, line ${idx + 1}`}
|
||||||
|
aria-invalid={!!errors[`amount-${line.key}`]}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
aria-label={`Remove line ${idx + 1}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
||||||
|
{idx === lines.length - 1 && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add line
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
||||||
|
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
||||||
|
<span className="text-muted-foreground">Paid amount</span>
|
||||||
|
<span className="text-right tabular-nums">{formatAmount(paid)}</span>
|
||||||
|
<span className="font-medium text-destructive">{remaining < 0 ? "Over by" : "Remaining"}</span>
|
||||||
|
<span className="text-right font-semibold text-destructive tabular-nums">{formatAmount(Math.abs(remaining))}</span>
|
||||||
|
</div>
|
||||||
|
<FieldError errors={[errors.lines ? { message: errors.lines } : undefined]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatAmount } from "@/lib/format"
|
||||||
|
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||||
|
import { SalesInvoicePayment, SalesInvoiceSummary } from "@/types/sales"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import {
|
||||||
|
localToday,
|
||||||
|
PaymentLine,
|
||||||
|
PaymentLinesEditor,
|
||||||
|
paymentLinesTotal,
|
||||||
|
round2,
|
||||||
|
validatePaymentLines,
|
||||||
|
} from "@/components/sales/PaymentLinesEditor"
|
||||||
|
|
||||||
|
interface SalesInvoiceBulkPaymentDialogProps {
|
||||||
|
/** Draft or Posted invoices with a balance, all for the same (non-cash) customer. */
|
||||||
|
invoices: SalesInvoiceSummary[]
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** A selected draft was confirmed (posted) on the way to paying it. */
|
||||||
|
onConfirmed: (salesInvoiceId: number) => void
|
||||||
|
/** Called with every payment recorded — one per invoice/payment-type split. */
|
||||||
|
onPaid: (payments: SalesInvoicePayment[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oldest invoice first — the same order the backend applies the payment in. */
|
||||||
|
function byAge(a: SalesInvoiceSummary, b: SalesInvoiceSummary) {
|
||||||
|
return a.invoiceDate.localeCompare(b.invoiceDate) || a.salesInvoiceId - b.salesInvoiceId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pay several invoices of one customer at once, split across payment lines. The lines' total is
|
||||||
|
* applied oldest invoice first (shown per invoice before saving) and posted server-side as a single
|
||||||
|
* GL journal entry, so it either all goes through or none of it does. */
|
||||||
|
export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, onConfirmed, onPaid }: SalesInvoiceBulkPaymentDialogProps) {
|
||||||
|
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||||
|
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||||
|
const [reference, setReference] = useState("")
|
||||||
|
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
// Drafts this dialog has already confirmed — so a retry after a failed payment doesn't re-post them.
|
||||||
|
const [postedIds, setPostedIds] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const ordered = useMemo(() => [...invoices].sort(byAge), [invoices])
|
||||||
|
const balance = round2(ordered.reduce((sum, i) => sum + i.totals.balanceAmount, 0))
|
||||||
|
|
||||||
|
// Fresh form for each open / different selection — "adjust state while rendering", not an effect.
|
||||||
|
const openKey = open && ordered.length > 0 ? ordered.map((i) => i.salesInvoiceId).join(",") : null
|
||||||
|
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||||
|
if (openKey !== null && resetFor !== openKey) {
|
||||||
|
setResetFor(openKey)
|
||||||
|
setLines([{ key: 0, glBankAccountId: "", amount: balance.toFixed(2) }])
|
||||||
|
setReference("")
|
||||||
|
setPaymentDate(localToday())
|
||||||
|
setErrors({})
|
||||||
|
setPostedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || accounts !== null) return
|
||||||
|
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||||
|
}, [open, accounts])
|
||||||
|
|
||||||
|
// Preview of how the paid amount lands on each invoice, oldest first.
|
||||||
|
const allocation = useMemo(() => {
|
||||||
|
let left = Math.max(0, Math.min(paymentLinesTotal(lines), balance))
|
||||||
|
const rows: { inv: SalesInvoiceSummary; applied: number }[] = []
|
||||||
|
for (const inv of ordered) {
|
||||||
|
const applied = round2(Math.min(left, inv.totals.balanceAmount))
|
||||||
|
left = round2(left - applied)
|
||||||
|
rows.push({ inv, applied })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}, [ordered, lines, balance])
|
||||||
|
|
||||||
|
if (ordered.length === 0) return null
|
||||||
|
const customerName = ordered[0].customerSnapshotName
|
||||||
|
const draftCount = ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId)).length
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const nextErrors = validatePaymentLines(lines, balance)
|
||||||
|
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||||
|
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
// Drafts are confirmed first (payments only go against approved invoices) — the same
|
||||||
|
// "confirm & pay" the single-invoice dialog does. A failed confirm stops before any payment;
|
||||||
|
// drafts already confirmed by then simply stay approved.
|
||||||
|
for (const inv of ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId))) {
|
||||||
|
try {
|
||||||
|
await salesApi.postInvoice(inv.salesInvoiceId)
|
||||||
|
setPostedIds((prev) => new Set(prev).add(inv.salesInvoiceId))
|
||||||
|
onConfirmed(inv.salesInvoiceId)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Could not confirm invoice ${inv.invoiceNo}`, errorMessage(err))
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payments = await salesApi.payInvoicesBulk({
|
||||||
|
salesInvoiceIds: ordered.map((i) => i.salesInvoiceId),
|
||||||
|
lines: lines.map((l) => ({ amount: Number(l.amount), glBankAccountId: Number(l.glBankAccountId) })),
|
||||||
|
reference: reference || undefined,
|
||||||
|
paymentDate,
|
||||||
|
})
|
||||||
|
const paid = round2(payments.reduce((sum, p) => sum + p.amount, 0))
|
||||||
|
const invoiceCount = new Set(payments.map((p) => p.salesInvoiceId)).size
|
||||||
|
toast.success(
|
||||||
|
"Payment recorded",
|
||||||
|
`${formatAmount(paid)} applied to ${invoiceCount} invoice${invoiceCount === 1 ? "" : "s"} (${payments[0]?.glJournalNo ?? "—"}).`
|
||||||
|
)
|
||||||
|
onPaid(payments)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not record payment", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Pay {ordered.length} invoices — {customerName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{draftCount > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
||||||
|
{draftCount === 1 ? "it" : "them"} first, then record the payment.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Date</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allocation.map(({ inv, applied }) => (
|
||||||
|
<tr key={inv.salesInvoiceId} className="border-t">
|
||||||
|
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
|
||||||
|
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="bulk-pay-customer">Customer</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="bulk-pay-customer"
|
||||||
|
value={customerName}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.paymentDate}>
|
||||||
|
<FieldLabel htmlFor="bulk-pay-date">Payment date</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="bulk-pay-date"
|
||||||
|
type="date"
|
||||||
|
max={localToday()}
|
||||||
|
value={paymentDate}
|
||||||
|
onChange={(e) => setPaymentDate(e.target.value)}
|
||||||
|
aria-invalid={!!errors.paymentDate}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="bulk-pay-reference">Reference (optional)</FieldLabel>
|
||||||
|
<Input id="bulk-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={submitting}>
|
||||||
|
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
|
||||||
|
|
||||||
interface SalesInvoicePaymentDialogProps {
|
interface SalesInvoicePaymentDialogProps {
|
||||||
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
||||||
@@ -24,23 +24,28 @@ interface SalesInvoicePaymentDialogProps {
|
|||||||
invoice: SalesInvoiceSummary | null
|
invoice: SalesInvoiceSummary | null
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||||
* it started that way or this dialog auto-posted it first. */
|
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||||
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
|
/** Record a customer payment against an invoice's balance — full or partial, split across one or
|
||||||
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
|
* more payment lines (e.g. part cash, part bank), each into an existing GL cash/bank account. Each
|
||||||
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
|
* line is posted as its own payment (the backend already allows several installments). A Draft
|
||||||
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
|
* invoice is posted first (the backend only accepts payments against a Posted invoice) so this
|
||||||
* PurchaseInvoicePaymentDialog on the AP side. */
|
* doubles as "confirm and pay in one step" from a list row. If a line fails part-way, the lines
|
||||||
|
* already recorded are removed from the form and the rest stay for a retry. */
|
||||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||||
const [amount, setAmount] = useState("")
|
|
||||||
const [reference, setReference] = useState("")
|
const [reference, setReference] = useState("")
|
||||||
|
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
|
||||||
|
// guaranteed to be refreshed by the parent between a partial failure and the retry.
|
||||||
|
const [postedHere, setPostedHere] = useState(false)
|
||||||
|
const [paidHere, setPaidHere] = useState(0)
|
||||||
|
|
||||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||||
// "adjust state while rendering" pattern, not inside an effect.
|
// "adjust state while rendering" pattern, not inside an effect.
|
||||||
@@ -48,10 +53,12 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
|||||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||||
if (openKey !== null && resetFor !== openKey) {
|
if (openKey !== null && resetFor !== openKey) {
|
||||||
setResetFor(openKey)
|
setResetFor(openKey)
|
||||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||||
setGlBankAccountId("")
|
|
||||||
setReference("")
|
setReference("")
|
||||||
|
setPaymentDate(localToday())
|
||||||
setErrors({})
|
setErrors({})
|
||||||
|
setPostedHere(false)
|
||||||
|
setPaidHere(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,98 +68,118 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
|||||||
|
|
||||||
if (!invoice) return null
|
if (!invoice) return null
|
||||||
|
|
||||||
|
const isDraft = invoice.status === "Draft" && !postedHere
|
||||||
|
const balance = round2(invoice.totals.balanceAmount - paidHere)
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!invoice) return
|
if (!invoice) return
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors = validatePaymentLines(lines, balance)
|
||||||
const amountNum = Number(amount)
|
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
|
||||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
if (isDraft) {
|
||||||
if (invoice.status === "Draft") {
|
try {
|
||||||
try {
|
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
setPostedHere(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not confirm invoice", errorMessage(err))
|
toast.error("Could not confirm invoice", errorMessage(err))
|
||||||
return
|
setSubmitting(false)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||||
amount: amountNum,
|
// keep the rest so the user can fix and retry without double-paying.
|
||||||
glBankAccountId: Number(glBankAccountId),
|
const recorded: SalesInvoicePayment[] = []
|
||||||
reference: reference || undefined,
|
let failure: unknown = null
|
||||||
})
|
for (const line of lines) {
|
||||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
try {
|
||||||
|
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||||
|
amount: Number(line.amount),
|
||||||
|
glBankAccountId: Number(line.glBankAccountId),
|
||||||
|
reference: reference || undefined,
|
||||||
|
paymentDate,
|
||||||
|
})
|
||||||
|
recorded.push(payment)
|
||||||
|
onPaid(payment, "Posted")
|
||||||
|
} catch (err) {
|
||||||
|
failure = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
|
||||||
|
if (failure === null) {
|
||||||
|
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
|
||||||
toast.success(
|
toast.success(
|
||||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${recordedTotal >= balance ? "fully paid" : "partially paid"}.`
|
||||||
)
|
)
|
||||||
onPaid(payment, "Posted")
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (err) {
|
return
|
||||||
toast.error(
|
|
||||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
|
||||||
errorMessage(err)
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPaidHere((p) => round2(p + recordedTotal))
|
||||||
|
setLines((prev) => prev.slice(recorded.length))
|
||||||
|
toast.error(
|
||||||
|
recorded.length > 0
|
||||||
|
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
|
||||||
|
: invoice.status === "Draft"
|
||||||
|
? "Invoice was confirmed, but the payment could not be recorded"
|
||||||
|
: "Could not record payment",
|
||||||
|
errorMessage(failure)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.glBankAccountId}>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
<Field>
|
||||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
<FieldLabel htmlFor="inv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
<Input
|
||||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
id="inv-pay-invoice-no"
|
||||||
</SelectTrigger>
|
value={invoice.invoiceNo}
|
||||||
<SelectContent>
|
readOnly
|
||||||
{(accounts ?? []).map((a) => (
|
tabIndex={-1}
|
||||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||||
{a.accountName} ({a.accountType})
|
/>
|
||||||
</SelectItem>
|
</Field>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.amount}>
|
<Field data-invalid={!!errors.paymentDate}>
|
||||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
<FieldLabel htmlFor="inv-pay-date">Payment date</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="inv-pay-amount"
|
id="inv-pay-date"
|
||||||
type="number"
|
type="date"
|
||||||
step="0.01"
|
max={localToday()}
|
||||||
min="0.01"
|
value={paymentDate}
|
||||||
max={invoice.totals.balanceAmount}
|
onChange={(e) => setPaymentDate(e.target.value)}
|
||||||
value={amount}
|
aria-invalid={!!errors.paymentDate}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
/>
|
||||||
aria-invalid={!!errors.amount}
|
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||||
/>
|
</Field>
|
||||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
</div>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||||
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -160,11 +187,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button onClick={submit} disabled={submitting}>
|
||||||
{submitting
|
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||||
? "Recording…"
|
|
||||||
: invoice.status === "Draft"
|
|
||||||
? "Confirm & record payment"
|
|
||||||
: "Record payment"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import { Printer } from "lucide-react"
|
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -12,7 +12,9 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
import { CompanyProfile } from "@/types/company-profile"
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||||
@@ -21,17 +23,26 @@ import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
|||||||
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||||
const ACCENT = "#8b1c24"
|
const ACCENT = "#8b1c24"
|
||||||
|
|
||||||
type PaperSize = "A4" | "80mm"
|
type PaperSize = "A4" | "80mm" | "58mm"
|
||||||
|
type ReceiptWidth = Exclude<PaperSize, "A4">
|
||||||
|
|
||||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||||
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||||
|
{ value: "58mm", label: "58mm receipt", hint: "Small thermal roll" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||||
const PAGE_RULES: Record<PaperSize, string> = {
|
const PAGE_RULES: Record<PaperSize, string> = {
|
||||||
A4: "@page { size: A4; margin: 12mm; }",
|
A4: "@page { size: A4; margin: 12mm; }",
|
||||||
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||||
|
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
||||||
|
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
||||||
|
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
||||||
|
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(n: number) {
|
function money(n: number) {
|
||||||
@@ -265,12 +276,15 @@ function ReceiptRow({ label, value, bold }: { label: string; value: string; bold
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||||
const t = invoice.totals
|
const t = invoice.totals
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
className={cn(
|
||||||
|
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
|
||||||
|
RECEIPT_SHEET_CLASS[width]
|
||||||
|
)}
|
||||||
style={printColorExact}
|
style={printColorExact}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -358,7 +372,7 @@ function ReceiptSheet({ invoice, customer, payments, company, template, dueDate,
|
|||||||
}
|
}
|
||||||
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||||
* template). Only the invoice is required — any other failure just leaves that block out. */
|
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||||
function useInvoiceDoc(invoiceId: number) {
|
function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
||||||
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||||
@@ -404,7 +418,8 @@ function useInvoiceDoc(invoiceId: number) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [invoiceId])
|
// refreshKey: bumped by the caller after a payment/confirm so the sheet shows the new state.
|
||||||
|
}, [invoiceId, refreshKey])
|
||||||
|
|
||||||
return { doc, error }
|
return { doc, error }
|
||||||
}
|
}
|
||||||
@@ -416,8 +431,57 @@ function useIsClient() {
|
|||||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
||||||
|
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||||
|
const A4_HEIGHT_MM = 297
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
||||||
|
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
||||||
|
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
||||||
|
* Both libraries are loaded on demand.
|
||||||
|
*/
|
||||||
|
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
||||||
|
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||||
|
// The sheet's drop shadow / ring are screen decoration only.
|
||||||
|
const dataUrl = await toPng(sheet, {
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
style: { boxShadow: "none", margin: "0" },
|
||||||
|
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
||||||
|
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||||
|
})
|
||||||
|
const img = new Image()
|
||||||
|
img.src = dataUrl
|
||||||
|
await img.decode()
|
||||||
|
|
||||||
|
const widthMm = PAPER_WIDTH_MM[size]
|
||||||
|
const imgHeightMm = (img.height * widthMm) / img.width
|
||||||
|
if (size !== "A4") {
|
||||||
|
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
||||||
|
return pdf.output("blob")
|
||||||
|
}
|
||||||
|
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||||
|
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
||||||
|
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
||||||
|
if (page > 0) pdf.addPage()
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
||||||
|
}
|
||||||
|
return pdf.output("blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
|
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -425,10 +489,55 @@ function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
|||||||
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
||||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||||
*/
|
*/
|
||||||
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||||
const { doc, error } = useInvoiceDoc(invoiceId)
|
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||||
const isClient = useIsClient()
|
const isClient = useIsClient()
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
||||||
|
|
||||||
|
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
||||||
|
|
||||||
|
async function makePdf(): Promise<Blob> {
|
||||||
|
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
||||||
|
if (!sheet) throw new Error("The invoice is still loading.")
|
||||||
|
return buildInvoicePdf(sheet, paperSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setBusy("download")
|
||||||
|
try {
|
||||||
|
saveBlob(await makePdf(), fileName)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not create the PDF", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
||||||
|
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
|
||||||
|
async function handleShare() {
|
||||||
|
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
|
||||||
|
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
|
||||||
|
setBusy("share")
|
||||||
|
try {
|
||||||
|
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
||||||
|
if (navigator.canShare?.({ files: [file] })) {
|
||||||
|
await navigator.share({ files: [file], title, text: title })
|
||||||
|
} else if (navigator.share) {
|
||||||
|
await navigator.share({ title, text: title, url: link })
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(link)
|
||||||
|
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Closing the share sheet isn't an error.
|
||||||
|
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error && !doc) {
|
if (error && !doc) {
|
||||||
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
@@ -437,7 +546,7 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
|||||||
return (
|
return (
|
||||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||||
{/* Preview window — scrolls so the page keeps its true size. */}
|
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
<div ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||||
{doc ? (
|
{doc ? (
|
||||||
<Sheet size={paperSize} doc={doc} />
|
<Sheet size={paperSize} doc={doc} />
|
||||||
) : (
|
) : (
|
||||||
@@ -448,30 +557,41 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
|||||||
{/* Paper size selection, under the invoice. */}
|
{/* Paper size selection, under the invoice. */}
|
||||||
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||||
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
<Select<PaperSize>
|
||||||
{PAPER_SIZES.map((s) => (
|
value={paperSize}
|
||||||
<button
|
onValueChange={(v) => v && setPaperSize(v)}
|
||||||
key={s.value}
|
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||||
type="button"
|
>
|
||||||
role="radio"
|
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||||
aria-checked={paperSize === s.value}
|
<SelectValue />
|
||||||
onClick={() => setPaperSize(s.value)}
|
</SelectTrigger>
|
||||||
className={cn(
|
<SelectContent>
|
||||||
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
{PAPER_SIZES.map((s) => (
|
||||||
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
<SelectItem key={s.value} value={s.value}>
|
||||||
)}
|
<span className="flex flex-col">
|
||||||
>
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
<span className="text-sm font-medium">{s.label}</span>
|
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
</span>
|
||||||
</button>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
|
||||||
|
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
|
||||||
|
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
Print {paperSize === "A4" ? "A4" : "receipt"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isClient &&
|
{isClient &&
|
||||||
@@ -493,20 +613,29 @@ export function SalesInvoicePrintDialog({
|
|||||||
invoiceNo,
|
invoiceNo,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
actions,
|
||||||
|
refreshKey,
|
||||||
}: {
|
}: {
|
||||||
invoiceId: number | null
|
invoiceId: number | null
|
||||||
invoiceNo?: string
|
invoiceNo?: string
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** Optional buttons shown in the header (e.g. Confirm / Pay) — turns the preview into an invoice view. */
|
||||||
|
actions?: ReactNode
|
||||||
|
/** Bump to reload the invoice (after a payment, confirm, …). */
|
||||||
|
refreshKey?: number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||||
<div className="border-b px-5 py-4 pr-12">
|
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
<div>
|
||||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||||
|
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} refreshKey={refreshKey} className="flex-1" />}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ export interface SuggestOption {
|
|||||||
label: string
|
label: string
|
||||||
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||||
hint?: string
|
hint?: string
|
||||||
|
/** Optional smaller second line under the label (e.g. stock and item details). Not searched. */
|
||||||
|
detail?: string
|
||||||
|
/** Optional pill shown on the right, before the hint (e.g. an available stock count). */
|
||||||
|
badge?: string
|
||||||
|
badgeTone?: SuggestBadgeTone
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestBadgeTone = "muted" | "success" | "danger"
|
||||||
|
|
||||||
|
const badgeToneClass: Record<SuggestBadgeTone, string> = {
|
||||||
|
muted: "bg-muted text-muted-foreground",
|
||||||
|
success: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||||
|
danger: "bg-destructive/10 text-destructive",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SuggestInputProps {
|
interface SuggestInputProps {
|
||||||
@@ -32,6 +45,8 @@ interface SuggestInputProps {
|
|||||||
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||||
keepOpenOnSelect?: boolean
|
keepOpenOnSelect?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Extra classes for the suggestion list (it defaults to the input's width), e.g. a wider min-width. */
|
||||||
|
listClassName?: string
|
||||||
"aria-label"?: string
|
"aria-label"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +68,7 @@ export function SuggestInput({
|
|||||||
browsable = false,
|
browsable = false,
|
||||||
keepOpenOnSelect = false,
|
keepOpenOnSelect = false,
|
||||||
className,
|
className,
|
||||||
|
listClassName,
|
||||||
...rest
|
...rest
|
||||||
}: SuggestInputProps) {
|
}: SuggestInputProps) {
|
||||||
const [query, setQuery] = React.useState("")
|
const [query, setQuery] = React.useState("")
|
||||||
@@ -218,7 +234,7 @@ export function SuggestInput({
|
|||||||
id={listId}
|
id={listId}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||||
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
className={cn("fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10", listClassName)}
|
||||||
>
|
>
|
||||||
{matches.length === 0 ? (
|
{matches.length === 0 ? (
|
||||||
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||||
@@ -241,8 +257,29 @@ export function SuggestInput({
|
|||||||
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{option.label}</span>
|
{option.detail ? (
|
||||||
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
)}
|
||||||
|
{(option.badge || option.hint) && (
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
{option.badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||||
|
badgeToneClass[option.badgeTone ?? "muted"],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{option.hint && <span className="text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface ListCustomersParams {
|
|||||||
q?: string
|
q?: string
|
||||||
status?: EntityStatus
|
status?: EntityStatus
|
||||||
customerType?: CustomerType
|
customerType?: CustomerType
|
||||||
|
/** The system cash customer (walk-in / one-time buyers) is left out by default — pass true only
|
||||||
|
* where the list is used to resolve names for existing documents. */
|
||||||
|
includeCashCustomer?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customersApi = {
|
export const customersApi = {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// GRN configuration. Singleton - no id. A saved `defaultWarehouseId` pins every GRN to that
|
||||||
|
// warehouse (the server forces it too); with none saved, New GRN shows a warehouse picker.
|
||||||
|
import { apiRequestWithETag } from "@/lib/api-client"
|
||||||
|
import { ApiResult } from "@/types/common"
|
||||||
|
import { GrnConfig, UpdateGrnConfigRequest } from "@/types/grn"
|
||||||
|
|
||||||
|
export const grnConfigApi = {
|
||||||
|
get(): Promise<ApiResult<GrnConfig>> {
|
||||||
|
return apiRequestWithETag<GrnConfig>("/grn-config")
|
||||||
|
},
|
||||||
|
|
||||||
|
update(request: UpdateGrnConfigRequest, ifMatch: string): Promise<ApiResult<GrnConfig>> {
|
||||||
|
return apiRequestWithETag<GrnConfig>("/grn-config", { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, PagedResponse } from "@/types/common"
|
import { ApiResult, PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
|
CreateBulkSalesInvoicePaymentRequest,
|
||||||
CreateSalesInvoicePaymentRequest,
|
CreateSalesInvoicePaymentRequest,
|
||||||
CreateSalesInvoiceRequest,
|
CreateSalesInvoiceRequest,
|
||||||
CreateSalesSlipRequest,
|
CreateSalesSlipRequest,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
SalesSlipStatus,
|
SalesSlipStatus,
|
||||||
SalesSlipPostingCheck,
|
SalesSlipPostingCheck,
|
||||||
SalesSlipSummary,
|
SalesSlipSummary,
|
||||||
|
SalesItemStock,
|
||||||
SalesStockAvailability,
|
SalesStockAvailability,
|
||||||
SalesSuggestedPrice,
|
SalesSuggestedPrice,
|
||||||
UpdateSalesInvoiceRequest,
|
UpdateSalesInvoiceRequest,
|
||||||
@@ -58,6 +60,11 @@ export const salesApi = {
|
|||||||
return apiRequest<SalesStockAvailability>(`/sales-invoices/stock-availability${buildQuery({ itemId, warehouseId })}`)
|
return apiRequest<SalesStockAvailability>(`/sales-invoices/stock-availability${buildQuery({ itemId, warehouseId })}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Issuable stock for every item at a warehouse — New Sale's "Add item" dropdown. */
|
||||||
|
getStockSummary(warehouseId: number): Promise<SalesItemStock[]> {
|
||||||
|
return apiRequest<SalesItemStock[]>(`/sales-invoices/stock-summary${buildQuery({ warehouseId })}`)
|
||||||
|
},
|
||||||
|
|
||||||
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
|
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
|
||||||
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
||||||
},
|
},
|
||||||
@@ -87,6 +94,11 @@ export const salesApi = {
|
|||||||
return apiRequest<SalesInvoicePayment>(`/sales-invoices/${salesInvoiceId}/payments`, { method: "POST", body: request })
|
return apiRequest<SalesInvoicePayment>(`/sales-invoices/${salesInvoiceId}/payments`, { method: "POST", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Pay several invoices of one customer in one go — one GL journal; returns a payment per invoice/account split. */
|
||||||
|
payInvoicesBulk(request: CreateBulkSalesInvoicePaymentRequest): Promise<SalesInvoicePayment[]> {
|
||||||
|
return apiRequest<SalesInvoicePayment[]>("/sales-invoices/payments/bulk", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
/** Payment history for an invoice, newest first. */
|
/** Payment history for an invoice, newest first. */
|
||||||
listInvoicePayments(salesInvoiceId: number): Promise<SalesInvoicePayment[]> {
|
listInvoicePayments(salesInvoiceId: number): Promise<SalesInvoicePayment[]> {
|
||||||
return apiRequest<SalesInvoicePayment[]>(`/sales-invoices/${salesInvoiceId}/payments`)
|
return apiRequest<SalesInvoicePayment[]>(`/sales-invoices/${salesInvoiceId}/payments`)
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { ItemListItem } from "@/types/master-data"
|
import { ItemListItem } from "@/types/master-data"
|
||||||
|
|
||||||
|
/** Rounds a sales unit price to 2dp, half away from zero (matches the server's SalesPricingService).
|
||||||
|
* Shifts via the exponent string rather than `* 100` so binary-float cases like 1.005 round up. */
|
||||||
|
export function roundPrice(value: number): number {
|
||||||
|
const shifted = Number(`${Math.round(Number(`${Math.abs(value)}e2`))}e-2`)
|
||||||
|
// Values JS prints in exponent form (e.g. 1e-7) can't take the string shift — those are ~0 anyway.
|
||||||
|
const rounded = Number.isFinite(shifted) ? shifted : Math.round(Math.abs(value) * 100) / 100
|
||||||
|
return value < 0 ? -rounded : rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unit price input's value as a 2dp price, or null when cleared. */
|
||||||
|
export function parsePriceInput(value: string): number | null {
|
||||||
|
if (value === "") return null
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? roundPrice(n) : null
|
||||||
|
}
|
||||||
|
|
||||||
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
|
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
|
||||||
if (!itemId) return null
|
if (!itemId) return null
|
||||||
return items.find((item) => item.itemId === itemId)?.sellingFlatPrice ?? null
|
const price = items.find((item) => item.itemId === itemId)?.sellingFlatPrice
|
||||||
|
return price === null || price === undefined ? null : roundPrice(price)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -163,3 +163,22 @@ export interface ReleaseGrnLineResponse {
|
|||||||
grnLineId: number
|
grnLineId: number
|
||||||
holdStatus: HoldStatus
|
holdStatus: HoldStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GRN configuration singleton. A saved `defaultWarehouseId` pins New GRN to that warehouse. */
|
||||||
|
export interface GrnConfig {
|
||||||
|
allowWarehouseSelection: boolean
|
||||||
|
defaultWarehouseId: number | null
|
||||||
|
defaultWarehouseName: string | null
|
||||||
|
allowDiscounts: boolean
|
||||||
|
allowVat: boolean
|
||||||
|
updatedAt: string | null
|
||||||
|
updatedBy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGrnConfigRequest {
|
||||||
|
allowWarehouseSelection: boolean
|
||||||
|
/** Required when `allowWarehouseSelection` is false. */
|
||||||
|
defaultWarehouseId: number | null
|
||||||
|
allowDiscounts: boolean
|
||||||
|
allowVat: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export interface SalesStockAvailability {
|
|||||||
available: number
|
available: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Issuable stock for one item at a warehouse; items missing from the list have none there. */
|
||||||
|
export interface SalesItemStock {
|
||||||
|
itemId: number
|
||||||
|
available: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SalesSuggestedPrice {
|
export interface SalesSuggestedPrice {
|
||||||
itemId: number
|
itemId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
@@ -162,6 +168,17 @@ export interface CreateSalesInvoicePaymentRequest {
|
|||||||
amount: number
|
amount: number
|
||||||
glBankAccountId: number
|
glBankAccountId: number
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
/** `YYYY-MM-DD`; when the customer paid (also the GL entry date). Omitted ⇒ today; never future. */
|
||||||
|
paymentDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pay several Posted invoices of one (non-cash) customer at once; applied oldest invoice first. */
|
||||||
|
export interface CreateBulkSalesInvoicePaymentRequest {
|
||||||
|
salesInvoiceIds: number[]
|
||||||
|
lines: { amount: number; glBankAccountId: number }[]
|
||||||
|
reference?: string | null
|
||||||
|
/** `YYYY-MM-DD`; omitted ⇒ today; never future. */
|
||||||
|
paymentDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSalesInvoiceLineRequest {
|
export interface CreateSalesInvoiceLineRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user