Compare commits

..

2 Commits

Author SHA1 Message Date
Sasanka f4ec3b4c65 fixers of day end sales and sales 2026-10-05 13:11:01 +05:30
Sasanka 3d3dbbbb59 sales & grn fixers 2026-10-04 15:31:59 +05:30
55 changed files with 21544 additions and 766 deletions
@@ -23,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
/// <para>
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
/// Phase-1 posture as <see cref="SalesConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/grn-config")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnConfigController : ApiControllerBase
{
private readonly IGrnConfigService _config;
public GrnConfigController(IGrnConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<GrnConfigDto>> Update(
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -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")]
@@ -80,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -143,6 +155,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
/// </summary>
[HttpPost("payments/bulk")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PayBulkAsync(request, ct));
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
@@ -0,0 +1,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; }
}
+2 -1
View File
@@ -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();
}
+27 -1
View File
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
@@ -34,6 +34,28 @@ public sealed class CreateSalesInvoicePaymentRequest
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
/// <summary>One way the customer paid in a multi-invoice payment: an amount into a cash/bank account.</summary>
public sealed class BulkSalesInvoicePaymentLine
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
}
/// <summary>
/// Pay several Posted invoices of one customer at once (never the generic cash customer). The lines'
/// total is applied oldest invoice first and may be less than the invoices' combined balance.
/// </summary>
public sealed class CreateBulkSalesInvoicePaymentRequest
{
[Required, MinLength(1)] public List<int> SalesInvoiceIds { get; set; } = [];
[Required, MinLength(1)] public List<BulkSalesInvoicePaymentLine> Lines { get; set; } = [];
[StringLength(100)] public string? Reference { get; set; }
/// <summary>When the customer paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
public DateOnly? PaymentDate { get; set; }
}
public sealed record SalesInvoicePostingIssueDto(
@@ -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");
}
}
}
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")
+1
View File
@@ -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>();
+24 -2
View File
@@ -16,19 +16,41 @@ public sealed class CustomerService : ICustomerService
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IBranchConfigService _branchConfig;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IUnitOfWork _uow;
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig, IUnitOfWork uow)
/// <summary>Code of the system "Cash Customer" row (see <see cref="SalesConfigService"/>).</summary>
private const string CashCustomerCode = "CUST-CASH";
public CustomerService(
IRepository<Customer> customers, IRepository<Warehouse> warehouses, IBranchConfigService branchConfig,
IRepository<SalesConfig> salesConfig, IUnitOfWork uow)
{
_customers = customers;
_warehouses = warehouses;
_branchConfig = branchConfig;
_salesConfig = salesConfig;
_uow = uow;
}
public async Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
{
var q = _customers.Query().AsNoTracking();
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId)
.FirstOrDefaultAsync(ct);
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
if (!includeCashCustomer)
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
// under. The cash customer is shared by every branch, so it is never filtered out here.
if (branchId is not null)
q = q.Where(c => c.BranchId == branchId
|| c.CustomerCode == CashCustomerCode
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
@@ -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);
}
+15 -2
View File
@@ -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, int? branchId = null, CancellationToken ct = default);
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
@@ -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);
}
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -12,6 +12,9 @@ public interface IStockService
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
/// <summary>Issuable (non-held) stock per item at one warehouse, keyed by item id.</summary>
Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
@@ -24,17 +24,19 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _payments;
private readonly IRepository<SalesConfig> _salesConfig;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsReceivableCode;
public SalesInvoicePaymentService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_salesConfig = salesConfig;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
@@ -58,17 +60,18 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
EntryDate = paymentDay,
SourceModule = "SALES_INVOICE_PAYMENT",
Reference = invoice.InvoiceNo,
Narration = $"Payment against invoice {invoice.InvoiceNo}",
@@ -83,7 +86,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
SalesInvoiceId = invoice.SalesInvoiceId,
Amount = request.Amount,
PaymentDate = now,
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
@@ -102,6 +105,134 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
return Map(payment);
}
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> PayBulkAsync(CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct = default)
{
var ids = request.SalesInvoiceIds.Distinct().ToList();
if (ids.Count == 0 || request.Lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Select at least one invoice and one payment line.", 400);
var invoices = await _invoices.Query().Where(i => ids.Contains(i.SalesInvoiceId)).ToListAsync(ct);
var missing = ids.Except(invoices.Select(i => i.SalesInvoiceId)).ToList();
if (missing.Count > 0)
throw new NotFoundException($"Sales invoice {missing[0]} was not found.");
if (invoices.Select(i => i.CustomerId).Distinct().Count() > 1)
throw new DomainException(ErrorCodes.Validation, "All invoices in one payment must belong to the same customer.", 400);
// The generic cash customer's invoices are paid one at a time, never grouped.
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId)
.FirstOrDefaultAsync(ct);
if (cashCustomerId is not null && invoices[0].CustomerId == cashCustomerId)
throw new DomainException(ErrorCodes.Validation, "Cash customer invoices cannot be paid together.", 400);
foreach (var inv in invoices)
{
if (inv.Status != SalesInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} must be posted before it can be paid.", 409);
if (inv.BalanceAmount <= 0)
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {inv.InvoiceNo} is already fully paid.", 409);
}
var total = request.Lines.Sum(l => l.Amount);
var totalBalance = invoices.Sum(i => i.BalanceAmount);
if (total > totalBalance)
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
$"Payment total {total} exceeds the invoices' combined balance {totalBalance}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var lines = request.Lines.Select(l => (
Line: l,
Account: accounts.FirstOrDefault(a => a.AccountId == l.GlBankAccountId)
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"Bank/cash account {l.GlBankAccountId} was not found.", 404)))
.ToList();
// Oldest invoice first: walk the lines in order, filling each invoice's balance before the next.
var ordered = invoices.OrderBy(i => i.InvoiceDate).ThenBy(i => i.SalesInvoiceId).ToList();
var allocations = new List<(SalesInvoice Invoice, GlBankAccount Account, decimal Amount)>();
var invIdx = 0;
var invRemaining = ordered[0].BalanceAmount;
foreach (var (line, account) in lines)
{
var lineRemaining = line.Amount;
while (lineRemaining > 0)
{
var take = Math.Min(lineRemaining, invRemaining);
allocations.Add((ordered[invIdx], account, take));
lineRemaining -= take;
invRemaining -= take;
if (invRemaining == 0 && invIdx < ordered.Count - 1)
invRemaining = ordered[++invIdx].BalanceAmount;
}
}
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
var paidNos = string.Join(", ", allocations.Select(a => a.Invoice.InvoiceNo).Distinct());
var narration = $"Payment against invoices {paidNos}";
if (narration.Length > 250) narration = narration[..247] + "...";
var payments = await _uow.ExecuteInTransactionAsync(async token =>
{
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var glLines = lines
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
.ToList();
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = paymentDay,
SourceModule = "SALES_INVOICE_PAYMENT",
Reference = request.Reference ?? ordered[0].InvoiceNo,
Narration = narration,
Lines = glLines
}, token);
var entities = new List<SalesInvoicePayment>();
foreach (var (invoice, account, amount) in allocations)
{
var entity = new SalesInvoicePayment
{
SalesInvoiceId = invoice.SalesInvoiceId,
Amount = amount,
PaymentDate = paymentDate,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
invoice.PaidAmount += amount;
invoice.BalanceAmount -= amount;
entities.Add(entity);
}
return entities;
}, ct);
return payments.Select(Map).ToList();
}
/// <summary>
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
/// that day's start.
/// </summary>
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
{
var today = DateOnly.FromDateTime(now);
var day = requested ?? today;
if (day > today.AddDays(1))
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
}
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Invoices have no CashierUserId — Day End matches them to a cashier by CreatedBy.
if (createdBy is not null) q = q.Where(x => x.CreatedBy == createdBy);
// paid=true: nothing left to pay; paid=false: a balance is still due.
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -224,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy);
}
@@ -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)
@@ -81,7 +81,11 @@ export default function CustomersPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerType])
// Branch-wise list, same as Sales / GRN: the header's active branch (a non-admin's own branch);
// an Admin on "All Branches" sees every branch's customers.
const listBranchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
useEffect(() => setPage(1), [query, status, customerType, listBranchId])
function load() {
setError(null)
@@ -90,6 +94,7 @@ export default function CustomersPage() {
q: query || undefined,
status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType,
branchId: listBranchId,
page,
pageSize,
})
@@ -106,10 +111,11 @@ export default function CustomersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, customerType, page, pageSize])
useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
// Every code in use, the hidden cash customer's included, so a generated code never collides.
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
@@ -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 &quot;Add item&quot; 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>
)}
@@ -2,18 +2,27 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { salesDayEndApi } from "@/lib/api/sales-day-end"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -23,6 +32,51 @@ function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
type SalesDateRange = "All" | "Today" | "7d" | "30d"
const salesDateRanges: { value: SalesDateRange; label: string; days: number | null }[] = [
{ value: "All", label: "All time", days: null },
{ value: "Today", label: "Today", days: 1 },
{ value: "7d", label: "7 days", days: 7 },
{ value: "30d", label: "30 days", days: 30 },
]
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
function toLocalIsoDate(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Inclusive range ending today ("7 days" is today plus the 6 days before it) — same as the Sales page. */
function salesDateBounds(range: SalesDateRange): { from?: string; to?: string } {
const days = salesDateRanges.find((r) => r.value === range)?.days
if (!days) return {}
const today = new Date()
const from = new Date(today)
from.setDate(today.getDate() - (days - 1))
return { from: toLocalIsoDate(from), to: toLocalIsoDate(today) }
}
/** Past its due date (`invoiceDate`) with money still owed — compared by calendar day, so an
* invoice due today is not overdue. Same rule as the Sales page. */
function isOverdue(row: SalesInvoiceSummary): boolean {
if (row.status !== "Posted" || row.totals.balanceAmount <= 0) return false
const due = new Date(row.invoiceDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
due.setHours(0, 0, 0, 0)
return due < today
}
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
type PaymentFilter = "All" | "Unpaid" | "Paid"
const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "All", label: "All payments" },
{ value: "Unpaid", label: "Balance due" },
{ value: "Paid", label: "Fully paid" },
]
export default function SalesDayEndPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
@@ -35,6 +89,49 @@ export default function SalesDayEndPage() {
const [report, setReport] = useState<SalesDayEnd | null>(null)
const [closing, setClosing] = useState(false)
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
// business date above): a cashier (null follows the cashier picked above, "All" shows every
// cashier's sales) and a date range.
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
const effectiveSalesCashier = salesCashier ?? cashierUserId
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
const [salesSearchInput, setSalesSearchInput] = useState("")
const [salesQuery, setSalesQuery] = useState("")
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
const [salesPage, setSalesPage] = useState(1)
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
const [salesReload, setSalesReload] = useState(0)
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const salesKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize, salesReload].join("|")
const [salesResult, setSalesResult] = useState<{
key: string
rows: SalesInvoiceSummary[] | null
pagination: PaginationMeta | null
error: string | null
} | null>(null)
const current = salesResult?.key === salesKey ? salesResult : null
const sales = current?.rows ?? null
const salesPagination = current?.pagination ?? null
const salesError = current?.error ?? null
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
// Read through the loaded rows so the pop-up's Pay button follows payments.
const viewRow = viewInvoice ? (sales?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
// Invoice payments are taken here, not on the Sales screens. One invoice at a time via Pay, or
// several of one customer ticked together — the generic cash customer's are paid one at a time.
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payOpen, setPayOpen] = useState(false)
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
useEffect(() => {
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
}, [])
useEffect(() => {
usersApi
.list({ pageSize: 200 })
@@ -62,6 +159,71 @@ export default function SalesDayEndPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate])
// Debounced search; a new term starts again from page 1.
useEffect(() => {
const timeout = setTimeout(() => {
setSalesQuery(salesSearchInput.trim())
setSalesPage(1)
setSelected(new Map())
}, 300)
return () => clearTimeout(timeout)
}, [salesSearchInput])
useEffect(() => {
if (effectiveSalesCashier === null) return
salesApi
.listInvoices({
page: salesPage,
pageSize: salesPageSize,
status: "Posted",
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
q: salesQuery || undefined,
})
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
function reloadSales() {
setSelected(new Map())
setSalesReload((n) => n + 1)
}
// Any change to what the table lists goes back to page 1 and clears the ticks.
function resetSalesView() {
setSalesPage(1)
setSelected(new Map())
}
function changeCashier(userId: number | null) {
setCashierUserId(userId)
setSalesCashier(null)
resetSalesView()
}
function changeSalesCashier(value: number | "All") {
setSalesCashier(value)
resetSalesView()
}
function changeSalesDateRange(value: SalesDateRange) {
setSalesDateRange(value)
resetSalesView()
}
function changeSalesPayment(value: PaymentFilter) {
setSalesPayment(value)
resetSalesView()
}
function changeSalesPageSize(size: number) {
setSalesPageSize(size)
resetSalesView()
}
async function handleClose() {
if (!cashierUserId) return
setClosing(true)
@@ -79,6 +241,26 @@ export default function SalesDayEndPage() {
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
function notSelectableReason(row: SalesInvoiceSummary): string | null {
if (selected.has(row.salesInvoiceId)) return null
if (row.totals.balanceAmount <= 0) return "Already fully paid"
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
return null
}
function toggleSelected(row: SalesInvoiceSummary) {
setSelected((prev) => {
const next = new Map(prev)
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
else next.set(row.salesInvoiceId, row)
return next
})
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -96,7 +278,7 @@ export default function SalesDayEndPage() {
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label>
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
@@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
<InvoiceSummaryPanel invoices={report.invoices} />
</div>
)}
<SalesTable
rows={sales}
error={salesError}
cashiers={cashiers}
cashierFilter={effectiveSalesCashier}
onCashierFilterChange={changeSalesCashier}
dateRange={salesDateRange}
onDateRangeChange={changeSalesDateRange}
paymentFilter={salesPayment}
onPaymentFilterChange={changeSalesPayment}
search={salesSearchInput}
onSearchChange={setSalesSearchInput}
pagination={salesPagination}
onPageChange={setSalesPage}
onPageSizeChange={changeSalesPageSize}
onView={setViewInvoice}
onPay={(row) => {
setPayInvoice(row)
setPayOpen(true)
}}
selected={selected}
notSelectableReason={notSelectableReason}
onToggle={toggleSelected}
onClearSelection={() => setSelected(new Map())}
onPaySelected={() => setBulkPayOpen(true)}
/>
{/* The invoice pop-up (View action or row click). Its Pay button follows the reloaded row,
and the sheet itself re-reads after a payment via refreshKey. */}
<SalesInvoicePrintDialog
invoiceId={viewRow?.salesInvoiceId ?? null}
invoiceNo={viewRow?.invoiceNo}
open={viewRow !== null}
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
refreshKey={salesReload}
actions={
viewRow && viewRow.totals.balanceAmount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setPayInvoice(viewRow)
setPayOpen(true)
}}
aria-label={`Pay ${viewRow.invoiceNo}`}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
) : null
}
/>
{/* After a payment the table is re-read, so Paid / Remaining come from the server. */}
<SalesInvoicePaymentDialog invoice={payInvoice} open={payOpen} onOpenChange={setPayOpen} onPaid={reloadSales} />
<SalesInvoiceBulkPaymentDialog
invoices={[...selected.values()]}
open={bulkPayOpen}
onOpenChange={setBulkPayOpen}
onConfirmed={() => {}}
onPaid={reloadSales}
/>
</div>
)
}
// One page of Approved (Posted) sales invoices. Every filter (cashier or all cashiers, date range,
// payment status, search) is applied server-side, so the pager and counts cover all matches.
function SalesTable({
rows,
error,
cashiers,
cashierFilter,
onCashierFilterChange,
dateRange,
onDateRangeChange,
paymentFilter,
onPaymentFilterChange,
search,
onSearchChange,
pagination,
onPageChange,
onPageSizeChange,
onView,
onPay,
selected,
notSelectableReason,
onToggle,
onClearSelection,
onPaySelected,
}: {
rows: SalesInvoiceSummary[] | null
error: string | null
cashiers: ManagedUser[]
cashierFilter: number | "All" | null
onCashierFilterChange: (value: number | "All") => void
dateRange: SalesDateRange
onDateRangeChange: (value: SalesDateRange) => void
paymentFilter: PaymentFilter
onPaymentFilterChange: (value: PaymentFilter) => void
search: string
onSearchChange: (value: string) => void
pagination: PaginationMeta | null
onPageChange: (page: number) => void
onPageSizeChange: (size: number) => void
onView: (row: SalesInvoiceSummary) => void
onPay: (row: SalesInvoiceSummary) => void
selected: Map<number, SalesInvoiceSummary>
notSelectableReason: (row: SalesInvoiceSummary) => string | null
onToggle: (row: SalesInvoiceSummary) => void
onClearSelection: () => void
onPaySelected: () => void
}) {
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
function renderSelectBox(row: SalesInvoiceSummary) {
const reason = notSelectableReason(row)
return (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={reason !== null}
title={reason ?? "Select to pay together"}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => onToggle(row)}
/>
)
}
// View opens the invoice pop-up; Pay shows while a balance is still due.
function renderActions(row: SalesInvoiceSummary) {
return (
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onView(row)}
aria-label={`View ${row.invoiceNo}`}
title="View invoice"
>
<FileText className="size-4" />
</Button>
{row.totals.balanceAmount > 0 && (
<Button type="button" variant="outline" size="sm" onClick={() => onPay(row)} aria-label={`Pay ${row.invoiceNo}`}>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)
}
const showCashier = cashierFilter === "All"
const cashierNameById = new Map(cashiers.map((c) => [c.userId, c.displayName]))
const visible = rows ?? []
const filtered = paymentFilter !== "All" || search.trim().length > 0
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div>
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select<number | "All" | null>
value={cashierFilter}
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
<SelectValue placeholder="Cashier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All cashiers</SelectItem>
{cashiers.map((c) => (
<SelectItem key={c.userId} value={c.userId}>{c.displayName}</SelectItem>
))}
</SelectContent>
</Select>
<Select<SalesDateRange>
value={dateRange}
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{salesDateRanges.map((r) => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<PaymentFilter>
value={paymentFilter}
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentFilters.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="h-10 text-sm"
/>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
{!error && rows === null && <Skeleton className="h-32 w-full" />}
{!error && rows !== null && visible.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{filtered ? "No sales match these filters." : "No approved sales in this period."}
</p>
)}
{selected.size > 0 && (
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
Clear
</Button>
<Button type="button" size="sm" onClick={onPaySelected}>
<CircleDollarSign className="size-4" />
Pay selected
</Button>
</div>
</div>
)}
{!error && rows !== null && visible.length > 0 && (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{visible.map((row) => (
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
{renderSelectBox(row)}
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate font-medium">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</span>
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
</span>
</button>
{renderActions(row)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row) => (
<TableRow
key={row.salesInvoiceId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
onView(row)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
{renderSelectBox(row)}
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
{showCashier && (
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell
className={cn(
"px-3 py-3.5 text-right tabular-nums",
row.status !== "Cancelled" && row.totals.balanceAmount > 0 && "font-semibold text-destructive"
)}
>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
{renderActions(row)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<PaginationFooter
pagination={pagination}
onPageChange={onPageChange}
pageSizeOptions={SALES_PAGE_SIZE_OPTIONS}
onPageSizeChange={onPageSizeChange}
/>
)}
{/* Invoices counts every match; the amounts are for the rows on this page. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
<Stat label="Net (this page)" value={netTotal} emphasize />
<Stat label="Paid (this page)" value={paidTotal} />
<Stat label="Remaining (this page)" value={balanceTotal} />
</div>
</>
)}
</div>
)
}
@@ -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"
@@ -58,6 +58,22 @@ const blankLine = (key: string): Line => ({
parentLineId: null,
})
/** Comparable form of the draft's editable fields (line keys excluded) — for "unsaved changes". */
function draftSnapshot(customerId: number | null, warehouseId: number | null, invoiceType: SalesInvoiceType, lines: Line[]): string {
return JSON.stringify({
customerId,
warehouseId,
invoiceType,
lines: lines.map((line) => ({
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice ?? null,
})),
})
}
/** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
function statusWord(status: SalesInvoiceStatus): string {
return status === "Posted" ? "approved" : status.toLowerCase()
@@ -77,6 +93,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
// The draft as last loaded/saved — Save as Draft is enabled only when the form differs from it.
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
@@ -95,6 +113,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
// editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null)
// The shared cash customer — offered on every branch's Bill To picker.
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -103,7 +123,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 }),
@@ -117,6 +138,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setAllowDiscounts(config?.allowDiscounts ?? true)
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
setCashCustomerId(config?.cashCustomerId ?? null)
setCustomers(customerRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
@@ -128,24 +150,24 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId)
setInvoiceType(doc.data.invoiceType)
setLines(
doc.data.lines.map((line) => ({
key: String(line.salesInvoiceLineId),
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: line.qty,
freeQty: line.freeQty,
unitPrice: line.unitPrice,
allowManualPriceOverride: true,
discountMode: line.discountMode,
discountPct: line.discountPct,
discountAmount: line.discountAmount,
discountValue: 0,
taxPct: line.taxPct,
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId,
}))
)
const loadedLines: Line[] = doc.data.lines.map((line) => ({
key: String(line.salesInvoiceLineId),
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: line.qty,
freeQty: line.freeQty,
unitPrice: line.unitPrice,
allowManualPriceOverride: true,
discountMode: line.discountMode,
discountPct: line.discountPct,
discountAmount: line.discountAmount,
discountValue: 0,
taxPct: line.taxPct,
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId,
}))
setLines(loadedLines)
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
})
.catch((err) => setError(errorMessage(err)))
}, [invoiceId, resolvedParams.id])
@@ -183,6 +205,21 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
const isDraft = invoice?.status === "Draft"
// Branch-wise Bill To picker: customers of the invoice's own branch, plus the shared cash customer
// and whoever the draft is already billed to (so an older cross-branch draft still shows its customer).
const branchCustomers = useMemo(
() =>
customers.filter(
(c) =>
!invoice ||
c.branchId === invoice.branchId ||
c.customerId === cashCustomerId ||
c.customerCode === "CUST-CASH" ||
c.customerId === invoice.customerId,
),
[customers, invoice, cashCustomerId],
)
const isDirty = savedSnapshot !== null && draftSnapshot(customerId, warehouseId, invoiceType, lines) !== savedSnapshot
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
// With selection off, saving moves the draft onto the default sales warehouse (server-forced),
@@ -232,7 +269,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)
}
@@ -240,11 +277,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function save() {
if (!customerId || !warehouseId || !etag || !invoice) return
/** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */
async function save(quiet = false): Promise<boolean> {
if (!customerId || !warehouseId || !etag || !invoice) return false
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
return
return false
}
setSaving(true)
@@ -277,25 +315,37 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
)
setInvoice(updated.data)
setEtag(updated.etag)
toast.success("Invoice saved", updated.data.invoiceNo)
setSavedSnapshot(draftSnapshot(customerId, warehouseId, invoiceType, lines))
if (!quiet) {
// Save as Draft is done with the invoice — back to the Sales list (drafts table).
toast.success("Draft saved", updated.data.invoiceNo)
router.push("/dashboard/sales/invoices")
}
return true
} catch (err) {
setError(errorMessage(err))
return false
} finally {
setSaving(false)
}
}
// Approve = save the draft's current edits, then post it. Only an approved invoice takes payments.
async function post() {
if (!postingCheck?.canPost) {
setError("Resolve stock shortages before posting this invoice.")
setError("Resolve stock shortages before approving this invoice.")
return
}
setBusy("post")
if (isDirty && !(await save(true))) {
setBusy(null)
return
}
setError(null)
try {
const posted = await salesApi.postInvoice(invoiceId)
setInvoice(posted)
toast.success("Invoice posted", posted.invoiceNo)
toast.success("Invoice approved", posted.invoiceNo)
router.refresh()
} catch (err) {
setError(errorMessage(err))
@@ -386,11 +436,67 @@ 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"}`}>
{/* A draft reopens in edit mode right here in the invoice body: header fields and lines are
editable in place; Save as Draft / Approve invoice sit under Actions. */}
{isDraft ? (
<div className="grid gap-4 py-5 md:grid-cols-3 print:hidden">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</Label>
<select
value={customerId ? String(customerId) : ""}
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select customer</option>
{branchCustomers.map((c) => (
<option key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</Label>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invoice type</Label>
<select
value={invoiceType}
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
</div>
</div>
) : null}
<div className={cn("grid gap-6 py-5 md:grid-cols-2", isDraft && "hidden print:grid")}>
<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,26 +505,93 @@ 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">
{isDraft ? (
<div className="print:hidden">
<SuggestInput
options={itemOptions}
onSelect={(v) => addItem(Number(v))}
placeholder="Add item — type SKU or name…"
emptyText="No items match."
className="mb-3 w-full sm:max-w-md"
aria-label="Add item"
/>
<div className="overflow-x-auto border-y">
<table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr>
<th className="px-4 py-2 text-left font-medium">Item</th>
<th className="px-4 py-2 text-left font-medium">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="min-w-64 px-4 py-3">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="w-28 px-4 py-3">
<input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
aria-label="Qty"
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
/>
</td>
{allowFreeItems && (
<td className="w-28 px-4 py-3">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
aria-label="Free qty"
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
/>
</td>
)}
<td className="w-32 px-4 py-3">
<input
type="number"
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
aria-label="Unit price"
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
/>
</td>
<td className="w-24 px-4 py-3 text-right">
<button
type="button"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
aria-label="Remove line"
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
>
<Minus className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-muted-foreground">Totals below update when the draft is saved.</p>
</div>
) : null}
<div className={cn("overflow-x-auto border-y", isDraft && "hidden print:block")}>
<table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr>
@@ -452,6 +625,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>
@@ -524,23 +719,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="mt-6 border-t pt-5 print:hidden">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Actions</h3>
<span className="text-sm text-muted-foreground">{isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
<span className="text-sm text-muted-foreground">
{isDraft ? "Edit the draft above, then save as draft or approve. Payments open once approved." : "Only draft invoices are editable."}
</span>
</div>
{isDraft ? (
<>
<SuggestInput
options={itemOptions}
onSelect={(v) => addItem(Number(v))}
placeholder="Add item — type SKU or name…"
emptyText="No items match."
className="mb-4 w-full sm:max-w-md"
aria-label="Add item"
/>
<div className="mb-4 flex flex-wrap gap-2">
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
<button type="button" onClick={() => save()} disabled={!isDirty || saving || busy !== null} title={isDirty ? undefined : "No changes to save"} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
<Save className="size-4" />
{saving ? "Saving..." : "Save invoice"}
{saving && busy === null ? "Saving..." : "Save as Draft"}
</button>
{postingCheck && !postingCheck.canPost ? (
<Link
@@ -553,151 +742,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
) : null}
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve invoice"}
</button>
<button type="button" onClick={cancel} disabled={busy !== null} className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-300 bg-rose-50 px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 disabled:opacity-50">
<X className="size-4" />
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
</button>
</div>
<div className="overflow-x-auto border">
<table className="w-full text-sm">
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Item</th>
<th className="px-4 py-2 text-left font-medium">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="px-4 py-3 min-w-64">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
{allowFreeItems && (
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
)}
<td className="px-4 py-3 w-32">
<input
type="number"
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(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">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</td>
<td className="px-4 py-3 w-24 text-right">
<button
type="button"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
>
<Minus className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<select
value={customerId ? String(customerId) : ""}
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select customer</option>
{customers.map((c) => (
<option key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice type</Label>
<select
value={invoiceType}
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
</div>
</div>
<div className="mt-5 flex justify-end">
<button
type="button"
onClick={post}
disabled={!canPost}
className="inline-flex h-10 items-center gap-2 rounded-full bg-black px-5 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40"
>
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
</button>
</div>
</>
) : (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
@@ -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()
@@ -134,7 +137,8 @@ function NewSalesInvoiceContent() {
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Which action is in flight — Save as Draft or Approve invoice.
const [saving, setSaving] = useState<"draft" | "approve" | null>(null)
const [scanError, setScanError] = useState<string | null>(null)
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
@@ -156,10 +160,28 @@ function NewSalesInvoiceContent() {
const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id)
return w ? `${w.code} — ${w.name}` : null
}
// The sale's branch (see the branch picker below). Declared here so the customer list can use it.
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
// Branch-wise, like the warehouses below: only customers created under the sale's branch. The
// cash customer is shared by every branch and is added to the dropdown separately.
const regularCustomers = useMemo(
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
[customers, salesConfig],
() =>
customers.filter(
(c) => c.customerId !== salesConfig?.cashCustomerId && (effectiveBranchId === null || c.branchId === effectiveBranchId),
),
[customers, salesConfig, effectiveBranchId],
)
// 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)
@@ -233,7 +255,6 @@ function NewSalesInvoiceContent() {
// branch to pin to and the Admin must pick one explicitly for this sale.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId]
@@ -248,6 +269,12 @@ function NewSalesInvoiceContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
// Same for the customer: one from another branch is cleared when the branch changes (the
// shared cash customer always stays valid).
const customerOutOfBranch =
customerId !== null && customerId !== cashCustomerId && customers.length > 0 && !regularCustomers.some((c) => c.customerId === customerId)
if (customerOutOfBranch) setCustomerId(null)
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
}
@@ -358,7 +385,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 +463,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(
@@ -590,7 +680,9 @@ function NewSalesInvoiceContent() {
const netTotal = Math.max(0, grossTotal - discountTotal)
const payableTotal = netTotal + taxTotal
async function submit() {
/** "draft" saves the sale as a Draft (still editable); "approve" saves it and approves (posts) it
* straight away — only an approved invoice can take payments. */
async function submit(mode: "draft" | "approve") {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
if (lines.length === 0) return setSubmitError("Add at least one item.")
@@ -612,7 +704,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,
@@ -624,12 +716,25 @@ function NewSalesInvoiceContent() {
})),
}
setSaving(true)
setSaving(mode)
setSubmitError(null)
try {
const created = await salesApi.createInvoice(payload)
toast.success("Invoice created", created.data.invoiceNo)
router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`)
const detailUrl = `/dashboard/sales/invoices/${created.data.salesInvoiceId}`
if (mode === "approve") {
try {
const posted = await salesApi.postInvoice(created.data.salesInvoiceId)
toast.success("Invoice approved", posted.invoiceNo)
} catch (err) {
// The sale is saved — it just stays a Draft; its page shows why and can approve it later.
toast.error(`Saved as draft ${created.data.invoiceNo} — could not approve`, errorMessage(err))
}
router.push(detailUrl)
} else {
// Save as Draft goes straight back to the Sales list (drafts table).
toast.success("Draft saved", created.data.invoiceNo)
router.push("/dashboard/sales/invoices")
}
} catch (err) {
if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) {
router.push(`/login?next=/dashboard/sales/invoices/new`)
@@ -637,7 +742,7 @@ function NewSalesInvoiceContent() {
}
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
setSaving(null)
}
}
@@ -661,7 +766,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 +780,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 +1042,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 +1200,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"
/>
@@ -1191,8 +1306,11 @@ function NewSalesInvoiceContent() {
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => submit("draft")} disabled={saving !== null}>
{saving === "draft" ? "Saving…" : "Save as Draft"}
</Button>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => submit("approve")} disabled={saving !== null}>
{saving === "approve" ? "Approving…" : "Approve invoice"}
</Button>
</div>
</div>
@@ -3,15 +3,28 @@
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 {
ArrowDown,
ArrowUp,
ArrowUpDown,
CalendarDays,
CheckCircle2,
CircleDollarSign,
FileText,
Loader2,
Plus,
Receipt,
Tag,
} from "lucide-react"
import { salesApi } from "@/lib/api/sales"
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 { DateRangePicker } from "@/components/ui/date-picker"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Field, FieldLabel } from "@/components/ui/field"
@@ -20,28 +33,43 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "gross" | "discount" | "net"
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 })
}
type DateRange = "All" | "Today" | "7d" | "30d"
type DateRange = "All" | "Today" | "7d" | "30d" | "Custom"
type CustomRange = { from: Date | undefined; to?: Date | undefined }
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
{ value: "All", label: "All time", days: null },
{ value: "Today", label: "Today", days: 1 },
{ value: "7d", label: "7 days", days: 7 },
{ value: "30d", label: "30 days", days: 30 },
{ value: "Custom", label: "Custom range", days: null },
]
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
@@ -49,8 +77,13 @@ function toIsoDate(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. A custom range with
* only its start picked so far covers just that day; with nothing picked it doesn't filter. */
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
if (range === "Custom") {
if (!custom?.from) return {}
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
}
const days = dateRanges.find((r) => r.value === range)?.days
if (!days) return {}
const today = new Date()
@@ -62,23 +95,28 @@ function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
{ value: "Posted", label: "Approved" },
{ 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
}
export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRange>("All")
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
// Strings, so the effects below re-run only when the actual bounds change.
const { from: dateFrom, to: dateTo } = dateRangeBounds(dateRange, customRange)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
@@ -87,9 +125,17 @@ export default function SalesInvoicesPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
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)
// This screen lists drafts only; the row, its invoice no. and the invoice icon all open the
// invoice page, where a draft is editable.
const router = useRouter()
function openDraft(row: SalesInvoiceSummary) {
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}
// Ticked drafts for "Approve selected" — cleared whenever the list's filters change.
const [selected, setSelected] = useState<Set<number>>(new Set())
const [bulkApproving, setBulkApproving] = useState(false)
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
@@ -104,7 +150,8 @@ export default function SalesInvoicesPage() {
useEffect(() => {
setPage(1)
setPageSize(rowsPerPage)
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
setSelected(new Set())
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
@@ -117,11 +164,13 @@ export default function SalesInvoicesPage() {
.listInvoices({
page,
pageSize,
status: status === "All" ? undefined : status,
// Drafts only — approved invoices are worked (and paid) from the Day End screen.
status: "Draft",
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
warehouseId,
...dateRangeBounds(dateRange),
from: dateFrom,
to: dateTo,
})
.then((res) => {
setRows(res.items)
@@ -129,7 +178,7 @@ export default function SalesInvoicesPage() {
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
@@ -140,7 +189,13 @@ export default function SalesInvoicesPage() {
setConfirmingId(row.salesInvoiceId)
try {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
// No longer a draft, so it leaves this list — it's now on Day End.
setRows((prev) => prev?.filter((r) => r.salesInvoiceId !== row.salesInvoiceId) ?? prev)
setSelected((prev) => {
const next = new Set(prev)
next.delete(row.salesInvoiceId)
return next
})
toast.success("Invoice confirmed", posted.invoiceNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
@@ -149,19 +204,48 @@ export default function SalesInvoicesPage() {
}
}
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setRows(
(prev) =>
prev?.map((r) =>
r.salesInvoiceId === payment.salesInvoiceId
? {
...r,
status: newStatus,
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
}
: r
) ?? prev
)
// No bulk endpoint — each draft is approved in turn (each posts its own stock + GL), so one that
// fails (e.g. short stock) stays ticked in the list while the rest go through.
async function handleBulkApprove() {
const ids = [...selected]
setBulkApproving(true)
const approved: number[] = []
const failed: string[] = []
for (const id of ids) {
const row = rows?.find((r) => r.salesInvoiceId === id)
try {
await salesApi.postInvoice(id)
approved.push(id)
} catch (err) {
failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`)
}
}
setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev)
setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id))))
setBulkApproving(false)
if (approved.length > 0) toast.success("Invoices approved", `${approved.length} invoice(s) approved.`)
if (failed.length > 0) toast.error(`${failed.length} invoice(s) could not be approved`, failed.join("\n"))
}
function toggleSelected(id: number) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// 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 +255,51 @@ export default function SalesInvoicesPage() {
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
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 "gross": return r.totals.subtotal
case "discount": return r.totals.discountTotal
case "net": return r.totals.grandTotal
}
}
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 draft on screen (the loaded page, after search).
const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId))
function toggleAll(checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
return next
})
}
function renderSelectBox(row: SalesInvoiceSummary) {
return (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={bulkApproving}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => toggleSelected(row.salesInvoiceId)}
/>
)
}
const hasFilters = dateFrom !== undefined || 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 +309,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
@@ -204,28 +316,26 @@ export default function SalesInvoicesPage() {
</div>
</div>
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
{/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every
matching draft; the amounts are over the invoices currently listed (filters + loaded page),
passed as strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats ? (
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
) : (
<Skeleton className="h-22.5 rounded-xl" />
)}
{rows !== null ? (
<>
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<FilterBar
className="border-b px-4 py-4"
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Filter by customer or invoice number"
@@ -235,7 +345,10 @@ 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={customerId ? String(customerId) : "All"}
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
>
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All customers</SelectItem>
@@ -257,22 +370,6 @@ export default function SalesInvoicesPage() {
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter>
value={status}
onValueChange={(v) => setStatus(v ?? "All")}
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
<ListFilter className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{tabs.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<DateRange>
value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")}
@@ -288,9 +385,30 @@ export default function SalesInvoicesPage() {
))}
</SelectContent>
</Select>
{dateRange === "Custom" && (
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
)}
</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> draft{selected.size === 1 ? "" : "s"} selected
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" disabled={bulkApproving} onClick={() => setSelected(new Set())}>
Clear
</Button>
<Button type="button" size="sm" disabled={bulkApproving} onClick={handleBulkApprove}>
{bulkApproving ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{bulkApproving ? "Approving…" : "Approve 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 && (
@@ -312,6 +430,15 @@ export default function SalesInvoicesPage() {
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
<label className="flex items-center gap-3 px-1 text-sm text-muted-foreground">
<Checkbox
checked={allTicked}
disabled={bulkApproving}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
/>
Select all
</label>
{visibleRows.map((row) => (
<div
key={row.salesInvoiceId}
@@ -319,17 +446,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}`)
openDraft(row)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
if (e.key === "Enter") openDraft(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">
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
<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,16 +475,19 @@ 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>
<p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{row.totals.subtotal.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>
<p className="text-muted-foreground">Discount</p>
<p className="font-medium text-foreground">{row.totals.discountTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
@@ -361,33 +496,27 @@ export default function SalesInvoicesPage() {
</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)}>
<Printer className="size-4" />
Print
<Button
type="button"
variant="outline"
size="icon"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
>
<FileText className="size-4" />
</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"}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
</div>
@@ -402,45 +531,69 @@ 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={allTicked}
disabled={bulkApproving}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
title="Select all"
/>
</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="Gross" active={sortKey === "gross"} order={sortOrder} onClick={() => toggleSort("gross")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Discount" active={sortKey === "discount"} order={sortOrder} onClick={() => toggleSort("discount")} alignRight /></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-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}`)
openDraft(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
>
{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">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
@@ -448,41 +601,29 @@ 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)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
>
<Printer className="size-4" />
<FileText className="size-4" />
</Button>
{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"}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
</div>
@@ -494,7 +635,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}
@@ -506,14 +647,6 @@ export default function SalesInvoicesPage() {
</>
)}
</div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<SalesInvoicePrintDialog
invoiceId={printInvoice?.salesInvoiceId ?? null}
invoiceNo={printInvoice?.invoiceNo}
open={printInvoice !== null}
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
/>
</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&apos;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&apos;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&apos;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>
))
)}
+6
View File
@@ -10,6 +10,12 @@ 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
/** Branch-wise list (same as Sales / GRN): only customers created under this branch, plus the
* shared cash customer. Omit for every branch. */
branchId?: number
}
export const customersApi = {
+15
View File
@@ -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 })
},
}
+18
View File
@@ -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,
@@ -33,6 +35,12 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
from?: string
to?: string
/** UserId who created the invoice — Day End's cashier match. */
createdBy?: number
/** true = fully paid, false = balance still due. */
paid?: boolean
/** Matches invoice number or customer name. */
q?: string
}
export interface ListSalesSlipsParams extends SalesListFilterParams {
@@ -58,6 +66,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 +100,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`)
+18 -1
View File
@@ -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)
}
+2
View File
@@ -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",
+19
View File
@@ -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
}
+19
View File
@@ -51,6 +51,8 @@ export interface SalesInvoiceSummary {
totals: SalesInvoiceTotals
createdAt: string
glJournalNo: string | null
/** UserId who created it — Day End's cashier. */
createdBy: number
}
export interface SalesInvoicePostingIssue {
@@ -102,6 +104,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 +170,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 {