feat: add purchase invoice batch print page and related components

- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices.
- Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout.
- Added invoice layout presets for different styles (Classic, Compact, Modern).
- Developed PurchaseInvoicePaymentDialog for handling payments against invoices.
- Introduced API mocks for company profile and invoice template configuration.
- Added validation functions for company profile and purchase invoices.
- Defined types for company profile, invoice template configuration, and purchase invoices.
This commit is contained in:
2026-09-01 15:25:32 +05:30
parent 160c687db3
commit bebec10eb3
70 changed files with 12446 additions and 589 deletions
+1 -22
View File
@@ -11,12 +11,10 @@ namespace ERPCore.Controllers;
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
private readonly IGrnPaymentService _payments;
public GrnsController(IGrnService grns, IGrnPaymentService payments)
public GrnsController(IGrnService grns)
{
_grns = grns;
_payments = payments;
}
/// <summary>List GRNs, newest first.</summary>
@@ -64,23 +62,4 @@ public sealed class GrnsController : ApiControllerBase
public async Task<ActionResult<ReleaseLineResultDto>> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{grnId:int}/payments")]
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(grnId, request, ct);
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
}
/// <summary>Payment history for this GRN, newest first.</summary>
[HttpGet("{grnId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
=> Ok(await _payments.ListAsync(grnId, ct));
}
@@ -0,0 +1,102 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
+1
View File
@@ -10,6 +10,7 @@ public static class DocumentTypes
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
+5 -5
View File
@@ -34,14 +34,14 @@ public class Grn
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
public decimal PaidAmount { get; set; }
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
public decimal BalanceAmount { get; set; }
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
/// <see cref="PurchaseInvoice"/>.</summary>
public int? PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
/// are then recorded against this aggregate rather than against the individual GRNs.
/// </summary>
public class PurchaseInvoice
{
public int PurchaseInvoiceId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string VendorInvoiceNo { get; set; } = string.Empty;
public DateTime VendorInvoiceDate { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string VendorSnapshotName { get; set; } = string.Empty;
public string? VendorSnapshotTaxNo { get; set; }
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
/// PurchaseInvoiceService.PostAsync.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
/// Invoice consolidates GRNs, it does not re-enter item lines).
/// </summary>
public class PurchaseInvoiceLine
{
public int PurchaseInvoiceLineId { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal LineTotal { get; set; }
}
@@ -1,16 +1,17 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
/// A vendor payment made against a Posted purchase invoice's balance (installments
/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
/// before being recorded here.
/// </summary>
public class GrnPayment
public class PurchaseInvoicePayment
{
public int GrnPaymentId { get; set; }
public int PurchaseInvoicePaymentId { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
@@ -4,7 +4,7 @@ namespace ERPCore.Domain.Entities;
/// A customer payment made against a Posted sales invoice's balance (installments
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
/// being recorded here — the mirror image of <see cref="PurchaseInvoicePayment"/>.
/// </summary>
public class SalesInvoicePayment
{
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum PurchaseInvoiceStatus
{
Draft,
Posted,
Cancelled
}
+7 -13
View File
@@ -17,34 +17,28 @@ public sealed record GrnLineDto(
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
decimal PaidAmount, decimal BalanceAmount);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
public sealed record GrnPaymentDto(
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreateGrnPaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
/// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
public sealed record GrnEligibleForInvoiceDto(
int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
// Requests ----------------------------------------------------------------------
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
public sealed record PurchaseInvoiceLineDto(
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
decimal Subtotal, decimal VatTotal, decimal LineTotal);
public sealed record PurchaseInvoiceTotalsDto(
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
public sealed record PurchaseInvoiceDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
string? GlJournalNo, DateTime? GlPostedAt,
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
public sealed record PurchaseInvoiceSummaryDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record PurchaseInvoicePaymentDto(
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreatePurchaseInvoicePaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
public sealed class CreatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
public sealed class UpdatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
@@ -17,41 +17,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
}
}
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
{
public void Configure(EntityTypeBuilder<GrnPayment> builder)
{
builder.ToTable("grn_payments");
builder.HasKey(p => p.GrnPaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.GrnId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{
public void Configure(EntityTypeBuilder<GrnLine> builder)
@@ -63,9 +63,10 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
);
}
}
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<PurchaseInvoice>
{
public void Configure(EntityTypeBuilder<PurchaseInvoice> builder)
{
builder.ToTable("purchase_invoices");
builder.HasKey(x => x.PurchaseInvoiceId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
builder.Property(x => x.VendorInvoiceDate).IsRequired();
// A vendor invoice number is only meaningful per-vendor; the service also excludes
// Cancelled invoices from the duplicate check, so this composite index is a coarse
// backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
builder.HasOne(x => x.Vendor)
.WithMany()
.HasForeignKey(x => x.VendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(PurchaseInvoiceStatus.Draft);
foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.VendorId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.PurchaseInvoice)
.HasForeignKey(x => x.PurchaseInvoiceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration<PurchaseInvoiceLine>
{
public void Configure(EntityTypeBuilder<PurchaseInvoiceLine> builder)
{
builder.ToTable("purchase_invoice_lines");
builder.HasKey(x => x.PurchaseInvoiceLineId);
builder.Property(x => x.Subtotal).HasPrecision(18, 4);
builder.Property(x => x.VatTotal).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.HasOne(x => x.Grn)
.WithMany()
.HasForeignKey(x => x.GrnId)
.OnDelete(DeleteBehavior.Restrict);
// A GRN can be consolidated onto at most one active line at a time — cancelling the
// invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
// this stays enforceable rather than blocking a later re-invoice of the same GRN.
builder.HasIndex(x => x.GrnId).IsUnique();
}
}
public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration<PurchaseInvoicePayment>
{
public void Configure(EntityTypeBuilder<PurchaseInvoicePayment> builder)
{
builder.ToTable("purchase_invoice_payments");
builder.HasKey(p => p.PurchaseInvoicePaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.PurchaseInvoiceId);
}
}
@@ -42,6 +42,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
@@ -56,7 +57,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
// notification-config item and silently overwrites this one. See migration
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
);
@@ -66,7 +66,11 @@ public class ErpDbContext : DbContext
// --- Goods Receipt (docs/10 Part C.3) ---
public DbSet<Grn> Grns => Set<Grn>();
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
// --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
public DbSet<PurchaseInvoice> PurchaseInvoices => Set<PurchaseInvoice>();
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>();
@@ -0,0 +1,24 @@
// <auto-generated />
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260825000000_FixProductionTemplatesSubNavItemIdConflict")]
partial class FixProductionTemplatesSubNavItemIdConflict
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
// This migration only repairs seed row data via raw SQL — no entity/column shape
// changes — so it does not redeclare the full model here. The single source of
// truth for scaffolding future migrations is ErpDbContextModelSnapshot.cs, which
// already reflects this fix (production.templates seeded at SubNavItemId 27).
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Repairs a collision baked into migration history: 20260818090139_AddProductionNavPermissions
/// inserted "production.templates" as sub_nav_items row 24, then
/// 20260820100345_AddNotificationConfigModulePreferences reused the same literal id 24 for
/// "settings.notification-config" via a raw-SQL <c>INSERT ... ON CONFLICT ("SubNavItemId") DO
/// UPDATE</c>, silently overwriting the production.templates row instead of inserting a new
/// one. Row 24 now correctly holds notification-config; this migration re-creates the lost
/// production.templates row under a fresh id (27, matching the corrected seed data in
/// SubNavItemConfiguration/PermissionConfiguration) and repoints the NAV:production.templates
/// permission (id 36) at it.
/// </summary>
public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (27, 14, 'production.templates', '/dashboard/production/templates', NULL, 'Templates', 1)
ON CONFLICT ("SubNavItemId") DO NOTHING;
""");
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 27 WHERE "PermissionId" = 36;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 24 WHERE "PermissionId" = 36;
""");
migrationBuilder.Sql("""
DELETE FROM sub_nav_items WHERE "SubNavItemId" = 27;
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
migrationBuilder.AddColumn<int>(
name: "PurchaseInvoiceId",
table: "grns",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "purchase_invoices",
columns: table => new
{
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorInvoiceNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
VendorInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
VendorId = table.Column<int>(type: "integer", nullable: false),
VendorSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
VendorSnapshotTaxNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaidAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BalanceAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoices", x => x.PurchaseInvoiceId);
table.ForeignKey(
name: "FK_purchase_invoices_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoices_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_lines",
columns: table => new
{
PurchaseInvoiceLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_lines", x => x.PurchaseInvoiceLineId);
table.ForeignKey(
name: "FK_purchase_invoice_lines_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_lines_purchase_invoices_PurchaseInvoiceId",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_payments",
columns: table => new
{
PurchaseInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_payments", x => x.PurchaseInvoicePaymentId);
table.ForeignKey(
name: "FK_purchase_invoice_payments_purchase_invoices_PurchaseInvoice~",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 28, "procurement.purchase-invoices", "/dashboard/procurement/purchase-invoices", null, "Purchase Invoices", 4, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 39, "NAV:procurement.purchase-invoices", null, 28 });
migrationBuilder.CreateIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_GrnId",
table: "purchase_invoice_lines",
column: "GrnId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_PurchaseInvoiceId",
table: "purchase_invoice_lines",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_CreatedBy",
table: "purchase_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_PurchaseInvoiceId",
table: "purchase_invoice_payments",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_CreatedBy",
table: "purchase_invoices",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_DocNo",
table: "purchase_invoices",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_Status",
table: "purchase_invoices",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId",
table: "purchase_invoices",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId_VendorInvoiceNo",
table: "purchase_invoices",
columns: new[] { "VendorId", "VendorInvoiceNo" });
migrationBuilder.AddForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId",
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DropTable(
name: "purchase_invoice_lines");
migrationBuilder.DropTable(
name: "purchase_invoice_payments");
migrationBuilder.DropTable(
name: "purchase_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 39);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 28);
migrationBuilder.DropColumn(
name: "PurchaseInvoiceId",
table: "grns");
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
}
}
@@ -1411,10 +1411,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1433,16 +1429,15 @@ namespace ERPCore.Migrations
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("PoId")
.HasColumnType("integer");
b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -1469,6 +1464,8 @@ namespace ERPCore.Migrations
b.HasIndex("PoId");
b.HasIndex("PurchaseInvoiceId");
b.HasIndex("Status");
b.HasIndex("VendorId");
@@ -1584,55 +1581,6 @@ namespace ERPCore.Migrations
b.ToTable("grn_line_warranty_numbers", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.Property<int>("GrnPaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("GrnPaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("GrnId");
b.ToTable("grn_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
{
b.Property<int>("HrDocumentTypeId")
@@ -2976,7 +2924,7 @@ namespace ERPCore.Migrations
{
PermissionId = 36,
Code = "NAV:production.templates",
SubNavItemId = 24
SubNavItemId = 27
},
new
{
@@ -2989,6 +2937,12 @@ namespace ERPCore.Migrations
PermissionId = 38,
Code = "NAV:production.produced",
SubNavItemId = 26
},
new
{
PermissionId = 39,
Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28
});
});
@@ -3228,6 +3182,189 @@ namespace ERPCore.Migrations
b.ToTable("production_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Property<int>("PurchaseInvoiceId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("VendorId")
.HasColumnType("integer");
b.Property<DateTime>("VendorInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorInvoiceNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("VendorSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("VendorSnapshotTaxNo")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("PurchaseInvoiceId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("VendorId");
b.HasIndex("VendorId", "VendorInvoiceNo");
b.ToTable("purchase_invoices", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.Property<int>("PurchaseInvoiceLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceLineId"));
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("PurchaseInvoiceLineId");
b.HasIndex("GrnId")
.IsUnique();
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.Property<int>("PurchaseInvoicePaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoicePaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("PurchaseInvoicePaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property<int>("PoId")
@@ -5337,6 +5474,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 28,
Code = "procurement.purchase-invoices",
Href = "/dashboard/procurement/purchase-invoices",
Label = "Purchase Invoices",
NavItemId = 4,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 9,
Code = "ledgers.trial-balance",
@@ -5438,7 +5585,7 @@ namespace ERPCore.Migrations
},
new
{
SubNavItemId = 24,
SubNavItemId = 27,
Code = "production.templates",
Href = "/dashboard/production/templates",
Label = "Templates",
@@ -6176,6 +6323,11 @@ namespace ERPCore.Migrations
.HasForeignKey("PoId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany()
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
@@ -6190,6 +6342,8 @@ namespace ERPCore.Migrations
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
b.Navigation("PurchaseOrder");
b.Navigation("Vendor");
@@ -6248,25 +6402,6 @@ namespace ERPCore.Migrations
b.Navigation("GrnLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany("Payments")
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Grn");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
@@ -6567,6 +6702,63 @@ namespace ERPCore.Migrations
b.Navigation("Creator");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany()
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Lines")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Grn");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Payments")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -7516,8 +7708,6 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
@@ -7568,6 +7758,13 @@ namespace ERPCore.Migrations
b.Navigation("Stages");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
+4 -1
View File
@@ -105,7 +105,10 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
// Purchase invoices (vendor invoices consolidating Confirmed GRNs)
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
// Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
@@ -1,120 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class GrnPaymentService : IGrnPaymentService
{
private readonly IRepository<Grn> _grns;
private readonly IRepository<GrnPayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glClearingAccountCode;
public GrnPaymentService(
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_grns = grns;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
{
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
if (grn.Status == GrnStatus.Draft)
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
if (request.Amount > grn.BalanceAmount)
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnService.ConfirmAsync: 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 posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "GRN_PAYMENT",
Reference = grn.DocNo,
Narration = $"Payment against GRN {grn.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
]
}, token);
var entity = new GrnPayment
{
GrnId = grn.GrnId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
grn.PaidAmount += request.Amount;
grn.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
{
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
throw new NotFoundException($"GRN {grnId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.GrnId == grnId)
.OrderByDescending(p => p.GrnPaymentId)
.Select(p => new GrnPaymentDto(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static GrnPaymentDto Map(GrnPayment p) => new(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
+6 -6
View File
@@ -92,7 +92,8 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -102,6 +103,7 @@ public sealed class GrnService : IGrnService
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
@@ -302,15 +304,13 @@ public sealed class GrnService : IGrnService
grn.PostedAt = now;
grn.GlJournalNo = posted.JournalNo;
grn.GlPostedAt = now;
grn.PaidAmount = 0m;
grn.BalanceAmount = totalPayable;
await UpdatePoStatusAsync(grn.PoId, token);
return 0;
}, ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
@@ -437,7 +437,7 @@ public sealed class GrnService : IGrnService
.Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
@@ -446,7 +446,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
@@ -1,10 +0,0 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
public interface IGrnPaymentService
{
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a Posted purchase invoice's balance (installments allowed).</summary>
public interface IPurchaseInvoicePaymentService
{
Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
public interface IPurchaseInvoiceService
{
Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
}
@@ -0,0 +1,119 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a Posted purchase invoice. Multiple installments are allowed
/// until <see cref="PurchaseInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit Accounts Payable / Credit the selected bank-or-cash
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
/// rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
{
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotPayable, $"Purchase invoice {purchaseInvoiceId} must be posted before it can be paid.", 409);
if (request.Amount > invoice.BalanceAmount)
throw new DomainException(ErrorCodes.PurchaseInvoicePaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.PurchaseInvoiceBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE_PAYMENT",
Reference = invoice.DocNo,
Narration = $"Payment against invoice {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
]
}, token);
var entity = new PurchaseInvoicePayment
{
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
invoice.PaidAmount += request.Amount;
invoice.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.Select(p => new PurchaseInvoicePaymentDto(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,303 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase invoice service. A purchase invoice consolidates one or more Confirmed
/// GRNs for the same vendor into a single vendor-facing document — each linked GRN
/// becomes one <see cref="PurchaseInvoiceLine"/>, a straight sum of that GRN's line
/// totals (no re-itemization). Draft invoices are fully editable (GRNs can be
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
/// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _grns;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode;
private readonly string _glAccountsPayableCode;
public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_invoices = invoices;
_vendors = vendors;
_grns = grns;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
_gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PurchaseInvoice
{
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
VendorInvoiceNo = vendorInvoiceNo,
VendorInvoiceDate = request.VendorInvoiceDate,
VendorId = request.VendorId,
VendorSnapshotName = vendor.Name,
VendorSnapshotTaxNo = vendor.TaxReg,
Status = PurchaseInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = grns.Select(BuildLine).ToList()
};
Recalculate(entity);
await _invoices.AddAsync(entity, token);
foreach (var grn in grns) grn.PurchaseInvoice = entity;
return entity;
}, ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
// Free GRNs that were linked to this invoice but are no longer selected.
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
foreach (var line in invoice.Lines)
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
invoice.VendorId = request.VendorId;
invoice.VendorInvoiceNo = vendorInvoiceNo;
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
invoice.VendorSnapshotName = vendor.Name;
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
invoice.Lines.Clear();
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be posted.");
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE",
Reference = invoice.DocNo,
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
]
}, token);
invoice.Status = PurchaseInvoiceStatus.Posted;
invoice.GlJournalNo = posted.JournalNo;
invoice.GlPostedAt = now;
invoice.UpdatedAt = now;
return 0;
}, ct);
return MapInvoice(invoice);
}
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be cancelled.");
var dto = MapInvoice(invoice);
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
// the link rows are removed (the unique index on GrnId only tracks the active link),
// while the Cancelled header row itself remains for audit history.
foreach (var line in invoice.Lines)
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
invoice.Lines.Clear();
invoice.Status = PurchaseInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
}
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{
return await _grns.Query().AsNoTracking()
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
.Select(g => new GrnEligibleForInvoiceDto(
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
.ToListAsync(ct);
}
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
x.VendorId == vendorId
&& x.Status != PurchaseInvoiceStatus.Cancelled
&& x.VendorInvoiceNo == vendorInvoiceNo
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
}
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines)
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
if (grns.Count != distinctIds.Count)
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
foreach (var grn in grns)
{
if (grn.Status != GrnStatus.Confirmed)
throw new DomainException(ErrorCodes.GrnNotConfirmed, $"GRN {grn.DocNo} must be confirmed before it can be invoiced.", 409);
if (grn.VendorId != vendorId)
throw new DomainException(ErrorCodes.GrnVendorMismatch, $"GRN {grn.DocNo} belongs to a different vendor.", 409);
if (grn.PurchaseInvoiceId is not null && grn.PurchaseInvoiceId != excludingPurchaseInvoiceId)
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
}
return grns;
}
private static PurchaseInvoiceLine BuildLine(Grn grn) => new()
{
Grn = grn,
GrnId = grn.GrnId,
Subtotal = grn.Lines.Sum(l => l.ReceivedValue),
VatTotal = grn.Lines.Sum(l => l.VatAmount),
LineTotal = grn.Lines.Sum(l => l.LineTotal)
};
private static void Recalculate(PurchaseInvoice invoice)
{
invoice.Subtotal = invoice.Lines.Sum(x => x.Subtotal);
invoice.VatTotal = invoice.Lines.Sum(x => x.VatTotal);
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal);
invoice.PaidAmount = 0m;
invoice.BalanceAmount = invoice.GrandTotal;
}
private static PurchaseInvoiceTotalsDto MapTotals(PurchaseInvoice x) => new(
x.Subtotal, x.VatTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount);
private static PurchaseInvoiceSummaryDto MapSummary(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.Status, x.Lines.Count, MapTotals(x), x.CreatedAt, x.GlJournalNo);
private static PurchaseInvoiceDto MapInvoice(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.VendorSnapshotTaxNo, x.Status, x.Lines.Count, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.GlJournalNo, x.GlPostedAt, MapTotals(x),
x.Lines.OrderBy(l => l.PurchaseInvoiceLineId).Select(l => new PurchaseInvoiceLineDto(
l.PurchaseInvoiceLineId, l.GrnId, l.Grn?.DocNo ?? string.Empty, l.Grn?.PostedAt ?? default,
l.Subtotal, l.VatTotal, l.LineTotal)).ToList());
}
@@ -16,8 +16,8 @@ namespace ERPCore.Services;
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable
/// clearing account when vendor-paid; this one credits the receivable asset when
/// <see cref="PurchaseInvoicePaymentService"/> — the mirror image of it (that one debits a
/// payable account when the vendor is paid; this one credits the receivable asset when
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
+10 -5
View File
@@ -73,13 +73,18 @@ public static class ErrorCodes
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED";
// GRN payments
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
// Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
// Purchase invoices (consolidate Confirmed GRNs; vendor payments now tracked here, not on the GRN)
public const string GrnAlreadyInvoiced = "GRN_ALREADY_INVOICED";
public const string GrnVendorMismatch = "GRN_VENDOR_MISMATCH";
public const string GrnNotConfirmed = "GRN_NOT_CONFIRMED";
public const string PurchaseInvoiceNotEditable = "PURCHASE_INVOICE_NOT_EDITABLE";
public const string DuplicateVendorInvoiceNo = "DUPLICATE_VENDOR_INVOICE_NO";
public const string PurchaseInvoiceNotPayable = "PURCHASE_INVOICE_NOT_PAYABLE";
public const string PurchaseInvoicePaymentExceedsBalance = "PURCHASE_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string PurchaseInvoiceBankAccountNotFound = "PURCHASE_INVOICE_BANK_ACCOUNT_NOT_FOUND";
}
+4
View File
@@ -47,5 +47,9 @@
"SalesInvoice": {
"GlAccountsReceivableCode": "1300"
},
"PurchaseInvoice": {
"GlClearingAccountCode": "2000",
"GlAccountsPayableCode": "2200"
},
"AllowedHosts": "*"
}
@@ -0,0 +1,96 @@
import { Badge } from "@/components/ui/badge"
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
// Fixed width (not w-fit) so every status pill is the same size regardless of
// label length, same convention as components/receiving/status-badges.tsx.
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
function requisitionClass(status: RequisitionStatus) {
return status === "Draft" ? "bg-warning/10 text-warning border-transparent" : "bg-success/10 text-success border-transparent"
}
export function RequisitionStatusBadge({ status }: { status: RequisitionStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${requisitionClass(status)}`}>
{status}
</Badge>
)
}
function rfqClass(status: RfqStatus) {
return status === "Open" ? "bg-success/10 text-success border-transparent" : "bg-muted text-muted-foreground border-transparent"
}
export function RfqStatusBadge({ status }: { status: RfqStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${rfqClass(status)}`}>
{status}
</Badge>
)
}
function poClass(status: PurchaseOrderStatus) {
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
}
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
{status}
</Badge>
)
}
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
}
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
{purchaseInvoiceStatusLabel[status]}
</Badge>
)
}
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
{purchaseInvoicePaymentLabel[status]}
</Badge>
)
}
@@ -0,0 +1,298 @@
"use client"
import { use, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookOpen, Printer } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { vendorsApi } from "@/lib/api/vendors"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Vendor } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
/** "Posted" reads as "approved" everywhere in the UI — matches PurchaseInvoiceStatusBadge's label. */
function statusWord(status: PurchaseInvoiceStatus): string {
return status === "Posted" ? "approved" : status.toLowerCase()
}
export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter()
const resolvedParams = use(params)
const purchaseInvoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<PurchaseInvoice | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [payments, setPayments] = useState<PurchaseInvoicePayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
if (!Number.isFinite(purchaseInvoiceId)) {
setError(`Invalid purchase invoice id '${resolvedParams.id}'.`)
return
}
Promise.all([
vendorsApi.list({ pageSize: 200 }),
purchaseInvoicesApi.get(purchaseInvoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([vendorRes, doc, companyRes, templateRes]) => {
setVendors(vendorRes.items)
setInvoice(doc.data)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [purchaseInvoiceId, resolvedParams.id])
// No manual Save/Post/Cancel step: a Draft landed on here is posted immediately and
// automatically — this page is only ever meant to be seen in its Posted (read-only) state.
const autoPostedRef = useRef(false)
useEffect(() => {
if (!invoice || invoice.status !== "Draft" || autoPostedRef.current) return
autoPostedRef.current = true
setPosting(true)
setError(null)
purchaseInvoicesApi
.post(purchaseInvoiceId)
.then((posted) => {
setInvoice(posted)
toast.success("Purchase invoice posted", posted.docNo)
router.refresh()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setPosting(false))
}, [invoice, purchaseInvoiceId, router])
useEffect(() => {
if (!invoice || invoice.status !== "Posted") {
setPayments([])
return
}
purchaseInvoicesApi.listPayments(purchaseInvoiceId).then(setPayments).catch(() => setPayments([]))
}, [invoice?.status, purchaseInvoiceId])
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
setInvoice((prev) =>
prev
? {
...prev,
status: newStatus,
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
}
: prev
)
setPayments((prev) => [payment, ...prev])
}
const vendor = useMemo(() => vendors.find((v) => v.vendorId === invoice?.vendorId), [vendors, invoice?.vendorId])
if (error && !invoice) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>
}
if (!invoice) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Invoice Details</h1>
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div>
</div>
<div className="flex items-center gap-2">
{invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" />
View in Ledger
</Link>
)}
<Link
href={`/print/procurement/purchase-invoices/${invoice.purchaseInvoiceId}`}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print
</Link>
</div>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="border-b pb-4">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Purchase Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.docNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<PurchaseInvoiceStatusBadge status={invoice.status} />
{invoice.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={invoice.totals.paidAmount} balanceAmount={invoice.totals.balanceAmount} />
)}
</div>
<div className="mt-1 text-sm text-muted-foreground">Vendor invoice: {invoice.vendorInvoiceNo}</div>
<div className="text-sm text-muted-foreground">Date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
</div>
<InvoicePrintHeader company={company} template={template} />
</div>
</div>
{layout.arrangement === "highlight" && (
<div className={`mt-5 ${layout.totalsBanner}`}>
<span className={layout.totalsBannerLabel}>Grand total</span>
<span className={layout.totalsBannerValue}>{formatAmount(invoice.totals.grandTotal)}</span>
</div>
)}
<div className="grid gap-6 py-5 md:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Vendor</div>
<div className="mt-2 font-semibold text-foreground">{invoice.vendorSnapshotName}</div>
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
</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">
<Table className="text-sm">
<TableHeader>
<TableRow className={layout.tableHeaderRow}>
<TableHead className="h-11 px-4 text-sm">GRN</TableHead>
<TableHead className="h-11 px-4 text-sm">Posted</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">VAT</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.purchaseInvoiceLineId}>
<TableCell className="px-4 py-3">
<Link href={`/dashboard/receiving/grn/${line.grnId}`} className="font-medium hover:underline">
{line.grnDocNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<InvoicePrintFooter company={company} template={template} />
<div className="mt-6 border-t pt-5">
{invoice.status === "Draft" ? (
<div className="flex items-center gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
{posting ? "Posting invoice…" : "Waiting to post…"}
</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">
<span>This invoice is {statusWord(invoice.status)}.</span>
{invoice.status === "Posted" ? (
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
</Button>
) : null}
</div>
)}
{invoice.status === "Posted" && (
<div className="mt-5 flex flex-col gap-2 rounded-2xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-semibold text-foreground">Payment status</span>
<span className="text-muted-foreground">
Paid {formatAmount(invoice.totals.paidAmount)} of {formatAmount(invoice.totals.grandTotal)} — balance {formatAmount(invoice.totals.balanceAmount)}
{invoice.glJournalNo ? ` · GL journal ${invoice.glJournalNo}` : ""}
</span>
</div>
{payments.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-3 text-xs">Date</TableHead>
<TableHead className="h-9 px-3 text-xs">Account</TableHead>
<TableHead className="h-9 px-3 text-xs">Reference</TableHead>
<TableHead className="h-9 px-3 text-xs">GL journal</TableHead>
<TableHead className="h-9 px-3 text-xs text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.purchaseInvoicePaymentId}>
<TableCell className="px-3 py-2">{new Date(p.paymentDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-2">{p.bankAccountName}</TableCell>
<TableCell className="px-3 py-2">{p.reference ?? "—"}</TableCell>
<TableCell className="px-3 py-2">{p.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
</section>
<PurchaseInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -0,0 +1,337 @@
"use client"
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Combobox } from "@/components/ui/combobox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { vendorsApi } from "@/lib/api/vendors"
import { validatePurchaseInvoiceHeader } from "@/lib/validations/purchase-invoices"
import { Vendor } from "@/types/master-data"
import { GrnEligibleForInvoice } from "@/types/purchase-invoices"
const lkr = new Intl.NumberFormat("en-LK", {
style: "currency",
currency: "LKR",
minimumFractionDigits: 2,
})
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function NewPurchaseInvoiceContent() {
const router = useRouter()
const searchParams = useSearchParams()
const vendorIdFromQuery = searchParams.get("vendorId")
const grnIdFromQuery = searchParams.get("grnId")
const appliedDeepLinkRef = useRef(false)
const [vendors, setVendors] = useState<Vendor[]>([])
const [vendorId, setVendorId] = useState<number | null>(null)
const [vendorInvoiceNo, setVendorInvoiceNo] = useState("")
const [vendorInvoiceDate, setVendorInvoiceDate] = useState(todayIso())
const [eligibleGrns, setEligibleGrns] = useState<GrnEligibleForInvoice[] | null>(null)
const [selectedGrnIds, setSelectedGrnIds] = useState<Set<number>>(new Set())
const [grnsError, setGrnsError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
useEffect(() => {
vendorsApi
.list({ pageSize: 200 })
.then((res) => {
setVendors(res.items)
if (vendorIdFromQuery) {
setVendorId(Number(vendorIdFromQuery))
} else {
setVendorId(res.items[0]?.vendorId ?? null)
}
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Bumped to force a refetch when the browser restores this page from its back-forward
// cache (e.g. Back button after create() redirected away) — bfcache restores the frozen
// JS/React state verbatim without re-running effects, so without this the picker would
// keep showing the GRN we just invoiced as still eligible.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
function onPageShow(e: PageTransitionEvent) {
if (e.persisted) setRefetchTick((t) => t + 1)
}
window.addEventListener("pageshow", onPageShow)
return () => window.removeEventListener("pageshow", onPageShow)
}, [])
useEffect(() => {
if (!vendorId) {
setEligibleGrns(null)
setSelectedGrnIds(new Set())
return
}
setGrnsError(null)
setEligibleGrns(null)
purchaseInvoicesApi
.listEligibleGrns(vendorId)
.then((grns) => {
setEligibleGrns(grns)
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — pre-check
// that GRN once its vendor's eligible list has loaded, but only once per page load
// (a bfcache-triggered refetch should re-apply it the same way, since the GRN may
// still be eligible if the user came Back without ever submitting).
if (grnIdFromQuery && (!appliedDeepLinkRef.current || refetchTick > 0)) {
appliedDeepLinkRef.current = true
const id = Number(grnIdFromQuery)
setSelectedGrnIds(grns.some((g) => g.grnId === id) ? new Set([id]) : new Set())
} else if (!grnIdFromQuery) {
setSelectedGrnIds(new Set())
}
})
.catch((err) => setGrnsError(errorMessage(err)))
}, [vendorId, grnIdFromQuery, refetchTick])
const vendorOptions = useMemo(
() => vendors.map((v) => ({ value: v.vendorId, label: `${v.code} — ${v.name}` })),
[vendors]
)
function toggleGrn(grnId: number, checked: boolean) {
setSelectedGrnIds((prev) => {
const next = new Set(prev)
if (checked) next.add(grnId)
else next.delete(grnId)
return next
})
}
function toggleAll(checked: boolean) {
setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set())
}
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the
// whole reason this page was opened, so show only it rather than the vendor's full eligible
// list (which could pull in unrelated GRNs the user never meant to consolidate here).
const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null
const visibleGrns = useMemo(
() => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)),
[eligibleGrns, deepLinkedGrnId]
)
const selectedGrns = useMemo(
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
[eligibleGrns, selectedGrnIds]
)
const subtotal = selectedGrns.reduce((sum, g) => sum + g.subtotal, 0)
const vatTotal = selectedGrns.reduce((sum, g) => sum + g.vatTotal, 0)
const grandTotal = selectedGrns.reduce((sum, g) => sum + g.grandTotal, 0)
const allSelected = visibleGrns.length > 0 && visibleGrns.every((g) => selectedGrnIds.has(g.grnId))
async function submit() {
const grnIds = Array.from(selectedGrnIds)
const nextErrors = validatePurchaseInvoiceHeader({ vendorId, vendorInvoiceNo, vendorInvoiceDate, grnIds })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
setSubmitError(null)
try {
const created = await purchaseInvoicesApi.create({
vendorId: vendorId!,
vendorInvoiceNo: vendorInvoiceNo.trim(),
vendorInvoiceDate,
grnIds,
})
// Deep-linked from a specific GRN — the GRN is already known and confirmed, so there's
// nothing left to review before posting; skip the separate Draft step and post right away.
if (deepLinkedGrnId !== null) {
try {
const posted = await purchaseInvoicesApi.post(created.data.purchaseInvoiceId)
toast.success("Purchase invoice created and posted", posted.docNo)
} catch (postErr) {
toast.error("Invoice created, but could not be posted", errorMessage(postErr))
}
} else {
toast.success("Purchase invoice created", created.data.docNo)
}
router.push(`/dashboard/procurement/purchase-invoices/${created.data.purchaseInvoiceId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Invoice</h1>
<p className="text-base text-muted-foreground">Pick a vendor, then consolidate one or more of its confirmed, un-invoiced GRNs.</p>
</div>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.vendorId}>
<FieldLabel>Vendor</FieldLabel>
<Combobox
items={vendorOptions}
value={vendorId}
onValueChange={setVendorId}
placeholder="Search vendor…"
aria-invalid={!!errors.vendorId}
/>
<FieldError errors={[errors.vendorId ? { message: errors.vendorId } : undefined]} />
</Field>
<Field data-invalid={!!errors.vendorInvoiceNo}>
<FieldLabel htmlFor="pinv-vendor-no">Vendor invoice no.</FieldLabel>
<Input
id="pinv-vendor-no"
value={vendorInvoiceNo}
onChange={(e) => setVendorInvoiceNo(e.target.value)}
placeholder="e.g. INV-84213"
aria-invalid={!!errors.vendorInvoiceNo}
/>
<FieldError errors={[errors.vendorInvoiceNo ? { message: errors.vendorInvoiceNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.vendorInvoiceDate}>
<FieldLabel htmlFor="pinv-vendor-date">Vendor invoice date</FieldLabel>
<Input
id="pinv-vendor-date"
type="date"
value={vendorInvoiceDate}
max={todayIso()}
onChange={(e) => setVendorInvoiceDate(e.target.value)}
aria-invalid={!!errors.vendorInvoiceDate}
/>
<FieldError errors={[errors.vendorInvoiceDate ? { message: errors.vendorInvoiceDate } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Eligible GRNs</h2>
{errors.grnIds && <span className="text-sm text-destructive">{errors.grnIds}</span>}
</div>
{!vendorId && (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">Select a vendor to see its confirmed, un-invoiced GRNs.</p>
)}
{vendorId && grnsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{grnsError}</div>
)}
{vendorId && !grnsError && eligibleGrns === null && <Skeleton className="h-32 w-full" />}
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length === 0 && (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
{deepLinkedGrnId !== null
? "That GRN is no longer eligible to invoice (already invoiced, or not confirmed)."
: "No confirmed, un-invoiced GRNs for this vendor."}
</p>
)}
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length > 0 && (
<div className="overflow-x-auto rounded-2xl border">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-11 w-10 px-3">
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} aria-label="Select all" />
</TableHead>
<TableHead className="h-11 px-3 text-sm">GRN</TableHead>
<TableHead className="h-11 px-3 text-sm">Posted</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Lines</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">VAT</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleGrns.map((g) => (
<TableRow key={g.grnId}>
<TableCell className="px-3 py-3">
<Checkbox
checked={selectedGrnIds.has(g.grnId)}
onCheckedChange={(v) => toggleGrn(g.grnId, !!v)}
aria-label={`Select ${g.docNo}`}
/>
</TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
<TableCell className="px-3 py-3 text-right font-semibold tabular-nums">{lkr.format(g.grandTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base">
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">Subtotal</span>
<span className="tabular-nums">{lkr.format(subtotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">VAT</span>
<span className="tabular-nums">{lkr.format(vatTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Grand total</span>
<span className="font-semibold tabular-nums">{lkr.format(grandTotal)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}>
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewPurchaseInvoicePage() {
return (
<Suspense fallback={<Skeleton className="h-12 w-full" />}>
<NewPurchaseInvoiceContent />
</Suspense>
)
}
@@ -0,0 +1,299 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 10
const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
{ value: "Posted", label: "Approved" },
{ value: "Cancelled", label: "Cancelled" },
]
export default function PurchaseInvoicesPage() {
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
useEffect(() => {
setError(null)
purchaseInvoicesApi
.list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status])
async function handleConfirm(row: PurchaseInvoiceSummary) {
setConfirmingId(row.purchaseInvoiceId)
try {
const posted = await purchaseInvoicesApi.post(row.purchaseInvoiceId)
setRows((prev) => prev?.map((r) => (r.purchaseInvoiceId === row.purchaseInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.docNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
setConfirmingId(null)
}
}
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
setRows(
(prev) =>
prev?.map((r) =>
r.purchaseInvoiceId === payment.purchaseInvoiceId
? {
...r,
status: newStatus,
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
}
: r
) ?? prev
)
}
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || query.length > 0
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const vatTotal = visibleRows.reduce((sum, row) => sum + row.totals.vatTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/procurement/purchase-invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Invoices</h1>
<p className="text-base text-muted-foreground">Vendor bills consolidating confirmed GRNs, with posting and settlement tracking.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/procurement/purchase-invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Purchase Invoice
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setStatus(t.value)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by vendor, invoice no. or doc no."
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</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 && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<ReceiptText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase invoices match your filters." : "No purchase invoices yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-4 text-sm">Vendor invoice no.</TableHead>
<TableHead className="h-12 px-4 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">GRNs</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">VAT</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Total</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</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 text-muted-foreground">{row.totals.vatTotal.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">
<div className="flex flex-col items-start gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.purchaseInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.purchaseInvoiceId ? "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
</Button>
)}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">VAT</div>
<div className="text-base font-semibold tabular-nums">{vatTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
<PurchaseInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -3,26 +3,27 @@
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn, GrnPayment } from "@/types/grn"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { PurchaseInvoice } from "@/types/purchase-invoices"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
@@ -38,12 +39,24 @@ export default function GrnDetailPage() {
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [payments, setPayments] = useState<GrnPayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID())
// Bumped to force a refetch when the browser restores this page from its back-forward
// cache (e.g. Back button after "Create Purchase Invoice" redirected away) — bfcache
// restores the frozen JS/React state verbatim without re-running effects, so without this
// the page would keep showing the pre-invoice GRN state (no linked invoice) after Back.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
function onPageShow(e: PageTransitionEvent) {
if (e.persisted) setRefetchTick((t) => t + 1)
}
window.addEventListener("pageshow", onPageShow)
return () => window.removeEventListener("pageshow", onPageShow)
}, [])
useEffect(() => {
if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
@@ -53,7 +66,7 @@ export default function GrnDetailPage() {
setUoms(uo.items)
})
.catch(() => {})
}, [grnId])
}, [grnId, refetchTick])
useEffect(() => {
if (!grn) return
@@ -61,16 +74,12 @@ export default function GrnDetailPage() {
}, [grn?.warehouseId])
useEffect(() => {
if (!grn || grn.status === "Draft") return
grnsApi.listPayments(grn.grnId).then(setPayments).catch(() => setPayments([]))
}, [grn?.grnId, grn?.status])
function handlePaid(_updated: Grn, payment: GrnPayment) {
setGrn((prev) =>
prev ? { ...prev, paidAmount: prev.paidAmount + payment.amount, balanceAmount: prev.balanceAmount - payment.amount } : prev
)
setPayments((prev) => [payment, ...prev])
}
if (!grn?.purchaseInvoiceId) return
purchaseInvoicesApi
.get(grn.purchaseInvoiceId)
.then((res) => setLinkedInvoice(res.data))
.catch(() => setLinkedInvoice(null))
}, [grn?.purchaseInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -87,9 +96,7 @@ export default function GrnDetailPage() {
try {
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
setConfirmResult(result)
setGrn((prev) =>
prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo, balanceAmount: result.balanceAmount } : prev
)
setGrn((prev) => (prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo } : prev))
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock. Journal ${result.glJournalNo}.`)
} catch (err) {
setError(errorMessage(err))
@@ -140,7 +147,6 @@ export default function GrnDetailPage() {
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
<GrnStatusBadge status={grn.status} />
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
</div>
<p className="text-base text-muted-foreground">
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
@@ -150,22 +156,14 @@ export default function GrnDetailPage() {
<div className="flex items-center gap-2">
{grn.status !== "Draft" && (
<>
<Link
href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
title="View this GRN's postings in the General Ledger report"
>
<BookOpen className="size-5" />
View in Ledger
</Link>
{grn.balanceAmount > 0 && (
<Button size="lg" onClick={() => setPayDialogOpen(true)}>
<CircleDollarSign className="size-5" />
Pay
</Button>
)}
</>
<Link
href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
title="View this GRN's postings in the General Ledger report"
>
<BookOpen className="size-5" />
View in Ledger
</Link>
)}
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
@@ -176,8 +174,6 @@ export default function GrnDetailPage() {
</div>
</div>
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -314,47 +310,42 @@ export default function GrnDetailPage() {
<span className="text-muted-foreground">Document total</span>
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
</div>
{grn.status !== "Draft" && (
<>
<div className="flex gap-3">
<span className="text-muted-foreground">Amount paid</span>
<span className="tabular-nums">{formatAmount(grn.paidAmount)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">Balance due</span>
<span className="font-semibold tabular-nums">{formatAmount(grn.balanceAmount)}</span>
</div>
</>
)}
</div>
{grn.status !== "Draft" && payments.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-foreground">Payment History</h2>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
<TableHead className="h-12 px-3 text-sm">Reference</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.grnPaymentId}>
<TableCell className="px-3 py-3.5">{new Date(p.paymentDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{p.bankAccountName}</TableCell>
<TableCell className="px-3 py-3.5">{p.reference ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{p.glJournalNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{grn.status === "Confirmed" && (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-5">
<h2 className="text-lg font-semibold text-foreground">Purchase Invoice</h2>
{grn.purchaseInvoiceId ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}</span>
{linkedInvoice && <PurchaseInvoiceStatusBadge status={linkedInvoice.status} />}
{linkedInvoice && linkedInvoice.status !== "Draft" && (
<span className="text-sm text-muted-foreground">
Balance due: {linkedInvoice.totals.balanceAmount.toFixed(2)}
</span>
)}
</div>
<Link
href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<FileText className="size-4" />
View invoice
</Link>
</div>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span>
<Link
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
className={cn(buttonVariants({ size: "sm" }))}
>
<FileText className="size-4" />
Create Purchase Invoice
</Link>
</div>
)}
</div>
)}
</div>
@@ -98,6 +98,64 @@ function newKey() {
return `gline-${keySeq}`
}
let warrantySeq = 0
/** Auto-generated warranty number, pre-filled into the input so the user can accept or edit it. */
function generateWarrantyNumber(): string {
warrantySeq += 1
const stamp = Date.now().toString(36).toUpperCase()
const seq = warrantySeq.toString(36).toUpperCase().padStart(3, "0")
return `WTY-${stamp}-${seq}`
}
/** Splits a warranty number into its non-numeric prefix and trailing digit run, e.g. "WTY-000101" → { prefix: "WTY-", num: 101, width: 6 }. */
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
const m = s.trim().match(/^(.*?)(\d+)$/)
if (!m) return null
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
}
/**
* Splices an "adding number" onto the end of the current number: it keeps everything except
* the trailing characters, replacing as many of them as `adding` is long, e.g. current
* "WTY-000101" + adding "105" (3 chars) → keep "WTY-000", replace the last 3 with "105" →
* "WTY-000105".
*/
function applyAddingNumber(current: string, adding: string): string {
const tail = adding.trim()
if (!tail) return current
if (tail.length >= current.length) return tail
return current.slice(0, current.length - tail.length) + tail
}
/**
* Builds the sequential run of warranty numbers spanning `from` to `to` (inclusive) — both
* must share the same prefix and `to` must not come before `from`. Returns null otherwise
* so the caller can surface a validation message.
*/
function buildWarrantyRange(from: string, to: string): string[] | null {
const f = parseTrailingNumber(from)
const t = parseTrailingNumber(to)
if (!f || !t || f.prefix !== t.prefix) return null
const span = t.num - f.num + 1
if (span <= 0) return null
return Array.from({ length: span }, (_, i) => `${f.prefix}${String(f.num + i).padStart(f.width, "0")}`)
}
/**
* Resizes a line's warrantyNumbers to match its unit count and auto-fills any blank slot
* with a generated number — existing (manually edited) values are left untouched.
*/
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
if (item?.warranty !== "Warranty") return l.warrantyNumbers
const units = unitCount(l.qty)
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
for (let i = 0; i < units; i++) {
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
}
return next
}
function emptyLine(): DraftLine {
return {
key: newKey(),
@@ -218,8 +276,8 @@ export default function NewGrnPage() {
}
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
setLines(
openLines.map(
(l): DraftLine => ({
openLines.map((l): DraftLine => {
const draft: DraftLine = {
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
@@ -231,8 +289,9 @@ export default function NewGrnPage() {
vatPct: headerVatPct.trim() || "0",
holdStatus: "Available",
warrantyNumbers: [],
})
)
}
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
})
)
} catch (err) {
setHeaderError(errorMessage(err))
@@ -257,7 +316,13 @@ export default function NewGrnPage() {
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
setLines((prev) =>
prev.map((l) => {
if (l.key !== key) return l
const merged = { ...l, ...patch }
return { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
})
)
}
function removeLine(key: string) {
@@ -280,6 +345,62 @@ export default function NewGrnPage() {
)
}
// Typed "From" number plus an "Adding number" per line — "Replace numbers" keeps From as-is
// and splices Adding number onto its tail (see applyAddingNumber) to get To, then fills as
// many units as that range spans (from unit 1), leaving any remaining units' numbers untouched.
const [warrantyRangeInputs, setWarrantyRangeInputs] = useState<Record<string, { from: string; adding: string }>>({})
const [warrantyAddErrors, setWarrantyAddErrors] = useState<Record<string, string>>({})
function updateWarrantyRangeInput(lineKey: string, field: "from" | "adding", value: string) {
setWarrantyRangeInputs((prev) => {
const current = prev[lineKey] ?? { from: "", adding: "" }
return { ...prev, [lineKey]: { ...current, [field]: value } }
})
}
// Fills units 1..(To-From+1) with the typed sequence — only as many units as the range
// actually spans. Anything beyond that (the other units' numbers) is left exactly as it
// was: nothing gets wiped just because the range doesn't cover the whole line.
function replaceWarrantyNumbers(lineKey: string, units: number) {
const range = warrantyRangeInputs[lineKey]
if (!range?.from.trim() || !range?.adding.trim()) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
return
}
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
// number the same way "adding" splices onto "From", so you never have to retype the prefix.
const currentFirst = lines.find((l) => l.key === lineKey)?.warrantyNumbers[0]?.trim()
const from = currentFirst ? applyAddingNumber(currentFirst, range.from) : range.from
const to = applyAddingNumber(from, range.adding)
const seq = buildWarrantyRange(from, to)
if (!seq) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
return
}
if (seq.length > units) {
setWarrantyAddErrors((prev) => ({
...prev,
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
}))
return
}
setWarrantyAddErrors((prev) => {
const next = { ...prev }
delete next[lineKey]
return next
})
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = [...l.warrantyNumbers]
while (next.length < units) next.push("")
for (let i = 0; i < seq.length; i++) next[i] = seq[i]
return { ...l, warrantyNumbers: next }
})
)
// From/To are left exactly as typed — not cleared — so a typo is easy to fix in place.
}
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
// the "Warranty numbers" tab needs to capture, one input per unit.
const warrantyLines = lines
@@ -806,17 +927,56 @@ export default function NewGrnPage() {
<div className="flex flex-col gap-6">
{warrantyLines.map(({ line, item, units }) => {
const errors = lineErrors[line.key] ?? {}
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
const addError = warrantyAddErrors[line.key]
return (
<div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">From</Label>
<Input
value={range.from}
onChange={(e) => updateWarrantyRangeInput(line.key, "from", e.target.value)}
placeholder="e.g. 101 or WTY-000101"
className="h-9 w-40 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Adding number</Label>
<Input
value={range.adding}
onChange={(e) => updateWarrantyRangeInput(line.key, "adding", e.target.value)}
placeholder="e.g. 120"
className="h-9 w-40 text-sm"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => replaceWarrantyNumbers(line.key, units)}
>
Replace numbers
</Button>
<span className="text-xs text-muted-foreground">
Type just the trailing digits — From and Adding number each replace that many
characters off the end of unit 1&apos;s current number. Fills starting from unit 1
— units beyond the range keep their number.
</span>
{addError && <FieldError errors={[{ message: addError }]} />}
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
<TableHead className="h-10 px-3 text-sm">
Warranty number
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -6,7 +6,6 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
@@ -23,7 +22,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -175,8 +174,7 @@ export default function GrnListPage() {
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Balance</TableHead>
<TableHead className="h-12 px-3 text-sm">Payment</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
@@ -199,14 +197,15 @@ export default function GrnListPage() {
<TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{grn.status === "Draft" ? "—" : formatAmount(grn.balanceAmount)}
</TableCell>
<TableCell className="px-3 py-3.5">
{grn.status === "Draft" ? (
{grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />
<span className="text-sm text-muted-foreground">Not invoiced</span>
)}
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
@@ -11,17 +11,24 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
type Line = CreateSalesInvoiceLineRequest & { key: string }
@@ -63,6 +70,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
@@ -87,13 +96,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => {
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes]) => {
setCustomers(customerRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(warehouseRes.items)
setInvoice(doc.data)
setCompany(companyRes.data)
setTemplate(templateRes.data)
setEtag(doc.etag)
setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId)
@@ -274,6 +287,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice data is loading or unavailable."}</div>
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3 print:hidden">
@@ -301,7 +316,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<section className="bg-white print:bg-white">
<div className="border-b pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
@@ -314,14 +329,18 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span>
</div>
</div>
<div className="text-sm md:text-right">
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Company details are not configured for this invoice view.</div>
</div>
<InvoicePrintHeader company={company} template={template} />
</div>
</div>
<div className="grid gap-6 py-5 md:grid-cols-3">
{layout.arrangement === "highlight" && (
<div className={`mt-5 ${layout.totalsBanner}`}>
<span className={layout.totalsBannerLabel}>Net payable</span>
<span className={layout.totalsBannerValue}>{money.format(invoice.totals.netPayable)}</span>
</div>
)}
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
<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>
@@ -334,26 +353,28 @@ 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>
<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>
{layout.arrangement !== "highlight" && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">Discount</div>
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
<div className="text-muted-foreground">Free qty</div>
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
<div className="text-muted-foreground">Tax</div>
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
<div className="text-muted-foreground">Net payable</div>
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
</div>
</div>
</div>
)}
</div>
<div className="overflow-x-auto border-y">
<table className="w-full text-sm">
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
<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>
@@ -446,10 +467,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
) : null}
<div className="border-t pt-5">
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
<p className="mt-2 text-sm text-muted-foreground">Standard invoice template view.</p>
</div>
<InvoicePrintFooter company={company} template={template} />
<div className="mt-6 border-t pt-5 print:hidden">
<div className="mb-3 flex items-center justify-between">
@@ -1,22 +1,290 @@
import Link from "next/link"
import { Building2 } from "lucide-react"
"use client"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useEffect, useRef, useState } from "react"
import { Building2, Landmark, Trash2, Upload } from "lucide-react"
import { companyProfileApi } from "@/lib/api/company-profile"
import { errorMessage } from "@/lib/error-map"
import { validateCompanyProfile } from "@/lib/validations/company-profile"
import { CompanyProfile } from "@/types/company-profile"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
const blank = {
legalName: "",
tradeName: "",
taxRegistrationNo: "",
vatRegistrationNo: "",
addressLine1: "",
addressLine2: "",
city: "",
country: "",
phone: "",
email: "",
bankName: "",
bankBranch: "",
accountName: "",
accountNumber: "",
swiftCode: "",
}
export default function CompanyProfilePage() {
const [profile, setProfile] = useState<CompanyProfile | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [form, setForm] = useState(blank)
const fileInputRef = useRef<HTMLInputElement>(null)
function load() {
setError(null)
companyProfileApi
.get()
.then((res) => {
setProfile(res.data)
setEtag(res.etag)
setForm({
legalName: res.data.legalName,
tradeName: res.data.tradeName,
taxRegistrationNo: res.data.taxRegistrationNo ?? "",
vatRegistrationNo: res.data.vatRegistrationNo ?? "",
addressLine1: res.data.addressLine1,
addressLine2: res.data.addressLine2 ?? "",
city: res.data.city,
country: res.data.country,
phone: res.data.phone,
email: res.data.email,
bankName: res.data.bankName ?? "",
bankBranch: res.data.bankBranch ?? "",
accountName: res.data.accountName ?? "",
accountNumber: res.data.accountNumber ?? "",
swiftCode: res.data.swiftCode ?? "",
})
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
function set<K extends keyof typeof blank>(field: K, value: string) {
setForm((prev) => ({ ...prev, [field]: value }))
}
async function save() {
if (!etag) return
const nextErrors = validateCompanyProfile(form)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
const res = await companyProfileApi.update(
{
legalName: form.legalName.trim(),
tradeName: form.tradeName.trim(),
taxRegistrationNo: form.taxRegistrationNo.trim() || null,
vatRegistrationNo: form.vatRegistrationNo.trim() || null,
addressLine1: form.addressLine1.trim(),
addressLine2: form.addressLine2.trim() || null,
city: form.city.trim(),
country: form.country.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
bankName: form.bankName.trim() || null,
bankBranch: form.bankBranch.trim() || null,
accountName: form.accountName.trim() || null,
accountNumber: form.accountNumber.trim() || null,
swiftCode: form.swiftCode.trim() || null,
},
etag
)
setProfile(res.data)
setEtag(res.etag)
toast.success("Company profile saved")
} catch (err) {
toast.error("Could not save company profile", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
async function handleLogoPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
setUploadingLogo(true)
try {
const res = await companyProfileApi.uploadLogo(file)
setProfile(res.data)
setEtag(res.etag)
toast.success("Logo uploaded")
} catch (err) {
toast.error("Could not upload logo", errorMessage(err))
} finally {
setUploadingLogo(false)
}
}
async function removeLogo() {
setUploadingLogo(true)
try {
const res = await companyProfileApi.deleteLogo()
setProfile(res.data)
setEtag(res.etag)
toast.success("Logo removed")
} catch (err) {
toast.error("Could not remove logo", errorMessage(err))
} finally {
setUploadingLogo(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
<Building2 className="size-10 text-muted-foreground" />
<div>
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
</div>
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to Settings
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">Legal identity, contact, and bank details used on printed invoices.</p>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !profile && <Skeleton className="h-96 w-full" />}
{!error && profile && (
<div className="flex flex-col gap-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Identity & contact</h2>
</div>
<div className="-mt-4 border-t" />
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.legalName}>
<FieldLabel htmlFor="cp-legal-name">Legal name</FieldLabel>
<Input id="cp-legal-name" value={form.legalName} onChange={(e) => set("legalName", e.target.value)} aria-invalid={!!errors.legalName} />
<FieldError errors={[errors.legalName ? { message: errors.legalName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-trade-name">Trade name</FieldLabel>
<Input id="cp-trade-name" value={form.tradeName} onChange={(e) => set("tradeName", e.target.value)} placeholder="Shown on invoices if set" />
</Field>
<Field>
<FieldLabel htmlFor="cp-tax-reg">Tax registration no.</FieldLabel>
<Input id="cp-tax-reg" value={form.taxRegistrationNo} onChange={(e) => set("taxRegistrationNo", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-vat-reg">VAT registration no.</FieldLabel>
<Input id="cp-vat-reg" value={form.vatRegistrationNo} onChange={(e) => set("vatRegistrationNo", e.target.value)} />
</Field>
<Field data-invalid={!!errors.addressLine1}>
<FieldLabel htmlFor="cp-address1">Address line 1</FieldLabel>
<Input id="cp-address1" value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} aria-invalid={!!errors.addressLine1} />
<FieldError errors={[errors.addressLine1 ? { message: errors.addressLine1 } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-address2">Address line 2</FieldLabel>
<Input id="cp-address2" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
</Field>
<Field data-invalid={!!errors.city}>
<FieldLabel htmlFor="cp-city">City</FieldLabel>
<Input id="cp-city" value={form.city} onChange={(e) => set("city", e.target.value)} aria-invalid={!!errors.city} />
<FieldError errors={[errors.city ? { message: errors.city } : undefined]} />
</Field>
<Field data-invalid={!!errors.country}>
<FieldLabel htmlFor="cp-country">Country</FieldLabel>
<Input id="cp-country" value={form.country} onChange={(e) => set("country", e.target.value)} aria-invalid={!!errors.country} />
<FieldError errors={[errors.country ? { message: errors.country } : undefined]} />
</Field>
<Field data-invalid={!!errors.phone}>
<FieldLabel htmlFor="cp-phone">Phone</FieldLabel>
<Input id="cp-phone" value={form.phone} onChange={(e) => set("phone", e.target.value)} aria-invalid={!!errors.phone} />
<FieldError errors={[errors.phone ? { message: errors.phone } : undefined]} />
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="cp-email">Email</FieldLabel>
<Input id="cp-email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} />
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
</Field>
</FieldGroup>
<div className="flex items-center gap-2 pt-2">
<Landmark className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Bank details</h2>
</div>
<div className="-mt-4 border-t" />
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="cp-bank-name">Bank name</FieldLabel>
<Input id="cp-bank-name" value={form.bankName} onChange={(e) => set("bankName", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-bank-branch">Branch</FieldLabel>
<Input id="cp-bank-branch" value={form.bankBranch} onChange={(e) => set("bankBranch", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-account-name">Account name</FieldLabel>
<Input id="cp-account-name" value={form.accountName} onChange={(e) => set("accountName", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-account-number">Account number</FieldLabel>
<Input id="cp-account-number" value={form.accountNumber} onChange={(e) => set("accountNumber", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-swift">SWIFT code</FieldLabel>
<Input id="cp-swift" value={form.swiftCode} onChange={(e) => set("swiftCode", e.target.value)} />
</Field>
</FieldGroup>
<div className="flex items-center gap-2 pt-2">
<h2 className="text-lg font-semibold text-foreground">Logo</h2>
</div>
<div className="-mt-4 border-t" />
<div className="flex flex-wrap items-center gap-4">
<div className="flex size-20 items-center justify-center overflow-hidden rounded-xl border bg-muted/20">
{profile.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- small settings preview, not worth next/image config
<img src={profile.logoUrl} alt={profile.legalName} className="h-full w-full object-contain" />
) : (
<span className="text-xs text-muted-foreground">No logo</span>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleLogoPick} />
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={uploadingLogo}>
<Upload className="size-4" />
{uploadingLogo ? "Uploading…" : "Upload logo"}
</Button>
{profile.logoUrl && (
<Button type="button" variant="ghost" onClick={removeLogo} disabled={uploadingLogo}>
<Trash2 className="size-4" />
Remove logo
</Button>
)}
</div>
<div className="flex items-center justify-between border-t pt-4">
{profile.updatedAt && (
<p className="text-sm text-muted-foreground">
Last changed {new Date(profile.updatedAt).toLocaleString()}
{profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.
</p>
)}
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
import Link from "next/link"
import { Building2, LayoutTemplate, SlidersHorizontal } from "lucide-react"
const cards = [
{
title: "Product Configuration",
href: "/dashboard/products/settings",
icon: SlidersHorizontal,
description: "Configure product and master-data options",
},
{
title: "Invoice Template",
href: "/dashboard/settings/invoice-template",
icon: LayoutTemplate,
description: "Logo, footer, terms, and template style for printed invoices",
},
{
title: "Company Profile",
href: "/dashboard/settings/company-profile",
icon: Building2,
description: "Invoice header, tax, and bank details",
},
]
export default function ConfigurationPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
<p className="text-base text-muted-foreground">
Product options, invoice presentation, and company details used across the ERP.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<Link
key={card.href}
href={card.href}
className="flex items-center gap-4 rounded-2xl border bg-card p-5 shadow-sm ring-1 ring-foreground/5 transition-colors hover:bg-muted"
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<card.icon className="size-5 text-primary" />
</div>
<div>
<p className="font-semibold text-foreground">{card.title}</p>
<p className="text-sm text-muted-foreground">{card.description}</p>
</div>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,275 @@
"use client"
import { useEffect, useState } from "react"
import { LayoutTemplate } from "lucide-react"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { companyProfileApi } from "@/lib/api/company-profile"
import { errorMessage } from "@/lib/error-map"
import { InvoiceTemplateConfig, InvoiceTemplateStyle, LogoPlacement } from "@/types/invoice-template"
import { CompanyProfile } from "@/types/company-profile"
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
/**
* Invoice Template Config — a singleton settings row shared by both the Sales Invoice and
* Purchase Invoice print pages. Unlike Product Configuration's per-toggle autosave, this page
* holds two free-text fields (footer note, terms & conditions) that must not PUT on every
* keystroke, so it's an explicit-Save form instead.
*/
export default function InvoiceTemplatePage() {
const [config, setConfig] = useState<InvoiceTemplateConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [templateStyle, setTemplateStyle] = useState<InvoiceTemplateStyle>("Classic")
const [logoPlacement, setLogoPlacement] = useState<LogoPlacement>("Left")
const [showBankDetails, setShowBankDetails] = useState(true)
const [showTaxRegNo, setShowTaxRegNo] = useState(true)
const [footerNote, setFooterNote] = useState("")
const [termsAndConditions, setTermsAndConditions] = useState("")
function load() {
setError(null)
Promise.all([invoiceTemplateConfigApi.get(), companyProfileApi.get().catch(() => ({ data: null, etag: null }))])
.then(([configRes, companyRes]) => {
setConfig(configRes.data)
setEtag(configRes.etag)
setCompany(companyRes.data)
setTemplateStyle(configRes.data.templateStyle)
setLogoPlacement(configRes.data.logoPlacement)
setShowBankDetails(configRes.data.showBankDetails)
setShowTaxRegNo(configRes.data.showTaxRegNo)
setFooterNote(configRes.data.footerNote)
setTermsAndConditions(configRes.data.termsAndConditions)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function save() {
if (!etag) return
setSaving(true)
try {
const res = await invoiceTemplateConfigApi.update(
{ templateStyle, logoPlacement, showBankDetails, showTaxRegNo, footerNote, termsAndConditions },
etag
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Invoice template saved")
} catch (err) {
toast.error("Could not save invoice template", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
const previewConfig: InvoiceTemplateConfig = {
templateStyle,
logoPlacement,
showBankDetails,
showTaxRegNo,
footerNote,
termsAndConditions,
updatedAt: config?.updatedAt ?? null,
updatedBy: config?.updatedBy ?? null,
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Invoice Template</h1>
<p className="text-base text-muted-foreground">
Logo placement, footer, terms, and template style used by both Sales and Purchase invoice print views.
</p>
</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-96 w-full" />}
{!error && config && (
<>
<div className="flex flex-col gap-4 rounded-xl border p-6">
<div className="flex items-center gap-2">
<LayoutTemplate className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Layout</h2>
</div>
<div className="border-t" />
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Template style</FieldLabel>
<Select<InvoiceTemplateStyle> value={templateStyle} onValueChange={(v) => v && setTemplateStyle(v)}>
<SelectTrigger className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Classic" className="text-base">Classic</SelectItem>
<SelectItem value="Compact" className="text-base">Compact</SelectItem>
<SelectItem value="Modern" className="text-base">Modern</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Logo placement</FieldLabel>
<Select<LogoPlacement> value={logoPlacement} onValueChange={(v) => v && setLogoPlacement(v)}>
<SelectTrigger className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Left" className="text-base">Left</SelectItem>
<SelectItem value="Center" className="text-base">Center</SelectItem>
<SelectItem value="Right" className="text-base">Right</SelectItem>
<SelectItem value="None" className="text-base">None</SelectItem>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<div className="flex items-start gap-4 py-4">
<Switch checked={showBankDetails} onCheckedChange={setShowBankDetails} className="mt-1 shrink-0" aria-label="Show bank details" />
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Show bank details</span>
<span className="text-sm text-muted-foreground">Prints the company's bank name, account and SWIFT code on invoices.</span>
</div>
</div>
<div className="flex items-start gap-4 py-4">
<Switch checked={showTaxRegNo} onCheckedChange={setShowTaxRegNo} className="mt-1 shrink-0" aria-label="Show tax registration no." />
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Show tax registration no.</span>
<span className="text-sm text-muted-foreground">Prints the company's tax registration number in the invoice header.</span>
</div>
</div>
</div>
<FieldGroup className="grid grid-cols-1 gap-4">
<Field>
<FieldLabel htmlFor="tmpl-footer-note">Footer note</FieldLabel>
<Textarea id="tmpl-footer-note" value={footerNote} onChange={(e) => setFooterNote(e.target.value)} placeholder="e.g. Thank you for your business." />
</Field>
<Field>
<FieldLabel htmlFor="tmpl-terms">Terms & conditions</FieldLabel>
<Textarea id="tmpl-terms" value={termsAndConditions} onChange={(e) => setTermsAndConditions(e.target.value)} placeholder="e.g. Payment due within 30 days." />
</Field>
</FieldGroup>
<div className="flex items-center justify-between pt-2">
{config.updatedAt && (
<p className="text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span>
{(() => {
const layout = getInvoiceLayoutClasses(templateStyle)
const titleBlock = (
<div>
<div className={layout.eyebrow}>Sales Invoice</div>
<h2 className={layout.title}>INV-0001</h2>
<div className={layout.meta}>Status: Posted · Type: Standard</div>
</div>
)
const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} />
return (
<div className={`rounded-2xl border bg-white ${layout.sheet}`}>
{layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock}
{companyBlock}
</div>
) : (
<div className={`grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
</div>
)}
{layout.arrangement === "highlight" && (
<div className={layout.totalsBanner}>
<span className={layout.totalsBannerLabel}>Net payable</span>
<span className={layout.totalsBannerValue}>12,450.00</span>
</div>
)}
{layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
<div><span className="text-muted-foreground">Customer: </span><span className="font-medium text-foreground">Sample Customer Ltd</span></div>
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div>
</div>
) : (
<div className={`grid grid-cols-2 ${layout.infoGrid}`}>
<div className={layout.card}>
<div className={layout.cardLabel}>Customer</div>
<div className={layout.cardValue}>Sample Customer Ltd</div>
</div>
{layout.arrangement !== "highlight" && (
<div className={layout.card}>
<div className={layout.cardLabel}>Totals</div>
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">Net payable</span>
<span className={layout.totalsHighlight}>12,450.00</span>
</div>
</div>
)}
</div>
)}
<div className={layout.tableWrap}>
<table className="w-full text-sm">
<thead className={layout.tableHeaderRow}>
<tr>
<th className="p-2 text-left font-semibold">Item</th>
<th className="p-2 text-right font-semibold">Qty</th>
<th className="p-2 text-right font-semibold">Line total</th>
</tr>
</thead>
<tbody>
<tr className="border-t">
<td className="p-2">Sample product</td>
<td className="p-2 text-right">2.00</td>
<td className="p-2 text-right">12,450.00</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4">
<InvoicePrintFooter company={company} template={previewConfig} />
</div>
</div>
)
})()}
</div>
</>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { BellRing, Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
import { BellRing, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
const cards = [
{
@@ -21,16 +21,10 @@ const cards = [
description: "Choose which channels each module can use",
},
{
title: "Product Configuration",
href: "/dashboard/products/settings",
title: "Configuration",
href: "/dashboard/settings/configuration",
icon: SlidersHorizontal,
description: "Configure product and master-data options",
},
{
title: "Company Profile",
href: "/dashboard/settings/company-profile",
icon: Building2,
description: "Invoice header, tax, and bank details",
description: "Product, invoice template, and company profile settings",
},
]
+4
View File
@@ -239,6 +239,10 @@
break-inside: avoid;
}
.invoice-footer {
break-inside: avoid;
}
.invoice-sheet table {
width: 100% !important;
}
@@ -0,0 +1,155 @@
"use client"
import { use, useEffect, useState } from "react"
import { Printer } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { PurchaseInvoice } from "@/types/purchase-invoices"
export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const purchaseInvoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<PurchaseInvoice | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(purchaseInvoiceId)) {
setError(`Invalid purchase invoice id '${resolvedParams.id}'.`)
return
}
Promise.all([
purchaseInvoicesApi.get(purchaseInvoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([doc, companyRes, templateRes]) => {
setInvoice(doc.data)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, purchaseInvoiceId])
if (error && !invoice) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!invoice) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
const titleBlock = (
<div>
<div className={layout.eyebrow}>Purchase Invoice</div>
<h1 className={layout.title}>{invoice.docNo}</h1>
<div className={layout.meta}>
Vendor invoice: {invoice.vendorInvoiceNo} · Status: {invoice.status}
</div>
</div>
)
const companyBlock = <InvoicePrintHeader company={company} template={template} />
return (
<div className={`invoice-sheet mx-auto flex w-full max-w-5xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
{layout.arrangement === "compact" ? (
<div className={`invoice-header flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock}
{companyBlock}
</div>
) : (
<div className={`invoice-header grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
</div>
)}
{layout.arrangement === "highlight" && (
<div className={layout.totalsBanner}>
<span className={layout.totalsBannerLabel}>Grand total</span>
<span className={layout.totalsBannerValue}>{formatAmount(invoice.totals.grandTotal)}</span>
</div>
)}
{layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
<div><span className="text-muted-foreground">Vendor: </span><span className="font-medium text-foreground">{invoice.vendorSnapshotName}</span></div>
<div><span className="text-muted-foreground">Vendor invoice date: </span><span className="font-medium text-foreground">{new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</span></div>
<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={layout.card}>
<div className={layout.cardLabel}>Vendor</div>
<div className={layout.cardValue}>{invoice.vendorSnapshotName}</div>
{invoice.vendorSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
</div>
<div className={layout.card}>
<div className={layout.cardLabel}>Dates</div>
<div className="mt-2 text-sm text-muted-foreground">Vendor invoice date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
<div className="mt-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
</div>
{layout.arrangement !== "highlight" && (
<div className={layout.card}>
<div className={layout.cardLabel}>Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
</div>
</div>
)}
</div>
)}
<div className={layout.tableWrap}>
<Table>
<TableHeader>
<TableRow className={layout.tableHeaderRow}>
<TableHead>GRN</TableHead>
<TableHead>Posted</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
<TableHead className="text-right">VAT</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.purchaseInvoiceLineId}>
<TableCell className="font-medium">{line.grnDocNo}</TableCell>
<TableCell>{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="text-right">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="text-right font-medium">{formatAmount(line.lineTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<InvoicePrintFooter company={company} template={template} />
</div>
)
}
@@ -0,0 +1,137 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { PurchaseInvoiceSummary } from "@/types/purchase-invoices"
function statusClass(status: PurchaseInvoiceSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function PurchaseInvoiceBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
purchaseInvoicesApi.list({ page: 1, pageSize: 200 }),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([res, companyRes, templateRes]) => {
setRows(res.items)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery = `${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!rows) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Purchase invoice batch print data is loading or unavailable."}</div>
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
return (
<div className={`mx-auto flex w-full max-w-6xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className={`md:grid ${layout.arrangement === "compact" ? "" : "md:grid-cols-[1.4fr_1fr]"} ${layout.header}`}>
{layout.arrangement === "highlight" && <InvoicePrintHeader company={company} template={template} />}
<div>
<div className={layout.eyebrow}>Purchase Invoices</div>
<h1 className={layout.title}>Purchase Invoice Batch Print</h1>
<p className={layout.meta}>Printed register snapshot of current purchase invoices.</p>
</div>
{layout.arrangement !== "highlight" && <InvoicePrintHeader company={company} template={template} />}
</div>
<div className={layout.tableWrap}>
<Table>
<TableHeader>
<TableRow className={layout.tableHeaderRow}>
<TableHead>Invoice</TableHead>
<TableHead>Vendor invoice no.</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">GRNs</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
<TableHead className="text-right">VAT</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.purchaseInvoiceId}>
<TableCell className="font-medium">{row.docNo}</TableCell>
<TableCell>{row.vendorInvoiceNo}</TableCell>
<TableCell>{row.vendorSnapshotName}</TableCell>
<TableCell>{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{row.grnCount}</TableCell>
<TableCell className="text-right">{formatAmount(row.totals.subtotal)}</TableCell>
<TableCell className="text-right">{formatAmount(row.totals.vatTotal)}</TableCell>
<TableCell className="text-right font-medium">{formatAmount(row.totals.grandTotal)}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function PurchaseInvoiceBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<PurchaseInvoiceBatchPrintContent />
</Suspense>
)
}
@@ -9,11 +9,18 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { SalesInvoice } from "@/types/sales"
export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
@@ -24,6 +31,8 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
@@ -38,13 +47,17 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.getInvoice(invoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([cust, itemRes, uomRes, whRes, doc]) => {
.then(([cust, itemRes, uomRes, whRes, doc, companyRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setInvoice(doc.data)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, invoiceId])
@@ -61,9 +74,19 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
const customer = customers.find((c) => c.customerId === invoice.customerId)
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
const layout = getInvoiceLayoutClasses(template?.templateStyle)
const titleBlock = (
<div>
<div className={layout.eyebrow}>Sales Invoice</div>
<h1 className={layout.title}>{invoice.invoiceNo}</h1>
<div className={layout.meta}>Status: {invoice.status} · Type: {invoice.invoiceType}</div>
</div>
)
const companyBlock = <InvoicePrintHeader company={company} template={template} />
return (
<div className="invoice-sheet mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className={`invoice-sheet mx-auto flex w-full max-w-5xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
@@ -71,50 +94,65 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
</Button>
</div>
<div className="invoice-header grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
{layout.arrangement === "compact" ? (
<div className={`invoice-header flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock}
{companyBlock}
</div>
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">ERP Core Trading</div>
<div className="text-muted-foreground">Invoice print view</div>
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
) : (
<div className={`invoice-header grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 text-lg font-semibold text-foreground">{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}
{layout.arrangement === "highlight" && (
<div className={layout.totalsBanner}>
<span className={layout.totalsBannerLabel}>Net payable</span>
<span className={layout.totalsBannerValue}>{invoice.totals.netPayable.toFixed(2)}</span>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
)}
{layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
<div><span className="text-muted-foreground">Customer: </span><span className="font-medium text-foreground">{invoice.customerSnapshotName}</span></div>
<div><span className="text-muted-foreground">Warehouse: </span><span className="font-medium text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</span></div>
<div><span className="text-muted-foreground">Free qty: </span><span className="font-medium text-foreground">{freeQtyTotal.toFixed(2)}</span></div>
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>{invoice.totals.netPayable.toFixed(2)}</span></div>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<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">{invoice.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
) : (
<div className={`grid ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"} ${layout.infoGrid}`}>
<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>
<div className={layout.card}>
<div className={layout.cardLabel}>Warehouse</div>
<div className={layout.cardValue}>{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</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">{invoice.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net payable</div><div className={`text-right ${layout.totalsHighlight}`}>{invoice.totals.netPayable.toFixed(2)}</div>
</div>
</div>
)}
</div>
</div>
)}
<div className="overflow-hidden rounded-2xl border">
<div className={layout.tableWrap}>
<Table>
<TableHeader>
<TableRow>
<TableRow className={layout.tableHeaderRow}>
<TableHead className="w-[34%]">Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
@@ -144,6 +182,8 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
</TableBody>
</Table>
</div>
<InvoicePrintFooter company={company} template={template} />
</div>
)
}
@@ -5,11 +5,17 @@ import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { SalesInvoiceSummary } from "@/types/sales"
function statusClass(status: SalesInvoiceSummary["status"]) {
@@ -28,13 +34,22 @@ function SalesInvoiceBatchPrintContent() {
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
salesApi
.listInvoices({ page: 1, pageSize: 200 })
.then((res) => setRows(res.items))
.catch((err) => setError(errorMessage(err)))
Promise.all([
salesApi.listInvoices({ page: 1, pageSize: 200 }),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([res, companyRes, templateRes]) => {
setRows(res.items)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
@@ -52,8 +67,10 @@ function SalesInvoiceBatchPrintContent() {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice batch print data is loading or unavailable."}</div>
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className={`mx-auto flex w-full max-w-6xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
@@ -61,16 +78,20 @@ function SalesInvoiceBatchPrintContent() {
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoices</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Invoice Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current invoices.</p>
<div className={`md:grid ${layout.arrangement === "compact" ? "" : "md:grid-cols-[1.4fr_1fr]"} ${layout.header}`}>
{layout.arrangement === "highlight" && <InvoicePrintHeader company={company} template={template} />}
<div>
<div className={layout.eyebrow}>Sales Invoices</div>
<h1 className={layout.title}>Invoice Batch Print</h1>
<p className={layout.meta}>Printed register snapshot of current invoices.</p>
</div>
{layout.arrangement !== "highlight" && <InvoicePrintHeader company={company} template={template} />}
</div>
<div className="overflow-hidden rounded-2xl border">
<div className={layout.tableWrap}>
<Table>
<TableHeader>
<TableRow>
<TableRow className={layout.tableHeaderRow}>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
@@ -214,7 +214,7 @@ const navItems: {
children: [
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
],
},
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
@@ -34,6 +34,7 @@ const SEGMENT_LABELS: Record<string, string> = {
requisitions: "Requisitions",
rfqs: "RFQs",
"purchase-orders": "Purchase Orders",
"purchase-invoices": "Purchase Invoices",
"purchase-returns": "Purchase Returns",
production: "Production",
templates: "Templates",
@@ -0,0 +1,52 @@
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig, InvoiceTemplateStyle } from "@/types/invoice-template"
interface InvoicePrintFooterProps {
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
}
// A real (not purely cosmetic) effect of the template style: how much breathing room and
// accent the footer block gets, from denser to more formal.
const styleClass: Record<InvoiceTemplateStyle, string> = {
Compact: "border-t pt-3 gap-2",
Classic: "border-t pt-5 gap-3",
Modern: "border-t-2 border-t-primary pt-5 gap-3",
}
/** Bank details, terms & conditions, and footer note for an invoice print view — sourced from
* the shared Invoice Template Config and Company Profile. Used by both the Sales Invoice and
* Purchase Invoice print pages. */
export function InvoicePrintFooter({ company, template }: InvoicePrintFooterProps) {
if (!template) return null
const hasBankDetails =
template.showBankDetails && company && (company.bankName || company.accountNumber)
if (!hasBankDetails && !template.termsAndConditions.trim() && !template.footerNote.trim()) return null
return (
<div className={`invoice-footer flex flex-col ${styleClass[template.templateStyle]}`}>
{hasBankDetails && (
<div className="grid gap-1 text-sm">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bank details</div>
{company!.bankName && <div className="text-muted-foreground">Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</div>}
{company!.accountName && <div className="text-muted-foreground">Account name: {company!.accountName}</div>}
{company!.accountNumber && <div className="text-muted-foreground">Account number: {company!.accountNumber}</div>}
{company!.swiftCode && <div className="text-muted-foreground">SWIFT: {company!.swiftCode}</div>}
</div>
)}
{template.termsAndConditions.trim() && (
<div className="grid gap-1 text-sm">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Terms & conditions</div>
<p className="whitespace-pre-wrap text-muted-foreground">{template.termsAndConditions}</p>
</div>
)}
{template.footerNote.trim() && (
<p className="whitespace-pre-wrap text-xs text-muted-foreground">{template.footerNote}</p>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig, LogoPlacement } from "@/types/invoice-template"
interface InvoicePrintHeaderProps {
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
}
/** Company identity block for an invoice print view — logo (per template.logoPlacement),
* name/address/contact, and tax reg no (only when template.showTaxRegNo). Used by both the
* Sales Invoice and Purchase Invoice print pages so they render from one shared source. */
export function InvoicePrintHeader({ company, template }: InvoicePrintHeaderProps) {
if (!company) {
return (
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">Company details are not configured yet.</div>
<div className="text-muted-foreground">Set them up under Settings → Company Profile.</div>
</div>
)
}
const placement: LogoPlacement = template?.logoPlacement ?? "None"
const showTaxRegNo = template?.showTaxRegNo ?? false
const hasLogo = placement !== "None" && !!company.logoUrl
// Logo always sits on its own line above the address block (never beside it) — only
// its horizontal position changes with placement, and the address text below matches
// that same alignment so the whole block reads as one column, not two misaligned ones.
const alignmentClasses: Record<LogoPlacement, string> = {
Left: "justify-items-start text-left",
Center: "justify-items-center text-center",
Right: "justify-items-end text-right",
None: "md:justify-items-end md:text-right",
}
return (
<div className={`grid gap-2 text-sm ${alignmentClasses[placement]}`}>
{hasLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company.logoUrl!} alt={company.legalName} className="h-12 w-auto object-contain" />
)}
<div className="font-semibold text-foreground">{company.tradeName || company.legalName}</div>
<div className="text-muted-foreground">{company.addressLine1}</div>
{company.addressLine2 && <div className="text-muted-foreground">{company.addressLine2}</div>}
<div className="text-muted-foreground">{[company.city, company.country].filter(Boolean).join(", ")}</div>
{company.phone && <div className="text-muted-foreground">{company.phone}</div>}
{company.email && <div className="text-muted-foreground">{company.email}</div>}
{showTaxRegNo && company.taxRegistrationNo && (
<div className="text-muted-foreground">Tax Reg No: {company.taxRegistrationNo}</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
import { InvoiceTemplateStyle } from "@/types/invoice-template"
/** Which document structure a style renders as — not just color/spacing, the actual
* placement of blocks on the page:
* - "standard": title+doc no. on the left, company block on the right, then a 3-up
* info row (party, second party detail, totals card), then the line table.
* - "compact": title and company stacked into one dense strip, info collapsed into a
* single inline summary line instead of three cards, to fit more on one page.
* - "highlight": company block leads (left), title/doc no. trails (right) — reversed
* from standard — then a full-width totals banner ahead of the line table instead of
* a totals card buried in the 3-up row, with party/second-party as a 2-up row below it.
*/
export type InvoiceArrangement = "standard" | "compact" | "highlight"
/** Layout classes + arrangement that make each `InvoiceTemplateStyle` a genuinely
* different print template (structure, density, accenting) rather than a cosmetic
* tweak. Shared by the Sales Invoice and Purchase Invoice print pages so a style change
* rearranges both document types identically. */
export interface InvoiceLayoutClasses {
arrangement: InvoiceArrangement
sheet: string
header: string
eyebrow: string
title: string
meta: string
infoGrid: string
card: string
cardLabel: string
cardValue: string
tableWrap: string
tableHeaderRow: string
totalsHighlight: string
totalsBanner: string
totalsBannerLabel: string
totalsBannerValue: string
}
const presets: Record<InvoiceTemplateStyle, InvoiceLayoutClasses> = {
Classic: {
arrangement: "standard",
sheet: "gap-6 p-6",
header: "gap-4 border-b pb-5",
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground",
title: "mt-2 text-3xl font-semibold text-foreground",
meta: "mt-2 text-sm text-muted-foreground",
infoGrid: "gap-4",
card: "rounded-2xl border bg-muted/20 p-4",
cardLabel: "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
cardValue: "mt-2 text-lg font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-2xl border",
tableHeaderRow: "",
totalsHighlight: "font-semibold text-foreground",
totalsBanner: "hidden",
totalsBannerLabel: "",
totalsBannerValue: "",
},
Compact: {
arrangement: "compact",
sheet: "gap-3 p-4",
header: "gap-1 border-b pb-2",
eyebrow: "text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
title: "text-xl font-semibold text-foreground",
meta: "text-xs text-muted-foreground",
infoGrid: "gap-x-6 gap-y-1",
card: "rounded-md border bg-muted/10 p-2.5",
cardLabel: "text-[10px] font-semibold uppercase tracking-wide text-muted-foreground",
cardValue: "mt-1 text-sm font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-md border",
tableHeaderRow: "",
totalsHighlight: "font-semibold text-foreground",
totalsBanner: "hidden",
totalsBannerLabel: "",
totalsBannerValue: "",
},
Modern: {
arrangement: "highlight",
sheet: "gap-6 p-6",
header: "gap-4 border-b-2 border-b-primary pb-5",
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-primary",
title: "mt-2 text-3xl font-bold text-foreground",
meta: "mt-2 text-sm text-muted-foreground",
infoGrid: "gap-4",
card: "rounded-2xl border border-primary/20 bg-primary/5 p-4",
cardLabel: "text-xs font-semibold uppercase tracking-wide text-primary",
cardValue: "mt-2 text-lg font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-2xl border border-primary/20",
tableHeaderRow: "bg-primary/10 [&_th]:text-primary",
totalsHighlight: "font-semibold text-primary",
totalsBanner: "flex items-center justify-between rounded-2xl bg-primary px-5 py-4 text-primary-foreground",
totalsBannerLabel: "text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80",
totalsBannerValue: "text-2xl font-bold text-primary-foreground",
},
}
export function getInvoiceLayoutClasses(style: InvoiceTemplateStyle | undefined): InvoiceLayoutClasses {
return presets[style ?? "Classic"]
}
@@ -3,11 +3,11 @@
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { grnsApi } from "@/lib/api/grns"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { Grn, GrnPayment } from "@/types/grn"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -16,16 +16,25 @@ import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
interface GrnPaymentDialogProps {
grn: Grn | null
interface PurchaseInvoicePaymentDialogProps {
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
// touches (id/no/status/balance) is already on the summary, so a list row can open it
// without a separate get() round trip. PurchaseInvoice extends PurchaseInvoiceSummary, so
// the detail page's full invoice still satisfies this as-is.
invoice: PurchaseInvoiceSummary | null
open: boolean
onOpenChange: (open: boolean) => void
onPaid: (grn: Grn, payment: GrnPayment) => 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. */
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
}
/** Pay the vendor against a confirmed GRN's balance — full or partial, from an existing
* GL cash/bank account. Modeled on ReceivedChequeDialog's "pick an account, submit" shape. */
export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPaymentDialogProps) {
/** Record a vendor payment against a Purchase 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. Modeled on
* SalesInvoicePaymentDialog's "pick an account, submit" shape. */
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
const [amount, setAmount] = useState("")
@@ -33,15 +42,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Reset the form fields for a fresh open (or a different GRN) during render, React's own
// sanctioned "adjust state while rendering" pattern (see the General Ledger report page's
// periodKey comment) — not inside the effect below, which would be a synchronous
// setState-in-effect (react-hooks/set-state-in-effect).
const openKey = open && grn ? `${grn.grnId}` : null
// Reset the form fields for a fresh open (or a different invoice) during render — same
// "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect.
const openKey = open && invoice ? `${invoice.purchaseInvoiceId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setAmount(grn!.balanceAmount.toFixed(2))
setAmount(invoice!.totals.balanceAmount.toFixed(2))
setGlBankAccountId("")
setReference("")
setErrors({})
@@ -52,30 +59,46 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
}, [open, accounts])
if (!grn) return null
if (!invoice) return null
async function submit() {
if (!grn) return
if (!invoice) return
const nextErrors: Record<string, string> = {}
const amountNum = Number(amount)
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from"
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
else if (amountNum > grn.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(grn.balanceAmount)})`
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const payment = await grnsApi.pay(grn.grnId, {
if (invoice.status === "Draft") {
try {
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
return
}
}
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
amount: amountNum,
glBankAccountId: Number(glBankAccountId),
reference: reference || undefined,
})
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
onPaid(grn, payment)
const fullyPaid = amountNum >= invoice.totals.balanceAmount
toast.success(
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
)
onPaid(payment, "Posted")
onOpenChange(false)
} catch (err) {
toast.error("Could not record payment", errorMessage(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)
}
@@ -85,18 +108,19 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Pay GRN {grn.docNo}</DialogTitle>
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(grn.balanceAmount)}</span>
{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>
</p>
<FieldGroup>
<Field data-invalid={!!errors.glBankAccountId}>
<FieldLabel htmlFor="grn-pay-account">Pay from</FieldLabel>
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
<SelectTrigger id="grn-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
<SelectTrigger id="pinv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
</SelectTrigger>
<SelectContent>
@@ -111,13 +135,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="grn-pay-amount">Amount</FieldLabel>
<FieldLabel htmlFor="pinv-pay-amount">Amount</FieldLabel>
<Input
id="grn-pay-amount"
id="pinv-pay-amount"
type="number"
step="0.01"
min="0.01"
max={grn.balanceAmount}
max={invoice.totals.balanceAmount}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
@@ -126,8 +150,8 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
</Field>
<Field>
<FieldLabel htmlFor="grn-pay-reference">Reference (optional)</FieldLabel>
<Input id="grn-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
</FieldGroup>
@@ -136,7 +160,11 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : "Record payment"}
{submitting
? "Recording…"
: invoice.status === "Draft"
? "Confirm & record payment"
: "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
import { Badge } from "@/components/ui/badge"
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
// Fixed width (not w-fit) so every status pill is the same size regardless of
// label length, same convention as components/receiving/status-badges.tsx.
@@ -43,3 +44,53 @@ export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
</Badge>
)
}
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
}
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
{purchaseInvoiceStatusLabel[status]}
</Badge>
)
}
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
{purchaseInvoicePaymentLabel[status]}
</Badge>
)
}
@@ -32,32 +32,3 @@ export function HoldStatusBadge({ status }: { status: HoldStatus }) {
</Badge>
)
}
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function paymentClass(status: GrnPaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const paymentLabel: Record<GrnPaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partial",
Paid: "Paid",
}
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = grnPaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
@@ -32,7 +32,8 @@ interface SalesInvoicePaymentDialogProps {
/** 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. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
* PurchaseInvoicePaymentDialog on the AP side. */
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
@@ -42,7 +43,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
const [submitting, setSubmitting] = useState(false)
// Reset the form fields for a fresh open (or a different invoice) during render — same
// "adjust state while rendering" pattern as GrnPaymentDialog, not inside an effect.
// "adjust state while rendering" pattern, not inside an effect.
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-20 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,94 @@
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
// (backend-not-built-yet) client. Restore before committing.
import { ApiResult } from "@/types/common"
import { CompanyProfile, UpdateCompanyProfileRequest } from "@/types/company-profile"
const STORAGE_KEY = "mock:company-profile"
const defaultProfile: CompanyProfile = {
legalName: "Hexa Trading (Private) Limited",
tradeName: "Hexa ERP",
taxRegistrationNo: "TRN-994521",
vatRegistrationNo: "VAT-118823",
addressLine1: "No. 42, Galle Road",
addressLine2: "Colombo 03",
city: "Colombo",
country: "Sri Lanka",
phone: "+94 11 234 5678",
email: "accounts@hexatrading.example",
bankName: "Commercial Bank of Ceylon",
bankBranch: "Colombo Main",
accountName: "Hexa Trading (Private) Limited",
accountNumber: "8001234567",
swiftCode: "CCEYLKLX",
logoUrl: null,
updatedAt: null,
updatedBy: null,
}
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
// module-level variable would reset to defaultProfile and never see what Settings saved.
// localStorage is the same origin across tabs, so it survives that reload.
function readProfile(): CompanyProfile {
if (typeof window === "undefined") return defaultProfile
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? { ...defaultProfile, ...JSON.parse(raw) } : defaultProfile
} catch {
return defaultProfile
}
}
function writeProfile(next: CompanyProfile) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// best-effort mock persistence; ignore storage failures
}
}
let profile: CompanyProfile = readProfile()
function etag() {
return `"cp-${profile.updatedAt ?? "initial"}"`
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export const companyProfileApi = {
async get(): Promise<ApiResult<CompanyProfile>> {
profile = readProfile()
return { data: profile, etag: etag() }
},
async update(request: UpdateCompanyProfileRequest, _ifMatch: string): Promise<ApiResult<CompanyProfile>> {
profile = { ...profile, ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
async uploadLogo(file: File): Promise<ApiResult<CompanyProfile>> {
const logoUrl = await readAsDataUrl(file)
profile = { ...profile, logoUrl, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
async deleteLogo(): Promise<ApiResult<CompanyProfile>> {
profile = { ...profile, logoUrl: null, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
}
export async function companyProfile(): Promise<CompanyProfile> {
return readProfile()
}
-12
View File
@@ -10,10 +10,8 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ConfirmGrnResponse,
CreateGrnPaymentRequest,
CreateGrnRequest,
Grn,
GrnPayment,
GrnStatus,
GrnSummary,
ReleaseAction,
@@ -60,14 +58,4 @@ export const grnsApi = {
body: { action },
})
},
/** Pay the vendor against the GRN's balance, in full or in installments. Posts a real GL journal entry. */
pay(grnId: number, request: CreateGrnPaymentRequest): Promise<GrnPayment> {
return apiRequest<GrnPayment>(`/grns/${grnId}/payments`, { method: "POST", body: request })
},
/** Payment history for a GRN, newest first. */
listPayments(grnId: number): Promise<GrnPayment[]> {
return apiRequest<GrnPayment[]>(`/grns/${grnId}/payments`)
},
}
@@ -0,0 +1,62 @@
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
// (backend-not-built-yet) client. Restore before committing.
import { ApiResult } from "@/types/common"
import { InvoiceTemplateConfig, UpdateInvoiceTemplateConfigRequest } from "@/types/invoice-template"
const STORAGE_KEY = "mock:invoice-template-config"
const defaultConfig: InvoiceTemplateConfig = {
templateStyle: "Classic",
logoPlacement: "Left",
showBankDetails: true,
showTaxRegNo: true,
footerNote: "Thank you for your business.",
termsAndConditions: "Payment due within 30 days of invoice date.",
updatedAt: null,
updatedBy: null,
}
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
// module-level variable would reset to defaultConfig and never see what Settings saved.
// localStorage is the same origin across tabs, so it survives that reload.
function readConfig(): InvoiceTemplateConfig {
if (typeof window === "undefined") return defaultConfig
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? { ...defaultConfig, ...JSON.parse(raw) } : defaultConfig
} catch {
return defaultConfig
}
}
function writeConfig(next: InvoiceTemplateConfig) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// best-effort mock persistence; ignore storage failures
}
}
let config: InvoiceTemplateConfig = readConfig()
function etag() {
return `"tmpl-${config.updatedAt ?? "initial"}"`
}
export const invoiceTemplateConfigApi = {
async get(): Promise<ApiResult<InvoiceTemplateConfig>> {
config = readConfig()
return { data: config, etag: etag() }
},
async update(request: UpdateInvoiceTemplateConfigRequest, _ifMatch: string): Promise<ApiResult<InvoiceTemplateConfig>> {
config = { ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeConfig(config)
return { data: config, etag: etag() }
},
}
export async function invoiceTemplateConfig(): Promise<InvoiceTemplateConfig> {
return readConfig()
}
@@ -0,0 +1,67 @@
// One typed client method per Purchase Invoice endpoint. A purchase invoice consolidates
// one or more Confirmed GRNs for the same vendor (each GRN becomes one line — see
// types/purchase-invoices.ts); item-level detail stays on the GRN. Draft invoices are
// editable via update(); post()/cancel() are the only status transitions.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreatePurchaseInvoicePaymentRequest,
CreatePurchaseInvoiceRequest,
GrnEligibleForInvoice,
PurchaseInvoice,
PurchaseInvoiceListFilterParams,
PurchaseInvoicePayment,
PurchaseInvoiceSummary,
UpdatePurchaseInvoiceRequest,
} from "@/types/purchase-invoices"
export const purchaseInvoicesApi = {
list(params: PurchaseInvoiceListFilterParams = {}): Promise<PagedResponse<PurchaseInvoiceSummary>> {
return apiRequest<PagedResponse<PurchaseInvoiceSummary>>(`/purchase-invoices${buildQuery(params)}`)
},
get(purchaseInvoiceId: number): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`)
},
/** 409 GRN_NOT_CONFIRMED / GRN_VENDOR_MISMATCH / GRN_ALREADY_INVOICED, or DUPLICATE_VENDOR_INVOICE_NO. */
create(request: CreatePurchaseInvoiceRequest): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>("/purchase-invoices", { method: "POST", body: request })
},
/** Draft only (409 PURCHASE_INVOICE_NOT_EDITABLE otherwise). Echo the ETag from get()/create() as ifMatch. */
update(purchaseInvoiceId: number, request: UpdatePurchaseInvoiceRequest, ifMatch: string): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
post(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/post`, { method: "POST" })
},
/** Draft only. */
cancel(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/cancel`, { method: "POST" })
},
/** Posted only (409 PURCHASE_INVOICE_NOT_PAYABLE); amount capped at the remaining balance. */
pay(purchaseInvoiceId: number, request: CreatePurchaseInvoicePaymentRequest): Promise<PurchaseInvoicePayment> {
return apiRequest<PurchaseInvoicePayment>(`/purchase-invoices/${purchaseInvoiceId}/payments`, {
method: "POST",
body: request,
})
},
/** Payment history, newest first. */
listPayments(purchaseInvoiceId: number): Promise<PurchaseInvoicePayment[]> {
return apiRequest<PurchaseInvoicePayment[]>(`/purchase-invoices/${purchaseInvoiceId}/payments`)
},
/** Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice. */
listEligibleGrns(vendorId: number): Promise<GrnEligibleForInvoice[]> {
return apiRequest<GrnEligibleForInvoice[]>(`/purchase-invoices/eligible-grns${buildQuery({ vendorId })}`)
},
}
+6
View File
@@ -35,6 +35,7 @@ const DETAIL_PREFERRED_CODES = new Set([
"TRANSFER_EXCEEDS_AVAILABLE",
"LEFTOVER_EXCEEDS_CONSUMED",
"REQUIRED_FIELD_MISSING",
"DUPLICATE_VENDOR_INVOICE_NO",
])
const CODE_MESSAGES: Record<string, string> = {
@@ -44,6 +45,11 @@ const CODE_MESSAGES: Record<string, string> = {
ONHOLD_NOT_ISSUABLE: "This stock is on hold/quarantined and cannot be issued.",
REASON_CODE_REQUIRED: "A reason code is required for this action.",
PO_NOT_EDITABLE: "This purchase order can no longer be edited.",
GRN_ALREADY_INVOICED: "This GRN has already been added to another purchase invoice.",
GRN_VENDOR_MISMATCH: "All GRNs on a purchase invoice must be from the same vendor.",
GRN_NOT_CONFIRMED: "Only confirmed GRNs can be added to a purchase invoice.",
PURCHASE_INVOICE_NOT_EDITABLE: "This purchase invoice can no longer be edited.",
DUPLICATE_VENDOR_INVOICE_NO: "This vendor invoice number has already been recorded for this vendor.",
SKU_DUPLICATE: "That SKU already exists.",
MASTER_IN_USE: "This record is referenced by other data and cannot be deleted.",
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
@@ -0,0 +1,22 @@
// Client-side UX validation only — same thin convention as lib/validations/sales.ts.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function validateCompanyProfile(input: {
legalName: string
addressLine1: string
city: string
country: string
phone: string
email: string
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.legalName.trim()) errors.legalName = "Enter the company's legal name"
if (!input.addressLine1.trim()) errors.addressLine1 = "Enter an address"
if (!input.city.trim()) errors.city = "Enter a city"
if (!input.country.trim()) errors.country = "Enter a country"
if (!input.phone.trim()) errors.phone = "Enter a phone number"
if (!input.email.trim()) errors.email = "Enter an email address"
else if (!EMAIL_RE.test(input.email.trim())) errors.email = "Enter a valid email address"
return errors
}
@@ -0,0 +1,21 @@
// Client-side UX validation only — required fields, format/range checks the browser can
// already see. Server-authoritative rules (GRN eligibility, vendor match, duplicate vendor
// invoice numbers) are never re-implemented here. Same pattern as lib/validations/sales.ts.
export function validatePurchaseInvoiceHeader(input: {
vendorId: number | null
vendorInvoiceNo: string
vendorInvoiceDate: string
grnIds: number[]
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.vendorId) errors.vendorId = "Select a vendor"
if (!input.vendorInvoiceNo.trim()) errors.vendorInvoiceNo = "Enter the vendor's invoice number"
if (!input.vendorInvoiceDate) {
errors.vendorInvoiceDate = "Enter the vendor's invoice date"
} else if (input.vendorInvoiceDate > new Date().toISOString().slice(0, 10)) {
errors.vendorInvoiceDate = "Invoice date cannot be in the future"
}
if (input.grnIds.length === 0) errors.grnIds = "Select at least one GRN to consolidate"
return errors
}
@@ -0,0 +1,27 @@
// Company Profile — singleton settings row (legal identity, contact, bank, logo) consumed by
// the invoice print pages via components/invoice-template/InvoicePrintHeader.tsx. `footerNote`
// deliberately lives on InvoiceTemplateConfig instead — this owns identity/contact/bank facts only.
export interface CompanyProfile {
legalName: string
tradeName: string
taxRegistrationNo: string | null
vatRegistrationNo: string | null
addressLine1: string
addressLine2: string | null
city: string
country: string
phone: string
email: string
bankName: string | null
bankBranch: string | null
accountName: string | null
accountNumber: string | null
swiftCode: string | null
logoUrl: string | null
updatedAt: string | null
updatedBy: number | null
}
/** Text fields only — the logo is managed separately via companyProfileApi.uploadLogo/deleteLogo. */
export type UpdateCompanyProfileRequest = Omit<CompanyProfile, "logoUrl" | "updatedAt" | "updatedBy">
+7 -25
View File
@@ -102,8 +102,11 @@ export interface Grn {
postedAt: string | null
/** Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm). */
glJournalNo: string | null
paidAmount: number
balanceAmount: number
/** Set once this GRN is consolidated into a Purchase Invoice — see types/purchase-invoices.ts.
* Denormalized so GRN pages can show the link without an extra fetch. Vendor payment now
* happens entirely on the Purchase Invoice, not here. */
purchaseInvoiceId: number | null
purchaseInvoiceNo: string | null
lines: GrnLine[]
}
@@ -118,8 +121,8 @@ export interface GrnSummary {
createdAt: string
postedAt: string | null
lineCount: number
paidAmount: number
balanceAmount: number
purchaseInvoiceId: number | null
purchaseInvoiceNo: string | null
}
/** docs/11 §4.2 confirm response. */
@@ -139,7 +142,6 @@ export interface ConfirmGrnResponse {
status: GrnStatus
postedAt: string
glJournalNo: string
balanceAmount: number
createdLayers: CreatedLayer[]
ledgerRefs: number[]
poStatus: PurchaseOrderStatus | null
@@ -155,23 +157,3 @@ export interface ReleaseGrnLineResponse {
grnLineId: number
holdStatus: HoldStatus
}
/** A vendor payment against a confirmed GRN's balance (installments allowed). */
export interface GrnPayment {
grnPaymentId: number
grnId: number
amount: number
paymentDate: string
glBankAccountId: number
bankAccountName: string
reference: string | null
/** Journal number of the real GL journal entry this payment posted. */
glJournalNo: string | null
createdAt: string
}
export interface CreateGrnPaymentRequest {
amount: number
glBankAccountId: number
reference?: string | null
}
@@ -0,0 +1,21 @@
// Invoice Template Config — singleton settings row shared by both Sales Invoice and
// Purchase Invoice print pages (see components/invoice-template/). Flat fields + RowVersion,
// same shape as ProductConfig (types/master-data.ts), not a catalog/child-collection —
// the field set here is small and fixed.
export type InvoiceTemplateStyle = "Classic" | "Compact" | "Modern"
export type LogoPlacement = "Left" | "Center" | "Right" | "None"
export interface InvoiceTemplateConfig {
templateStyle: InvoiceTemplateStyle
logoPlacement: LogoPlacement
showBankDetails: boolean
showTaxRegNo: boolean
footerNote: string
termsAndConditions: string
updatedAt: string | null
updatedBy: number | null
}
/** All fields are required; a partial body is a 400, never a silent disable (same rule as ProductConfig). */
export type UpdateInvoiceTemplateConfigRequest = Omit<InvoiceTemplateConfig, "updatedAt" | "updatedBy">
@@ -0,0 +1,94 @@
// Purchase Invoice (AP) types — mirrors the assumed backend contract (see the approved
// plan at .claude/plans/silly-roaming-codd.md Part A.1). A PurchaseInvoiceGrnLine is one
// consolidated GRN, not a re-itemization of GRN item lines — the GRN already owns
// item-level detail.
export type PurchaseInvoiceStatus = "Draft" | "Posted" | "Cancelled"
export interface PurchaseInvoiceGrnLine {
purchaseInvoiceLineId: number
grnId: number
grnDocNo: string
grnPostedAt: string
subtotal: number
vatTotal: number
lineTotal: number
}
export interface PurchaseInvoiceTotals {
subtotal: number
vatTotal: number
grandTotal: number
paidAmount: number
balanceAmount: number
}
export interface PurchaseInvoiceSummary {
purchaseInvoiceId: number
docNo: string
/** The vendor's own invoice/bill number — captured nowhere else in the app. */
vendorInvoiceNo: string
vendorInvoiceDate: string
vendorId: number
vendorSnapshotName: string
status: PurchaseInvoiceStatus
grnCount: number
totals: PurchaseInvoiceTotals
createdAt: string
glJournalNo: string | null
}
export interface PurchaseInvoice extends PurchaseInvoiceSummary {
vendorSnapshotTaxNo: string | null
createdBy: number
updatedAt: string | null
glPostedAt: string | null
lines: PurchaseInvoiceGrnLine[]
}
export interface PurchaseInvoicePayment {
purchaseInvoicePaymentId: number
purchaseInvoiceId: number
amount: number
paymentDate: string
glBankAccountId: number
bankAccountName: string
reference: string | null
glJournalNo: string | null
createdAt: string
}
export interface CreatePurchaseInvoicePaymentRequest {
amount: number
glBankAccountId: number
reference?: string | null
}
export interface CreatePurchaseInvoiceRequest {
vendorId: number
vendorInvoiceNo: string
vendorInvoiceDate: string
/** Must be non-empty, Confirmed, same vendor, and not already linked to another invoice. */
grnIds: number[]
}
export type UpdatePurchaseInvoiceRequest = CreatePurchaseInvoiceRequest
/** A Confirmed, not-yet-invoiced GRN for a vendor — offered when building a new Purchase Invoice. */
export interface GrnEligibleForInvoice {
grnId: number
docNo: string
postedAt: string
warehouseId: number
lineCount: number
subtotal: number
vatTotal: number
grandTotal: number
}
export interface PurchaseInvoiceListFilterParams {
page?: number
pageSize?: number
status?: PurchaseInvoiceStatus
vendorId?: number
}