Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ec3b4c65 | |||
| 3d3dbbbb59 |
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnConfigDiscountsVat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowVat",
|
||||
table: "grn_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowDiscounts",
|
||||
table: "grn_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowVat",
|
||||
table: "grn_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1531,53 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("grns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AllowDiscounts")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowVat")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowWarehouseSelection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("DefaultWarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("DefaultWarehouseId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("grn_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_grn_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Property<int>("GrnLineId")
|
||||
@@ -7211,6 +7258,23 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
|
||||
@@ -98,6 +98,7 @@ builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
|
||||
@@ -16,19 +16,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);
|
||||
}
|
||||
@@ -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 "Add item" above, or scan a barcode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked editable cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -853,19 +913,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -949,38 +997,44 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
{allowDiscounts && (
|
||||
<div>
|
||||
<Label className="text-sm">Disc %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<div>
|
||||
<Label className="text-sm">VAT %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1021,14 +1075,14 @@ export default function NewGrnPage() {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 min-w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
{allowDiscounts && <TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>}
|
||||
{allowVat && <TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>}
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
@@ -1051,19 +1105,7 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-11 items-center text-sm">{item ? `${item.sku} — ${item.name}` : "—"}</div>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
@@ -1131,39 +1173,45 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
{allowDiscounts && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
disabled={headerDiscountPct.trim() !== ""}
|
||||
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
{allowVat && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
disabled={headerVatPct.trim() !== ""}
|
||||
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
const c = computeLine(effectiveLine(line))
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
@@ -1224,6 +1272,24 @@ export default function NewGrnPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItemLine(Number(v))}
|
||||
onEnterWithoutMatch={handleGrnScan}
|
||||
browsable
|
||||
keepOpenOnSelect
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell colSpan={9 + (allowDiscounts ? 1 : 0) + (allowVat ? 1 : 0)} className="px-3 py-3 align-middle text-sm text-muted-foreground">
|
||||
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1350,9 +1416,9 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="text-muted-foreground">Document total{allowVat ? " (incl. VAT)" : ""}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
{lines.reduce((sum, l) => sum + computeLine(effectiveLine(l)).lineTotal, 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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's tax rate), plus a
|
||||
whole-GRN VAT %. Off: both are hidden and every line is received at 0% VAT, even
|
||||
for items that have a tax rate.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
||||
<div><span className="text-muted-foreground">Grand total: </span><span className={layout.totalsHighlight}>{formatAmount(invoice.totals.grandTotal)}</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"} ${layout.infoGrid}`}>
|
||||
<div className={`grid md:grid-cols-2 ${layout.infoGrid}`}>
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Vendor</div>
|
||||
<div className={layout.cardValue}>{invoice.vendorSnapshotName}</div>
|
||||
@@ -111,16 +111,6 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
||||
<div className="mt-2 text-sm text-muted-foreground">Vendor invoice date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -149,6 +139,18 @@ export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals sit under the GRN lines, right-aligned, like an invoice footer. */}
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={`ml-auto mt-4 w-full max-w-xs ${layout.card}`}>
|
||||
<div className={layout.cardLabel}>Totals</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
customersApi.list({ pageSize: 200 }),
|
||||
customersApi.list({ pageSize: 200, includeCashCustomer: true }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
@@ -125,7 +125,6 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Customer</div>
|
||||
<div className={layout.cardValue}>{invoice.customerSnapshotName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ItemReorderSetting, StockNature } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Row = { key: number; warehouseId: string; reorderPoint: string; reorderQty: string }
|
||||
|
||||
const toRows = (settings: ItemReorderSetting[]): Row[] =>
|
||||
settings.map((s, i) => ({ key: i, warehouseId: String(s.warehouseId), reorderPoint: String(s.reorderPoint), reorderQty: String(s.reorderQty) }))
|
||||
|
||||
interface ItemReorderPanelProps {
|
||||
itemId: number
|
||||
stockNature: StockNature
|
||||
/** The item's saved rules — the panel edits a copy and calls `onSaved` with the result. */
|
||||
settings: ItemReorderSetting[]
|
||||
warehouses: { warehouseId: number; code: string; name: string }[]
|
||||
uomName: string
|
||||
onSaved: (settings: ItemReorderSetting[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-warehouse reorder rules (FR-MD-05): when available stock at a warehouse drops to or below
|
||||
* its reorder point, the item shows on Stock → Reorder Alerts with the reorder qty as the
|
||||
* suggested requisition. A warehouse without a row is never checked. Saving replaces the whole
|
||||
* set (PUT /items/{id}/reorder), so removing a row and saving deletes that rule.
|
||||
*/
|
||||
export function ItemReorderPanel({ itemId, stockNature, settings, warehouses, uomName, onSaved }: ItemReorderPanelProps) {
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(settings))
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const savedKey = JSON.stringify(settings.map((s) => [s.warehouseId, s.reorderPoint, s.reorderQty]).sort())
|
||||
const currentKey = JSON.stringify(rows.map((r) => [Number(r.warehouseId), Number(r.reorderPoint), Number(r.reorderQty)]).sort())
|
||||
const dirty = savedKey !== currentKey
|
||||
const usedWarehouses = new Set(rows.map((r) => r.warehouseId))
|
||||
|
||||
function update(key: number, patch: Partial<Row>) {
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
// Start on the first warehouse that doesn't have a rule yet.
|
||||
const free = warehouses.find((w) => !usedWarehouses.has(String(w.warehouseId)))
|
||||
const key = Math.max(-1, ...rows.map((r) => r.key)) + 1
|
||||
setRows((prev) => [...prev, { key, warehouseId: free ? String(free.warehouseId) : "", reorderPoint: "", reorderQty: "" }])
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const next: Record<string, string> = {}
|
||||
const seen = new Set<string>()
|
||||
for (const r of rows) {
|
||||
if (!r.warehouseId) next[`wh-${r.key}`] = "Select a warehouse"
|
||||
else if (seen.has(r.warehouseId)) next[`wh-${r.key}`] = "This warehouse already has a rule"
|
||||
seen.add(r.warehouseId)
|
||||
const point = Number(r.reorderPoint)
|
||||
if (r.reorderPoint === "" || Number.isNaN(point) || point < 0) next[`pt-${r.key}`] = "0 or more"
|
||||
const qty = Number(r.reorderQty)
|
||||
if (r.reorderQty === "" || Number.isNaN(qty) || qty <= 0) next[`qty-${r.key}`] = "More than 0"
|
||||
}
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await itemsApi.updateReorder(itemId, {
|
||||
settings: rows.map((r) => ({ warehouseId: Number(r.warehouseId), reorderPoint: Number(r.reorderPoint), reorderQty: Number(r.reorderQty) })),
|
||||
})
|
||||
setRows(toRows(res.settings))
|
||||
onSaved(res.settings)
|
||||
toast.success("Reorder settings saved", res.settings.length === 0 ? "No warehouse is watched for this item now." : `${res.settings.length} warehouse rule${res.settings.length === 1 ? "" : "s"}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not save reorder settings", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4.5 text-warning" />
|
||||
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When available stock at a warehouse falls to or below its reorder point, this item appears on Stock → Reorder Alerts
|
||||
with the reorder qty as the suggested requisition. Warehouses without a rule aren't watched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stockNature !== "Stocked" && (
|
||||
<p className="rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||
This is a {stockNature === "Service" ? "service" : "non-stocked"} item — it never holds stock, so reorder rules won't trigger.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed py-6 text-center text-sm text-muted-foreground">No reorder rules yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="hidden grid-cols-[1fr_9rem_9rem_2.25rem] gap-2 text-sm font-medium sm:grid">
|
||||
<span>Warehouse</span>
|
||||
<span>Reorder point ({uomName})</span>
|
||||
<span>Reorder qty ({uomName})</span>
|
||||
<span />
|
||||
</div>
|
||||
{rows.map((r, idx) => (
|
||||
<div key={r.key} className="grid grid-cols-1 items-start gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_9rem_9rem_2.25rem] sm:rounded-none sm:border-0 sm:p-0">
|
||||
<div>
|
||||
<Select<string>
|
||||
value={r.warehouseId}
|
||||
onValueChange={(v) => update(r.key, { warehouseId: v ?? "" })}
|
||||
items={warehouses.map((w) => ({ value: String(w.warehouseId), label: `${w.code} — ${w.name}` }))}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={`Warehouse, rule ${idx + 1}`} aria-invalid={!!errors[`wh-${r.key}`]}>
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} disabled={usedWarehouses.has(String(w.warehouseId)) && r.warehouseId !== String(w.warehouseId)}>
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors[`wh-${r.key}`] ? { message: errors[`wh-${r.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="Reorder point"
|
||||
value={r.reorderPoint}
|
||||
onChange={(e) => update(r.key, { reorderPoint: e.target.value })}
|
||||
aria-label={`Reorder point, rule ${idx + 1}`}
|
||||
aria-invalid={!!errors[`pt-${r.key}`]}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<FieldError errors={[errors[`pt-${r.key}`] ? { message: errors[`pt-${r.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="Reorder qty"
|
||||
value={r.reorderQty}
|
||||
onChange={(e) => update(r.key, { reorderQty: e.target.value })}
|
||||
aria-label={`Reorder qty, rule ${idx + 1}`}
|
||||
aria-invalid={!!errors[`qty-${r.key}`]}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<FieldError errors={[errors[`qty-${r.key}`] ? { message: errors[`qty-${r.key}`] } : undefined]} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRows((prev) => prev.filter((x) => x.key !== r.key))}
|
||||
aria-label={`Remove rule ${idx + 1}`}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRow} disabled={rows.length >= warehouses.length}>
|
||||
<Plus className="size-4" />
|
||||
Add warehouse rule
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{dirty && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setRows(toRows(settings)); setErrors({}) }} disabled={saving}>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. */
|
||||
export type PaymentLine = { key: number; glBankAccountId: string; amount: string }
|
||||
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/** Today's date in the user's own timezone, as `YYYY-MM-DD` (what `<input type="date">` uses). */
|
||||
export function localToday(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function paymentLinesTotal(lines: PaymentLine[]): number {
|
||||
return round2(lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0))
|
||||
}
|
||||
|
||||
/** Per-line errors (`account-<key>` / `amount-<key>`) plus `lines` for the total, keyed for the editor. */
|
||||
export function validatePaymentLines(lines: PaymentLine[], balance: number): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
lines.forEach((l) => {
|
||||
const amount = Number(l.amount)
|
||||
if (!l.glBankAccountId) errors[`account-${l.key}`] = "Select a payment type"
|
||||
if (!l.amount || Number.isNaN(amount) || amount <= 0) errors[`amount-${l.key}`] = "Enter a valid amount"
|
||||
})
|
||||
const total = paymentLinesTotal(lines)
|
||||
if (lines.length === 0) errors.lines = "Add at least one payment line"
|
||||
else if (total > balance) errors.lines = `Total ${formatAmount(total)} exceeds the balance (${formatAmount(balance)})`
|
||||
return errors
|
||||
}
|
||||
|
||||
interface PaymentLinesEditorProps {
|
||||
lines: PaymentLine[]
|
||||
onChange: (lines: PaymentLine[]) => void
|
||||
accounts: CashAndBankAccountDto[] | null
|
||||
/** What is owed — the fixed "Total"; Paid is the lines' sum and Remaining the difference. */
|
||||
balance: number
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
/** Payment type + paid amount rows (add/remove), with a Total / Paid amount / Remaining summary. */
|
||||
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors }: PaymentLinesEditorProps) {
|
||||
const paid = paymentLinesTotal(lines)
|
||||
const remaining = round2(balance - paid)
|
||||
|
||||
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
||||
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
// A new line starts with whatever is still unpaid, so a two-way split is one edit.
|
||||
const key = Math.max(-1, ...lines.map((l) => l.key)) + 1
|
||||
onChange([...lines, { key, glBankAccountId: "", amount: remaining > 0 ? remaining.toFixed(2) : "" }])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-[1fr_8rem_8rem] gap-2 text-sm font-medium">
|
||||
<span>Payment type</span>
|
||||
<span>Paid amount</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{lines.map((line, idx) => (
|
||||
<div key={line.key} className="grid grid-cols-[1fr_8rem_8rem] items-start gap-2">
|
||||
<div>
|
||||
<Select<string> value={line.glBankAccountId} onValueChange={(v) => updateLine(line.key, { glBankAccountId: v ?? "" })}>
|
||||
<SelectTrigger
|
||||
className="w-full text-base"
|
||||
aria-label={`Payment type, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`account-${line.key}`]}
|
||||
>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select cash/bank"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={line.amount}
|
||||
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
||||
aria-label={`Paid amount, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`amount-${line.key}`]}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
||||
disabled={lines.length === 1}
|
||||
aria-label={`Remove line ${idx + 1}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
||||
{idx === lines.length - 1 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
|
||||
<Plus className="size-4" />
|
||||
Add line
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
||||
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
||||
<span className="text-muted-foreground">Paid amount</span>
|
||||
<span className="text-right tabular-nums">{formatAmount(paid)}</span>
|
||||
<span className="font-medium text-destructive">{remaining < 0 ? "Over by" : "Remaining"}</span>
|
||||
<span className="text-right font-semibold text-destructive tabular-nums">{formatAmount(Math.abs(remaining))}</span>
|
||||
</div>
|
||||
<FieldError errors={[errors.lines ? { message: errors.lines } : undefined]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { SalesInvoicePayment, SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import {
|
||||
localToday,
|
||||
PaymentLine,
|
||||
PaymentLinesEditor,
|
||||
paymentLinesTotal,
|
||||
round2,
|
||||
validatePaymentLines,
|
||||
} from "@/components/sales/PaymentLinesEditor"
|
||||
|
||||
interface SalesInvoiceBulkPaymentDialogProps {
|
||||
/** Draft or Posted invoices with a balance, all for the same (non-cash) customer. */
|
||||
invoices: SalesInvoiceSummary[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** A selected draft was confirmed (posted) on the way to paying it. */
|
||||
onConfirmed: (salesInvoiceId: number) => void
|
||||
/** Called with every payment recorded — one per invoice/payment-type split. */
|
||||
onPaid: (payments: SalesInvoicePayment[]) => void
|
||||
}
|
||||
|
||||
/** Oldest invoice first — the same order the backend applies the payment in. */
|
||||
function byAge(a: SalesInvoiceSummary, b: SalesInvoiceSummary) {
|
||||
return a.invoiceDate.localeCompare(b.invoiceDate) || a.salesInvoiceId - b.salesInvoiceId
|
||||
}
|
||||
|
||||
/** Pay several invoices of one customer at once, split across payment lines. The lines' total is
|
||||
* applied oldest invoice first (shown per invoice before saving) and posted server-side as a single
|
||||
* GL journal entry, so it either all goes through or none of it does. */
|
||||
export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, onConfirmed, onPaid }: SalesInvoiceBulkPaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||
const [reference, setReference] = useState("")
|
||||
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// Drafts this dialog has already confirmed — so a retry after a failed payment doesn't re-post them.
|
||||
const [postedIds, setPostedIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const ordered = useMemo(() => [...invoices].sort(byAge), [invoices])
|
||||
const balance = round2(ordered.reduce((sum, i) => sum + i.totals.balanceAmount, 0))
|
||||
|
||||
// Fresh form for each open / different selection — "adjust state while rendering", not an effect.
|
||||
const openKey = open && ordered.length > 0 ? ordered.map((i) => i.salesInvoiceId).join(",") : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: balance.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedIds(new Set())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accounts !== null) return
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
// Preview of how the paid amount lands on each invoice, oldest first.
|
||||
const allocation = useMemo(() => {
|
||||
let left = Math.max(0, Math.min(paymentLinesTotal(lines), balance))
|
||||
const rows: { inv: SalesInvoiceSummary; applied: number }[] = []
|
||||
for (const inv of ordered) {
|
||||
const applied = round2(Math.min(left, inv.totals.balanceAmount))
|
||||
left = round2(left - applied)
|
||||
rows.push({ inv, applied })
|
||||
}
|
||||
return rows
|
||||
}, [ordered, lines, balance])
|
||||
|
||||
if (ordered.length === 0) return null
|
||||
const customerName = ordered[0].customerSnapshotName
|
||||
const draftCount = ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId)).length
|
||||
|
||||
async function submit() {
|
||||
const nextErrors = validatePaymentLines(lines, balance)
|
||||
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
// Drafts are confirmed first (payments only go against approved invoices) — the same
|
||||
// "confirm & pay" the single-invoice dialog does. A failed confirm stops before any payment;
|
||||
// drafts already confirmed by then simply stay approved.
|
||||
for (const inv of ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId))) {
|
||||
try {
|
||||
await salesApi.postInvoice(inv.salesInvoiceId)
|
||||
setPostedIds((prev) => new Set(prev).add(inv.salesInvoiceId))
|
||||
onConfirmed(inv.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error(`Could not confirm invoice ${inv.invoiceNo}`, errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payments = await salesApi.payInvoicesBulk({
|
||||
salesInvoiceIds: ordered.map((i) => i.salesInvoiceId),
|
||||
lines: lines.map((l) => ({ amount: Number(l.amount), glBankAccountId: Number(l.glBankAccountId) })),
|
||||
reference: reference || undefined,
|
||||
paymentDate,
|
||||
})
|
||||
const paid = round2(payments.reduce((sum, p) => sum + p.amount, 0))
|
||||
const invoiceCount = new Set(payments.map((p) => p.salesInvoiceId)).size
|
||||
toast.success(
|
||||
"Payment recorded",
|
||||
`${formatAmount(paid)} applied to ${invoiceCount} invoice${invoiceCount === 1 ? "" : "s"} (${payments[0]?.glJournalNo ?? "—"}).`
|
||||
)
|
||||
onPaid(payments)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Pay {ordered.length} invoices — {customerName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{draftCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
||||
{draftCount === 1 ? "it" : "them"} first, then record the payment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Date</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allocation.map(({ inv, applied }) => (
|
||||
<tr key={inv.salesInvoiceId} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
|
||||
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bulk-pay-customer">Customer</FieldLabel>
|
||||
<Input
|
||||
id="bulk-pay-customer"
|
||||
value={customerName}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="bulk-pay-date">Payment date</FieldLabel>
|
||||
<Input
|
||||
id="bulk-pay-date"
|
||||
type="date"
|
||||
max={localToday()}
|
||||
value={paymentDate}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
aria-invalid={!!errors.paymentDate}
|
||||
/>
|
||||
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bulk-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="bulk-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
|
||||
|
||||
interface SalesInvoicePaymentDialogProps {
|
||||
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
||||
@@ -24,23 +24,28 @@ interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
||||
* it started that way or this dialog auto-posted it first. */
|
||||
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
|
||||
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
|
||||
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
|
||||
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
|
||||
* PurchaseInvoicePaymentDialog on the AP side. */
|
||||
/** Record a customer payment against an invoice's balance — full or partial, split across one or
|
||||
* more payment lines (e.g. part cash, part bank), each into an existing GL cash/bank account. Each
|
||||
* line is posted as its own payment (the backend already allows several installments). A Draft
|
||||
* invoice is posted first (the backend only accepts payments against a Posted invoice) so this
|
||||
* doubles as "confirm and pay in one step" from a list row. If a line fails part-way, the lines
|
||||
* already recorded are removed from the form and the rest stay for a retry. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||
const [reference, setReference] = useState("")
|
||||
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
|
||||
// guaranteed to be refreshed by the parent between a partial failure and the retry.
|
||||
const [postedHere, setPostedHere] = useState(false)
|
||||
const [paidHere, setPaidHere] = useState(0)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||
// "adjust state while rendering" pattern, not inside an effect.
|
||||
@@ -48,10 +53,12 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedHere(false)
|
||||
setPaidHere(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,98 +68,118 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const isDraft = invoice.status === "Draft" && !postedHere
|
||||
const balance = round2(invoice.totals.balanceAmount - paidHere)
|
||||
|
||||
async function submit() {
|
||||
if (!invoice) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
||||
const nextErrors = validatePaymentLines(lines, balance)
|
||||
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
if (isDraft) {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
setPostedHere(true)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||
// keep the rest so the user can fix and retry without double-paying.
|
||||
const recorded: SalesInvoicePayment[] = []
|
||||
let failure: unknown = null
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: Number(line.amount),
|
||||
glBankAccountId: Number(line.glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
paymentDate,
|
||||
})
|
||||
recorded.push(payment)
|
||||
onPaid(payment, "Posted")
|
||||
} catch (err) {
|
||||
failure = err
|
||||
break
|
||||
}
|
||||
}
|
||||
setSubmitting(false)
|
||||
|
||||
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
|
||||
if (failure === null) {
|
||||
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${recordedTotal >= balance ? "fully paid" : "partially paid"}.`
|
||||
)
|
||||
onPaid(payment, "Posted")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
||||
errorMessage(err)
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaidHere((p) => round2(p + recordedTotal))
|
||||
setLines((prev) => prev.slice(recorded.length))
|
||||
toast.error(
|
||||
recorded.length > 0
|
||||
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
|
||||
: invoice.status === "Draft"
|
||||
? "Invoice was confirmed, but the payment could not be recorded"
|
||||
: "Could not record payment",
|
||||
errorMessage(failure)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-invoice-no"
|
||||
value={invoice.invoiceNo}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="inv-pay-date">Payment date</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-date"
|
||||
type="date"
|
||||
max={localToday()}
|
||||
value={paymentDate}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
aria-invalid={!!errors.paymentDate}
|
||||
/>
|
||||
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -160,11 +187,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Printer } from "lucide-react"
|
||||
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -12,7 +12,9 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
@@ -21,17 +23,26 @@ import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||
const ACCENT = "#8b1c24"
|
||||
|
||||
type PaperSize = "A4" | "80mm"
|
||||
type PaperSize = "A4" | "80mm" | "58mm"
|
||||
type ReceiptWidth = Exclude<PaperSize, "A4">
|
||||
|
||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||
{ value: "58mm", label: "58mm receipt", hint: "Small thermal roll" },
|
||||
]
|
||||
|
||||
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||
const PAGE_RULES: Record<PaperSize, string> = {
|
||||
A4: "@page { size: A4; margin: 12mm; }",
|
||||
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
||||
}
|
||||
|
||||
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
||||
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
||||
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
||||
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
||||
}
|
||||
|
||||
function money(n: number) {
|
||||
@@ -265,12 +276,15 @@ function ReceiptRow({ label, value, bold }: { label: string; value: string; bold
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div
|
||||
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
||||
className={cn(
|
||||
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
|
||||
RECEIPT_SHEET_CLASS[width]
|
||||
)}
|
||||
style={printColorExact}
|
||||
>
|
||||
<div className="text-center">
|
||||
@@ -358,7 +372,7 @@ function ReceiptSheet({ invoice, customer, payments, company, template, dueDate,
|
||||
}
|
||||
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||
function useInvoiceDoc(invoiceId: number) {
|
||||
function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
||||
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||
@@ -404,7 +418,8 @@ function useInvoiceDoc(invoiceId: number) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
// refreshKey: bumped by the caller after a payment/confirm so the sheet shows the new state.
|
||||
}, [invoiceId, refreshKey])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
@@ -416,8 +431,57 @@ function useIsClient() {
|
||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||
}
|
||||
|
||||
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
||||
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
/**
|
||||
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
||||
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
||||
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
||||
* Both libraries are loaded on demand.
|
||||
*/
|
||||
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
||||
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||
// The sheet's drop shadow / ring are screen decoration only.
|
||||
const dataUrl = await toPng(sheet, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
style: { boxShadow: "none", margin: "0" },
|
||||
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
||||
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||
})
|
||||
const img = new Image()
|
||||
img.src = dataUrl
|
||||
await img.decode()
|
||||
|
||||
const widthMm = PAPER_WIDTH_MM[size]
|
||||
const imgHeightMm = (img.height * widthMm) / img.width
|
||||
if (size !== "A4") {
|
||||
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
||||
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
||||
return pdf.output("blob")
|
||||
}
|
||||
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
||||
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
||||
if (page > 0) pdf.addPage()
|
||||
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
||||
}
|
||||
return pdf.output("blob")
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,10 +489,55 @@ function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||
*/
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
const isClient = useIsClient()
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
||||
|
||||
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
||||
|
||||
async function makePdf(): Promise<Blob> {
|
||||
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
||||
if (!sheet) throw new Error("The invoice is still loading.")
|
||||
return buildInvoicePdf(sheet, paperSize)
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy("download")
|
||||
try {
|
||||
saveBlob(await makePdf(), fileName)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
||||
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
|
||||
async function handleShare() {
|
||||
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
|
||||
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
|
||||
setBusy("share")
|
||||
try {
|
||||
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title, text: title })
|
||||
} else if (navigator.share) {
|
||||
await navigator.share({ title, text: title, url: link })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
||||
}
|
||||
} catch (err) {
|
||||
// Closing the share sheet isn't an error.
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !doc) {
|
||||
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
@@ -437,7 +546,7 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||
<div ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||
{doc ? (
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
) : (
|
||||
@@ -448,30 +557,41 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
||||
{/* Paper size selection, under the invoice. */}
|
||||
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
||||
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paperSize === s.value}
|
||||
onClick={() => setPaperSize(s.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
||||
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||
<Select<PaperSize>
|
||||
value={paperSize}
|
||||
onValueChange={(v) => v && setPaperSize(v)}
|
||||
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
>
|
||||
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
|
||||
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
||||
Share
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
|
||||
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
Download
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : "receipt"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
@@ -493,20 +613,29 @@ export function SalesInvoicePrintDialog({
|
||||
invoiceNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
actions,
|
||||
refreshKey,
|
||||
}: {
|
||||
invoiceId: number | null
|
||||
invoiceNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Optional buttons shown in the header (e.g. Confirm / Pay) — turns the preview into an invoice view. */
|
||||
actions?: ReactNode
|
||||
/** Bump to reload the invoice (after a payment, confirm, …). */
|
||||
refreshKey?: number
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} refreshKey={refreshKey} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface SuggestOption {
|
||||
label: string
|
||||
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||
hint?: string
|
||||
/** Optional smaller second line under the label (e.g. stock and item details). Not searched. */
|
||||
detail?: string
|
||||
/** Optional pill shown on the right, before the hint (e.g. an available stock count). */
|
||||
badge?: string
|
||||
badgeTone?: SuggestBadgeTone
|
||||
}
|
||||
|
||||
export type SuggestBadgeTone = "muted" | "success" | "danger"
|
||||
|
||||
const badgeToneClass: Record<SuggestBadgeTone, string> = {
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
success: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||
danger: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
interface SuggestInputProps {
|
||||
@@ -32,6 +45,8 @@ interface SuggestInputProps {
|
||||
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||
keepOpenOnSelect?: boolean
|
||||
className?: string
|
||||
/** Extra classes for the suggestion list (it defaults to the input's width), e.g. a wider min-width. */
|
||||
listClassName?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
@@ -53,6 +68,7 @@ export function SuggestInput({
|
||||
browsable = false,
|
||||
keepOpenOnSelect = false,
|
||||
className,
|
||||
listClassName,
|
||||
...rest
|
||||
}: SuggestInputProps) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
@@ -218,7 +234,7 @@ export function SuggestInput({
|
||||
id={listId}
|
||||
role="listbox"
|
||||
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
className={cn("fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10", listClassName)}
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||
@@ -241,8 +257,29 @@ export function SuggestInput({
|
||||
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||
{option.detail ? (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
)}
|
||||
{(option.badge || option.hint) && (
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{option.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||
badgeToneClass[option.badgeTone ?? "muted"],
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</span>
|
||||
)}
|
||||
{option.hint && <span className="text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,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 = {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// GRN configuration. Singleton - no id. A saved `defaultWarehouseId` pins every GRN to that
|
||||
// warehouse (the server forces it too); with none saved, New GRN shows a warehouse picker.
|
||||
import { apiRequestWithETag } from "@/lib/api-client"
|
||||
import { ApiResult } from "@/types/common"
|
||||
import { GrnConfig, UpdateGrnConfigRequest } from "@/types/grn"
|
||||
|
||||
export const grnConfigApi = {
|
||||
get(): Promise<ApiResult<GrnConfig>> {
|
||||
return apiRequestWithETag<GrnConfig>("/grn-config")
|
||||
},
|
||||
|
||||
update(request: UpdateGrnConfigRequest, ifMatch: string): Promise<ApiResult<GrnConfig>> {
|
||||
return apiRequestWithETag<GrnConfig>("/grn-config", { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateBulkSalesInvoicePaymentRequest,
|
||||
CreateSalesInvoicePaymentRequest,
|
||||
CreateSalesInvoiceRequest,
|
||||
CreateSalesSlipRequest,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
SalesSlipStatus,
|
||||
SalesSlipPostingCheck,
|
||||
SalesSlipSummary,
|
||||
SalesItemStock,
|
||||
SalesStockAvailability,
|
||||
SalesSuggestedPrice,
|
||||
UpdateSalesInvoiceRequest,
|
||||
@@ -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`)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
|
||||
/** Rounds a sales unit price to 2dp, half away from zero (matches the server's SalesPricingService).
|
||||
* Shifts via the exponent string rather than `* 100` so binary-float cases like 1.005 round up. */
|
||||
export function roundPrice(value: number): number {
|
||||
const shifted = Number(`${Math.round(Number(`${Math.abs(value)}e2`))}e-2`)
|
||||
// Values JS prints in exponent form (e.g. 1e-7) can't take the string shift — those are ~0 anyway.
|
||||
const rounded = Number.isFinite(shifted) ? shifted : Math.round(Math.abs(value) * 100) / 100
|
||||
return value < 0 ? -rounded : rounded
|
||||
}
|
||||
|
||||
/** A unit price input's value as a 2dp price, or null when cleared. */
|
||||
export function parsePriceInput(value: string): number | null {
|
||||
if (value === "") return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? roundPrice(n) : null
|
||||
}
|
||||
|
||||
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
|
||||
if (!itemId) return null
|
||||
return items.find((item) => item.itemId === itemId)?.sellingFlatPrice ?? null
|
||||
const price = items.find((item) => item.itemId === itemId)?.sellingFlatPrice
|
||||
return price === null || price === undefined ? null : roundPrice(price)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -163,3 +163,22 @@ export interface ReleaseGrnLineResponse {
|
||||
grnLineId: number
|
||||
holdStatus: HoldStatus
|
||||
}
|
||||
|
||||
/** GRN configuration singleton. A saved `defaultWarehouseId` pins New GRN to that warehouse. */
|
||||
export interface GrnConfig {
|
||||
allowWarehouseSelection: boolean
|
||||
defaultWarehouseId: number | null
|
||||
defaultWarehouseName: string | null
|
||||
allowDiscounts: boolean
|
||||
allowVat: boolean
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
export interface UpdateGrnConfigRequest {
|
||||
allowWarehouseSelection: boolean
|
||||
/** Required when `allowWarehouseSelection` is false. */
|
||||
defaultWarehouseId: number | null
|
||||
allowDiscounts: boolean
|
||||
allowVat: boolean
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user