sales & grn fixers
This commit is contained in:
@@ -23,8 +23,9 @@ public sealed class CustomersController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/grn-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnConfigService _config;
|
||||
|
||||
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||
[HttpGet("stock-summary")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
@@ -143,6 +153,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||
/// </summary>
|
||||
[HttpPost("payments/bulk")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -0,0 +1,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
|
||||
{
|
||||
[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; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
|
||||
public sealed class BulkSalesInvoicePaymentLine
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
|
||||
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
|
||||
/// </summary>
|
||||
public sealed class CreateBulkSalesInvoicePaymentRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
|
||||
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||
public DateOnly? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
@@ -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>
|
||||
public sealed record SalesStockAvailabilityDto(int ItemId, int WarehouseId, bool IsStocked, decimal Available);
|
||||
|
||||
/// <summary>Issuable stock (on-hand less on-hold) for one item at a warehouse — one row per item
|
||||
/// that has ever had stock there, for New Sale's "Add item" dropdown.</summary>
|
||||
public sealed record SalesItemStockDto(int ItemId, decimal Available);
|
||||
|
||||
public sealed class CreateSalesInvoiceLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton GRN-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors SalesConfig).
|
||||
/// </summary>
|
||||
public sealed class GrnConfigConfiguration : IEntityTypeConfiguration<GrnConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnConfig> builder)
|
||||
{
|
||||
builder.ToTable("grn_config", t =>
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", $"\"ConfigId\" = {GrnConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert
|
||||
// instead of being swallowed by the database default.
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowVat).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public static class DataSeeder
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||
dirty |= await SeedSalesStockAsync(db, ct);
|
||||
@@ -144,6 +145,16 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the GRN configuration singleton — warehouse selection on, no default warehouse,
|
||||
/// so New GRN keeps its warehouse picker until an admin opts in from Settings → Configuration → GRN Configuration.</summary>
|
||||
private static async Task<bool> SeedGrnConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.GrnConfig.AnyAsync(c => c.ConfigId == GrnConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.GrnConfig.Add(new GrnConfig { ConfigId = GrnConfig.SingletonId });
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
@@ -46,6 +46,7 @@ public class ErpDbContext : DbContext
|
||||
/// <summary>Singleton row gating whether this deployment uses branches at all.</summary>
|
||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
|
||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllowWarehouseSelection = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
DefaultWarehouseId = table.Column<int>(type: "integer", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_config_warehouses_DefaultWarehouseId",
|
||||
column: x => x.DefaultWarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_DefaultWarehouseId",
|
||||
table: "grn_config",
|
||||
column: "DefaultWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_config_UpdatedBy",
|
||||
table: "grn_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfigDiscountsVat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowVat",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowVat",
|
||||
table: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowVat")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("grn_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Property<int>("GrnLineId")
|
||||
@@ -7211,6 +7258,23 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
|
||||
@@ -98,6 +98,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
|
||||
@@ -16,19 +16,37 @@ public sealed class CustomerService : ICustomerService
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
|
||||
private const string CashCustomerCode = "CUST-CASH";
|
||||
|
||||
public CustomerService(
|
||||
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
|
||||
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_branchConfig = branchConfig;
|
||||
_salesConfig = salesConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<CustomerDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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 ICurrentUser _currentUser;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IGrnConfigService _grnConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly INotificationService _notifications;
|
||||
@@ -52,7 +53,7 @@ public sealed class GrnService : IGrnService
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
@@ -70,6 +71,7 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_branchConfig = branchConfig;
|
||||
_grnConfig = grnConfig;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_notifications = notifications;
|
||||
@@ -118,6 +120,10 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// GRN Configuration default receiving warehouse: whatever the client sent, the GRN uses it.
|
||||
if (await _grnConfig.GetForcedWarehouseIdAsync(ct) is int forcedWarehouseId)
|
||||
request.WarehouseId = forcedWarehouseId;
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
@@ -144,6 +150,12 @@ public sealed class GrnService : IGrnService
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
|
||||
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
|
||||
if (!allowVat && request.Lines.Any(l => l.VatPct > 0m))
|
||||
throw new DomainException(ErrorCodes.Validation, "VAT is turned off in GRN Configuration.", 422);
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
|
||||
@@ -187,7 +199,8 @@ public sealed class GrnService : IGrnService
|
||||
// input.VatPct supplied is an explicit override; omitted (null) resolves from the
|
||||
// item's assigned TaxRate — see ISalesDomainService.ResolveLineTaxPctAsync's sales-side
|
||||
// equivalent of this same "Item.TaxRate drives the default, don't hand-type it" rule.
|
||||
var vatPct = input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
// GRN Configuration "Allow VAT" off: every line is 0% — the item's tax rate is not applied either.
|
||||
var vatPct = !allowVat ? 0m : input.VatPct ?? (item.TaxRateId is int taxRateId
|
||||
? await _taxRates.Query().AsNoTracking().Where(t => t.TaxRateId == taxRateId).Select(t => t.Percentage).FirstAsync(ct)
|
||||
: 0m);
|
||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
||||
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ public interface IStockService
|
||||
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
|
||||
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesInvoicePayment> _payments;
|
||||
private readonly IRepository<SalesConfig> _salesConfig;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
|
||||
public SalesInvoicePaymentService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_salesConfig = salesConfig;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = invoice.InvoiceNo,
|
||||
Narration = $"Payment against invoice {invoice.InvoiceNo}",
|
||||
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ids = request.SalesInvoiceIds.Distinct().ToList();
|
||||
if (ids.Count == 0 || request.Lines.Count == 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
|
||||
|
||||
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
|
||||
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
|
||||
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
|
||||
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
|
||||
|
||||
// The generic cash customer's invoices are paid one at a time, never grouped.
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
|
||||
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
|
||||
|
||||
foreach (var inv in invoices)
|
||||
{
|
||||
if (inv.Status != SalesInvoiceStatus.Posted)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
|
||||
if (inv.BalanceAmount <= 0)
|
||||
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
|
||||
}
|
||||
|
||||
var total = request.Lines.Sum(l => l.Amount);
|
||||
var totalBalance = invoices.Sum(i => i.BalanceAmount);
|
||||
if (total > totalBalance)
|
||||
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
|
||||
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var lines = request.Lines.Select(l => (
|
||||
Line: l,
|
||||
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
|
||||
.ToList();
|
||||
|
||||
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
|
||||
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
|
||||
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
|
||||
var invIdx = 0;
|
||||
var invRemaining = ordered[0].BalanceAmount;
|
||||
foreach (var (line, account) in lines)
|
||||
{
|
||||
var lineRemaining = line.Amount;
|
||||
while (lineRemaining > 0)
|
||||
{
|
||||
var take = Math.Min(lineRemaining, invRemaining);
|
||||
allocations.Add((ordered[invIdx], account, take));
|
||||
lineRemaining -= take;
|
||||
invRemaining -= take;
|
||||
if (invRemaining == 0 && invIdx < ordered.Count - 1)
|
||||
invRemaining = ordered[++invIdx].BalanceAmount;
|
||||
}
|
||||
}
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
|
||||
var narration = $"Payment against invoices {paidNos}";
|
||||
if (narration.Length > 250) narration = narration[..247] + "...";
|
||||
|
||||
var payments = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var glLines = lines
|
||||
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
||||
.ToList();
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = paymentDay,
|
||||
SourceModule = "SALES_INVOICE_PAYMENT",
|
||||
Reference = request.Reference ?? ordered[0].InvoiceNo,
|
||||
Narration = narration,
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
var entities = new List<SalesInvoicePayment>();
|
||||
foreach (var (invoice, account, amount) in allocations)
|
||||
{
|
||||
var entity = new SalesInvoicePayment
|
||||
{
|
||||
SalesInvoiceId = invoice.SalesInvoiceId,
|
||||
Amount = amount,
|
||||
PaymentDate = paymentDate,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
invoice.PaidAmount += amount;
|
||||
invoice.BalanceAmount -= amount;
|
||||
entities.Add(entity);
|
||||
}
|
||||
return entities;
|
||||
}, ct);
|
||||
|
||||
return payments.Select(Map).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
||||
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
|
||||
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
|
||||
/// that day's start.
|
||||
/// </summary>
|
||||
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
var day = requested ?? today;
|
||||
if (day > today.AddDays(1))
|
||||
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
|
||||
|
||||
@@ -21,8 +21,17 @@ public sealed class SalesPricingService : ISalesPricingService
|
||||
_fifo = fifo;
|
||||
}
|
||||
|
||||
// Sales unit prices are money — always 2dp, whichever source (flat, GRN weighted avg, FIFO avg,
|
||||
// manual) produced them, so the suggested-price endpoint and saved lines agree.
|
||||
public async Task<SalesPriceResolution> ResolveAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default)
|
||||
{
|
||||
var resolved = await ResolveUnroundedAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct);
|
||||
return resolved with { UnitPrice = Math.Round(resolved.UnitPrice, 2, MidpointRounding.AwayFromZero) };
|
||||
}
|
||||
|
||||
private async Task<SalesPriceResolution> ResolveUnroundedAsync(
|
||||
int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct)
|
||||
?? throw new InvalidOperationException($"Item {itemId} was not found.");
|
||||
|
||||
@@ -132,6 +132,18 @@ public sealed class StockService : IStockService
|
||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same figure as <see cref="GetOnHandAsync"/>'s Available (all layers, negative ones included,
|
||||
/// less held OnHold/Rejected GRN stock), for every item at the warehouse in one grouped query.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.WarehouseId == warehouseId
|
||||
&& !(l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected)))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
|
||||
@@ -109,7 +109,8 @@ export default function CustomersPage() {
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admin-only branch picker — non-admins never see this control, their own employee
|
||||
|
||||
@@ -190,19 +190,6 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
@@ -234,6 +221,21 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className="ml-auto mt-4 w-full max-w-xs">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium tabular-nums">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="border-t pt-2 text-muted-foreground">Grand total</div>
|
||||
<div className="border-t pt-2 text-right font-semibold tabular-nums">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
|
||||
@@ -37,6 +37,7 @@ import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
|
||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||
@@ -419,6 +420,16 @@ export default function ItemDetailPage() {
|
||||
dialog closes so this copy picks up what changed there. */}
|
||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||
|
||||
<ItemReorderPanel
|
||||
key={item.itemId}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
ListFilter,
|
||||
ListTree,
|
||||
Package,
|
||||
PackageX,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
@@ -25,6 +27,7 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Brand, Category, ItemOverview, ProductConfig, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
@@ -123,6 +127,8 @@ export default function ItemsPage() {
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [itemStats, setItemStats] = useState<{ total: number; lowStock: number; outOfStock: number } | null>(null)
|
||||
const dashboardStats = useDashboardStats()
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
@@ -211,6 +217,29 @@ export default function ItemsPage() {
|
||||
setLoadingMore(true)
|
||||
setPage((p) => p + 1)
|
||||
}
|
||||
// Catalogue-wide counts for the stat cards — independent of the list filters. Each is a
|
||||
// 1-row overview call read for its total, so "Low/Out of stock" match the Stock level filter.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
itemsApi.overview({ page: 1, pageSize: 1 }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "LowStock" }),
|
||||
itemsApi.overview({ page: 1, pageSize: 1, stock: "OutOfStock" }),
|
||||
])
|
||||
.then(([total, low, out]) => {
|
||||
if (cancelled) return
|
||||
setItemStats({
|
||||
total: total.pagination.totalItems,
|
||||
lowStock: low.pagination.totalItems,
|
||||
outOfStock: out.pagination.totalItems,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||
@@ -331,6 +360,24 @@ export default function ItemsPage() {
|
||||
</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
|
||||
(category, status, "Advanced" popover) grouped on the right. */}
|
||||
<FilterBar
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
@@ -14,11 +14,12 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { CreateGrnLineInput, GrnConfig, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -30,6 +31,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -197,10 +199,21 @@ export default function NewGrnPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [grnConfig, setGrnConfig] = useState<GrnConfig | null>(null)
|
||||
// A saved default receiving warehouse (GRN Configuration) pins every GRN to it — the picker is
|
||||
// replaced by a locked field, and the server forces it too.
|
||||
const allowWarehouseSelection = !grnConfig?.defaultWarehouseId && (grnConfig?.allowWarehouseSelection ?? true)
|
||||
// GRN Configuration "Allow discounts" / "Allow VAT" off: those inputs are hidden and every line is
|
||||
// treated (and sent) as 0% — the server rejects anything else.
|
||||
const allowDiscounts = grnConfig?.allowDiscounts ?? true
|
||||
const allowVat = grnConfig?.allowVat ?? true
|
||||
function effectiveLine(l: DraftLine): DraftLine {
|
||||
return { ...l, discountPct: allowDiscounts ? l.discountPct : "0", vatPct: allowVat ? l.vatPct : "0" }
|
||||
}
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
|
||||
@@ -240,8 +253,12 @@ export default function NewGrnPage() {
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
// Missing/failed config just means "pick a warehouse" — never block the form on it.
|
||||
grnConfigApi.get().then((r) => r.data).catch(() => null),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
.then(([wh, it, uo, ve, po, config]) => {
|
||||
setGrnConfig(config)
|
||||
if (config?.defaultWarehouseId) setWarehouseId(config.defaultWarehouseId)
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
@@ -281,7 +298,7 @@ export default function NewGrnPage() {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setVendorId(null)
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
setHeaderDiscountPct("")
|
||||
setHeaderVatPct("")
|
||||
setHeaderError(null)
|
||||
@@ -291,7 +308,7 @@ export default function NewGrnPage() {
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
if (!nextPoId) {
|
||||
setLines([emptyLine()])
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +322,8 @@ export default function NewGrnPage() {
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// A pinned default receiving warehouse is never replaced by the PO's warehouse.
|
||||
if (allowWarehouseSelection) setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map((l): DraftLine => {
|
||||
const draft: DraftLine = {
|
||||
@@ -357,40 +375,7 @@ export default function NewGrnPage() {
|
||||
try {
|
||||
const result = await itemsApi.scan(code)
|
||||
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
|
||||
setLines((prev) => {
|
||||
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]
|
||||
})
|
||||
addItemLine(result.itemId, scannedBatchNo)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
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>) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -420,6 +444,24 @@ export default function NewGrnPage() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
const itemOptions = useMemo<SuggestOption[]>(
|
||||
() =>
|
||||
(items ?? []).map((i) => {
|
||||
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||
const detail = [uom, i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return {
|
||||
value: String(i.itemId),
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
detail: detail || undefined,
|
||||
badge: i.warranty === "Warranty" ? "Warranty" : undefined,
|
||||
badgeTone: "muted",
|
||||
}
|
||||
}),
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -531,8 +573,8 @@ export default function NewGrnPage() {
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||
vatPct: allowVat ? line.vatPct : "0",
|
||||
warranty: item?.warranty ?? null,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
@@ -557,8 +599,8 @@ export default function NewGrnPage() {
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||
holdStatus: l.holdStatus,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
@@ -690,18 +732,28 @@ export default function NewGrnPage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{allowWarehouseSelection ? (
|
||||
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// Pinned to the default receiving warehouse — shown, not editable (same as New Sale).
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{(() => {
|
||||
const w = visibleWarehouses.find((x) => x.warehouseId === warehouseId)
|
||||
return w ? `${w.code} — ${w.name}` : (grnConfig?.defaultWarehouseName ?? "No default warehouse set")
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -709,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="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerDiscountPct}
|
||||
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
|
||||
placeholder="Applies to every line"
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{(allowDiscounts || allowVat) && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
|
||||
{allowDiscounts && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="text-base">Whole-GRN discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={headerDiscountPct}
|
||||
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 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">
|
||||
<ScanInput
|
||||
@@ -758,30 +816,11 @@ export default function NewGrnPage() {
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO.
|
||||
PO lines are prefilled. Search for an item below to also receive one that isn’t on the PO.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...emptyLine(),
|
||||
discountPct: headerDiscountPct.trim() || "0",
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
{/* Create a brand-new item in a separate tab, then refresh to pick it up. */}
|
||||
<Button
|
||||
type="button"
|
||||
@@ -833,14 +872,35 @@ export default function NewGrnPage() {
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "lines" && (
|
||||
<>
|
||||
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it is
|
||||
the last row of the table, under the Item column (same layout as Sales). */}
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="w-full md:hidden"
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
{lines.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
|
||||
No items yet — start typing in "Add item" above, or scan a barcode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked editable cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -949,38 +997,44 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
{allowDiscounts && (
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -1131,39 +1173,45 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{allowDiscounts && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
@@ -103,7 +103,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
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 }),
|
||||
uomsApi.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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -386,11 +387,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</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 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="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}
|
||||
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||
</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="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</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 className="overflow-x-auto border-y">
|
||||
@@ -452,6 +435,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</table>
|
||||
</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) ? (
|
||||
<div className="py-5">
|
||||
<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"
|
||||
step="0.01"
|
||||
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"
|
||||
/>
|
||||
<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 { Label } from "@/components/ui/label"
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
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 { errorMessage } from "@/lib/error-map"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -110,6 +111,8 @@ const lkr = new Intl.NumberFormat("en-LK", {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
const qtyFormat = new Intl.NumberFormat("en-LK", { maximumFractionDigits: 3 })
|
||||
|
||||
function NewSalesInvoiceContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -160,6 +163,17 @@ function NewSalesInvoiceContent() {
|
||||
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
||||
[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) {
|
||||
setCustomerId(nextCustomerId)
|
||||
@@ -358,7 +372,7 @@ function NewSalesInvoiceContent() {
|
||||
let price = resolvedPrices[key]
|
||||
if (price === undefined) {
|
||||
try {
|
||||
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
|
||||
price = roundPrice((await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice)
|
||||
} catch {
|
||||
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
|
||||
// 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(
|
||||
() =>
|
||||
items.map((i) => {
|
||||
const price = getSuggestedUnitPrice(items, i.itemId)
|
||||
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
|
||||
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(
|
||||
@@ -612,7 +689,7 @@ function NewSalesInvoiceContent() {
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
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,
|
||||
discountMode: line.discountMode,
|
||||
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="flex flex-col gap-2">
|
||||
<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">
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
@@ -671,11 +752,17 @@ function NewSalesInvoiceContent() {
|
||||
{salesConfig?.cashCustomerName ?? "Cash Customer"}
|
||||
</SelectItem>
|
||||
)}
|
||||
{regularCustomers.map((c) => (
|
||||
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
|
||||
{c.customerCode} — {c.displayName ?? c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{regularCustomers.map((c) => {
|
||||
const details = [c.customerType, c.phone, c.email, c.city].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<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>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -927,10 +1014,10 @@ function NewSalesInvoiceContent() {
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
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")}
|
||||
className="h-11 text-sm tabular-nums"
|
||||
/>
|
||||
@@ -1085,9 +1172,9 @@ function NewSalesInvoiceContent() {
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
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")}
|
||||
className="h-11 text-right text-sm tabular-nums"
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
FileText,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Plus,
|
||||
Printer,
|
||||
Receipt,
|
||||
Tag,
|
||||
} from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
@@ -30,6 +47,22 @@ import { Warehouse } from "@/types/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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) {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -69,8 +102,23 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||
{ 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() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
@@ -89,9 +137,23 @@ export default function SalesInvoicesPage() {
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
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)
|
||||
// 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 [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
@@ -104,11 +166,13 @@ export default function SalesInvoicesPage() {
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setPageSize(rowsPerPage)
|
||||
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||
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(() => {
|
||||
@@ -119,7 +183,7 @@ export default function SalesInvoicesPage() {
|
||||
pageSize,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
customerId,
|
||||
customerId: listCustomerId,
|
||||
warehouseId,
|
||||
...dateRangeBounds(dateRange),
|
||||
})
|
||||
@@ -129,7 +193,7 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -142,6 +206,7 @@ export default function SalesInvoicesPage() {
|
||||
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
||||
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
||||
toast.success("Invoice confirmed", posted.invoiceNo)
|
||||
setViewRefresh((n) => n + 1)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
} finally {
|
||||
@@ -150,6 +215,7 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
|
||||
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
|
||||
setViewRefresh((n) => n + 1)
|
||||
setRows(
|
||||
(prev) =>
|
||||
prev?.map((r) =>
|
||||
@@ -162,6 +228,77 @@ export default function SalesInvoicesPage() {
|
||||
: r
|
||||
) ?? 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(
|
||||
@@ -171,14 +308,43 @@ export default function SalesInvoicesPage() {
|
||||
) ?? [],
|
||||
[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 grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 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 (
|
||||
<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>
|
||||
</div>
|
||||
<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")}>
|
||||
<Plus className="size-5" />
|
||||
New Sale
|
||||
@@ -235,7 +392,11 @@ export default function SalesInvoicesPage() {
|
||||
<>
|
||||
<Field>
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All customers</SelectItem>
|
||||
@@ -291,6 +452,28 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</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 && rows === null && (
|
||||
@@ -319,17 +502,22 @@ export default function SalesInvoicesPage() {
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
setViewInvoice(row)
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<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="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
@@ -343,13 +531,12 @@ export default function SalesInvoicesPage() {
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date / Due</p>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Lines</p>
|
||||
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
@@ -358,36 +545,54 @@ export default function SalesInvoicesPage() {
|
||||
<p className="text-muted-foreground">Net</p>
|
||||
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
</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 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" />
|
||||
Print
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,45 +607,83 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Due</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3">
|
||||
<Checkbox
|
||||
checked={canSelectAll && allTicked}
|
||||
disabled={!canSelectAll}
|
||||
onCheckedChange={(c) => toggleAll(c === true)}
|
||||
aria-label="Select all"
|
||||
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
|
||||
/>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
{sortedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.salesInvoiceId}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
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">
|
||||
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<TableCell className="px-3 py-3.5 font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Receipt className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
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">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
@@ -448,13 +691,13 @@ export default function SalesInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPrintInvoice(row)}
|
||||
onClick={() => setViewInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
>
|
||||
@@ -463,26 +706,29 @@ export default function SalesInvoicesPage() {
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -494,7 +740,7 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden border-t px-4 py-4 md:block">
|
||||
<div className="hidden px-4 pb-4 md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
@@ -508,11 +754,53 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
|
||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={handleBulkConfirmed}
|
||||
onPaid={handleBulkPaid}
|
||||
/>
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
||||
invoiceNo={printInvoice?.invoiceNo}
|
||||
open={printInvoice !== null}
|
||||
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== 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>
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SalesReturnsListPage() {
|
||||
|
||||
useEffect(load, [page, pageSize, query])
|
||||
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]) => {
|
||||
setCustomers(c.items)
|
||||
setWarehouses(w.items)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = [
|
||||
{
|
||||
@@ -26,6 +26,12 @@ const cards = [
|
||||
icon: ShoppingCart,
|
||||
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",
|
||||
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>
|
||||
) : (
|
||||
<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.cardLabel}>Vendor</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-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</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>
|
||||
)}
|
||||
|
||||
@@ -149,6 +139,18 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={`ml-auto mt-4 w-full max-w-xs ${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} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.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.cardLabel}>Customer</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}
|
||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||
</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 { Field, FieldError, FieldGroup, FieldLabel } 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"
|
||||
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
|
||||
|
||||
interface SalesInvoicePaymentDialogProps {
|
||||
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
||||
@@ -24,23 +24,28 @@ interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
||||
* it started that way or this dialog auto-posted it first. */
|
||||
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
|
||||
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
|
||||
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
|
||||
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
|
||||
* PurchaseInvoicePaymentDialog on the AP side. */
|
||||
/** Record a customer payment against an invoice's balance — full or partial, split across one or
|
||||
* more payment lines (e.g. part cash, part bank), each into an existing GL cash/bank account. Each
|
||||
* line is posted as its own payment (the backend already allows several installments). A Draft
|
||||
* invoice is posted first (the backend only accepts payments against a Posted invoice) so this
|
||||
* 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) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
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)
|
||||
// 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
|
||||
// "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)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedHere(false)
|
||||
setPaidHere(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,98 +68,118 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const isDraft = invoice.status === "Draft" && !postedHere
|
||||
const balance = round2(invoice.totals.balanceAmount - paidHere)
|
||||
|
||||
async function submit() {
|
||||
if (!invoice) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
||||
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)})`
|
||||
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)
|
||||
try {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
if (isDraft) {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
setPostedHere(true)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||
// keep the rest so the user can fix and retry without double-paying.
|
||||
const recorded: SalesInvoicePayment[] = []
|
||||
let failure: unknown = null
|
||||
for (const line of lines) {
|
||||
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(
|
||||
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)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
||||
errorMessage(err)
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.status === "Draft" && "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>
|
||||
{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(balance)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</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.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-invoice-no"
|
||||
value={invoice.invoiceNo}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="inv-pay-date">Payment date</FieldLabel>
|
||||
<Input
|
||||
id="inv-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="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -160,11 +187,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
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 { customersApi } from "@/lib/api/customers"
|
||||
@@ -12,7 +12,9 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Customer } from "@/types/customers"
|
||||
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.
|
||||
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 }[] = [
|
||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||
{ 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. */
|
||||
const PAGE_RULES: Record<PaperSize, string> = {
|
||||
A4: "@page { size: A4; margin: 12mm; }",
|
||||
"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) {
|
||||
@@ -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). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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,
|
||||
* 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 [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||
@@ -404,7 +418,8 @@ function useInvoiceDoc(invoiceId: number) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
// refreshKey: bumped by the caller after a payment/confirm so the sheet shows the new state.
|
||||
}, [invoiceId, refreshKey])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
@@ -416,8 +431,57 @@ function useIsClient() {
|
||||
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 }) {
|
||||
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
|
||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||
*/
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
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) {
|
||||
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 (
|
||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||
{/* 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 ? (
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
) : (
|
||||
@@ -448,30 +557,41 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
||||
{/* 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-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
||||
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paperSize === s.value}
|
||||
onClick={() => setPaperSize(s.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
||||
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||
<Select<PaperSize>
|
||||
value={paperSize}
|
||||
onValueChange={(v) => v && setPaperSize(v)}
|
||||
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
>
|
||||
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</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>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : "receipt"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
@@ -493,20 +613,29 @@ export function SalesInvoicePrintDialog({
|
||||
invoiceNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
actions,
|
||||
refreshKey,
|
||||
}: {
|
||||
invoiceId: number | null
|
||||
invoiceNo?: string
|
||||
open: boolean
|
||||
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 (
|
||||
<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))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} refreshKey={refreshKey} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface SuggestOption {
|
||||
label: string
|
||||
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||
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 {
|
||||
@@ -32,6 +45,8 @@ interface SuggestInputProps {
|
||||
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||
keepOpenOnSelect?: boolean
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,6 +68,7 @@ export function SuggestInput({
|
||||
browsable = false,
|
||||
keepOpenOnSelect = false,
|
||||
className,
|
||||
listClassName,
|
||||
...rest
|
||||
}: SuggestInputProps) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
@@ -218,7 +234,7 @@ export function SuggestInput({
|
||||
id={listId}
|
||||
role="listbox"
|
||||
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
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 ? (
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||
{option.detail ? (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface ListCustomersParams {
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
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 = {
|
||||
|
||||
@@ -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 { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateBulkSalesInvoicePaymentRequest,
|
||||
CreateSalesInvoicePaymentRequest,
|
||||
CreateSalesInvoiceRequest,
|
||||
CreateSalesSlipRequest,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
SalesSlipStatus,
|
||||
SalesSlipPostingCheck,
|
||||
SalesSlipSummary,
|
||||
SalesItemStock,
|
||||
SalesStockAvailability,
|
||||
SalesSuggestedPrice,
|
||||
UpdateSalesInvoiceRequest,
|
||||
@@ -58,6 +60,11 @@ export const salesApi = {
|
||||
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>> {
|
||||
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
||||
},
|
||||
@@ -87,6 +94,11 @@ export const salesApi = {
|
||||
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. */
|
||||
listInvoicePayments(salesInvoiceId: number): Promise<SalesInvoicePayment[]> {
|
||||
return apiRequest<SalesInvoicePayment[]>(`/sales-invoices/${salesInvoiceId}/payments`)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
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 {
|
||||
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",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -163,3 +163,22 @@ export interface ReleaseGrnLineResponse {
|
||||
grnLineId: number
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
itemId: number
|
||||
warehouseId: number
|
||||
@@ -162,6 +168,17 @@ export interface CreateSalesInvoicePaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user