Merge pull request 'Dev' (#83) from dev into production

Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-10-21 13:14:19 +05:30
31 changed files with 14123 additions and 220 deletions
+51
View File
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<InvoiceItem> InvoiceItems { get; set; }
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
@@ -175,6 +178,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Invoice)
.WithMany(inv => inv.Items)
.HasForeignKey(i => i.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasOne(v => v.Invoice)
.WithMany()
.HasForeignKey(v => v.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
.IsUnique();
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.InvoiceVersion)
.WithMany(v => v.Items)
.HasForeignKey(i => i.FK_InvoiceVersionId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
@@ -10,5 +10,10 @@ public interface IInvoiceRepository
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task AddAsync(Models.Invoice invoice);
Task<Models.Item?> GetItemAsync(Guid itemId);
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
Task AddVersionAsync(InvoiceVersion version);
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
void Update(Models.Invoice invoice);
}
@@ -52,6 +52,29 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id)
{
var result = await service.GetVersionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.EditedByUserId;
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
try
{
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
+28 -2
View File
@@ -3,10 +3,35 @@
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null);
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
public record InvoiceVersionResponse(
Guid Id,
int VersionNumber,
InvoiceVersionStage Stage,
decimal TotalAmount,
Guid CreatedBy,
DateTime CreatedAt,
List<InvoiceVersionItemResponse> Items);
public record InvoiceItemResponse(
Guid Id,
Guid? ShopPOItemId,
Guid ItemId,
string ItemName,
int? OrderedQuantity,
int Quantity,
decimal UnitPrice,
bool IsRemoved);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
@@ -22,6 +47,7 @@ public record InvoiceResponse(
decimal TotalAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt);
DateTime CreatedAt,
List<InvoiceItemResponse> Items);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
@@ -52,6 +54,27 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
await context.InvoiceItems.AddRangeAsync(items);
public async Task AddVersionAsync(InvoiceVersion version) =>
await context.InvoiceVersions.AddAsync(version);
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Where(v => v.FK_InvoiceId == invoiceId)
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Include(v => v.Items).ThenInclude(i => i.Item)
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
.OrderBy(v => v.VersionNumber)
.ToListAsync();
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
}
+163 -16
View File
@@ -21,7 +21,61 @@ public class InvoiceService(
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt);
i.IsActive, i.CreatedAt,
i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
// Immutable copy of the invoice lines as they stand right now.
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new()
{
FK_InvoiceId = invoice.Id,
VersionNumber = versionNumber,
Stage = stage,
TotalAmount = invoice.TotalAmount,
CreatedBy = userId,
Items = items.Select(i => new InvoiceVersionItem
{
FK_ShopPOItemId = i.FK_ShopPOItemId,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsRemoved = i.IsRemoved
}).ToList()
};
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
{
if (await repo.GetByIdAsync(id) is null) return null;
var versions = await repo.GetVersionsAsync(id);
return versions.Select(v => new InvoiceVersionResponse(
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
v.Items.Select(i => new InvoiceVersionItemResponse(
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
}
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
{
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
// Added lines may or may not be in invoice.Items yet, so merge by reference.
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
}
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
{
FK_InvoiceId = invoiceId,
FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
@@ -69,6 +123,9 @@ public class InvoiceService(
try
{
await repo.AddAsync(invoice);
var lines = BuildItemsFromShopPO(po, invoice.Id);
await repo.AddItemsAsync(lines);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
await uow.CommitAsync(ct);
@@ -79,6 +136,85 @@ public class InvoiceService(
return ToResponse(saved!);
}
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
var newLines = new List<InvoiceItem>();
foreach (var edit in request.Items)
{
if (edit.UnitPrice is < 0)
throw new InvalidOperationException("Unit price cannot be negative");
if (edit.InvoiceItemId is Guid lineId)
{
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
item.IsRemoved = edit.IsRemoved;
if (!edit.IsRemoved)
{
ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity;
}
if (edit.UnitPrice is decimal price) item.UnitPrice = price;
}
else if (edit.ItemId is Guid itemId)
{
// New line for an item that was not on the PO.
if (edit.IsRemoved) continue;
if (edit.Quantity <= 0)
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
if (edit.UnitPrice is null)
throw new InvalidOperationException("Unit price is required for an added item");
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
var catalogItem = await repo.GetItemAsync(itemId)
?? throw new InvalidOperationException($"Item {itemId} not found");
newLines.Add(new InvoiceItem
{
FK_InvoiceId = invoice.Id,
FK_ItemId = catalogItem.Id,
Item = catalogItem,
Quantity = edit.Quantity,
UnitPrice = edit.UnitPrice.Value
});
}
else
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
}
invoice.TotalAmount = BillableTotal(invoice.Items.Concat(newLines));
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
private static void ValidateQuantity(InvoiceItem item, int quantity)
{
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
if (quantity < 0 || quantity > ordered)
throw new InvalidOperationException(
$"Quantity for item {item.Id} must be between 0 and {ordered}");
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
@@ -92,7 +228,12 @@ public class InvoiceService(
invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
@@ -106,31 +247,37 @@ public class InvoiceService(
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var items = invoice.Items.Where(i => i.IsActive).ToList();
var adjustments = request.Adjustments ?? [];
if (adjustments.Count > 0)
{
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
decimal total = 0;
foreach (var item in poItems)
{
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
if (billedQuantity < 0 || billedQuantity > item.Quantity)
throw new InvalidOperationException(
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
foreach (var adjustment in adjustments)
{
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
total += billedQuantity * item.UnitPrice;
item.IsRemoved = adjustment.IsRemoved;
if (!adjustment.IsRemoved)
{
ValidateQuantity(item, adjustment.AdjustedQuantity);
item.Quantity = adjustment.AdjustedQuantity;
}
invoice.TotalAmount = total;
}
if (items.Count > 0)
invoice.TotalAmount = BillableTotal(items);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
@@ -217,6 +217,9 @@ public class ShopPOService(
try
{
await invoiceRepo.AddAsync(invoice);
var lines = InvoiceService.BuildItemsFromShopPO(po, invoice.Id);
await invoiceRepo.AddItemsAsync(lines);
await invoiceRepo.AddVersionAsync(InvoiceService.BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, userId));
po.InvoiceId = invoice.Id;
repo.Update(po);
await uow.CommitAsync(ct);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InvoiceItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceItems", x => x.Id);
table.ForeignKey(
name: "FK_InvoiceItems_Invoices_FK_InvoiceId",
column: x => x.FK_InvoiceId,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_InvoiceItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_InvoiceItems_ShopPOItems_FK_ShopPOItemId",
column: x => x.FK_ShopPOItemId,
principalTable: "ShopPOItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_InvoiceItems_FK_InvoiceId",
table: "InvoiceItems",
column: "FK_InvoiceId");
migrationBuilder.CreateIndex(
name: "IX_InvoiceItems_FK_ItemId",
table: "InvoiceItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_InvoiceItems_FK_ShopPOItemId",
table: "InvoiceItems",
column: "FK_ShopPOItemId");
// Backfill: snapshot each existing invoice's PO lines so older invoices can be edited/finalized.
migrationBuilder.Sql(@"
INSERT INTO InvoiceItems (Id, FK_InvoiceId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, CreatedAt, IsActive)
SELECT UUID(), inv.Id, poi.Id, poi.FK_ItemId, poi.Quantity, poi.UnitPrice, NOW(6), 1
FROM Invoices inv
JOIN ShopPOItems poi ON poi.FK_ShopPOId = inv.FK_ShopPOId AND poi.IsActive = 1;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InvoiceItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceVersionsAndLineEdits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Guid>(
name: "FK_ShopPOItemId",
table: "InvoiceItems",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsRemoved",
table: "InvoiceItems",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "InvoiceVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_InvoiceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
VersionNumber = table.Column<int>(type: "int", nullable: false),
Stage = table.Column<int>(type: "int", nullable: false),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceVersions", x => x.Id);
table.ForeignKey(
name: "FK_InvoiceVersions_Invoices_FK_InvoiceId",
column: x => x.FK_InvoiceId,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "InvoiceVersionItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_InvoiceVersionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IsRemoved = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceVersionItems", x => x.Id);
table.ForeignKey(
name: "FK_InvoiceVersionItems_InvoiceVersions_FK_InvoiceVersionId",
column: x => x.FK_InvoiceVersionId,
principalTable: "InvoiceVersions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_InvoiceVersionItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_InvoiceVersionItems_ShopPOItems_FK_ShopPOItemId",
column: x => x.FK_ShopPOItemId,
principalTable: "ShopPOItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_InvoiceVersionItems_FK_InvoiceVersionId",
table: "InvoiceVersionItems",
column: "FK_InvoiceVersionId");
migrationBuilder.CreateIndex(
name: "IX_InvoiceVersionItems_FK_ItemId",
table: "InvoiceVersionItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_InvoiceVersionItems_FK_ShopPOItemId",
table: "InvoiceVersionItems",
column: "FK_ShopPOItemId");
migrationBuilder.CreateIndex(
name: "IX_InvoiceVersions_FK_InvoiceId_VersionNumber",
table: "InvoiceVersions",
columns: new[] { "FK_InvoiceId", "VersionNumber" },
unique: true);
// Backfill: one baseline version per existing invoice from its current lines.
migrationBuilder.Sql(@"
INSERT INTO InvoiceVersions (Id, FK_InvoiceId, VersionNumber, Stage, TotalAmount, CreatedBy, CreatedAt, IsActive)
SELECT UUID(), inv.Id, 1,
CASE inv.Status WHEN 0 THEN 0 WHEN 1 THEN 2 ELSE 3 END,
inv.TotalAmount, inv.CreatedBy, NOW(6), 1
FROM Invoices inv;");
migrationBuilder.Sql(@"
INSERT INTO InvoiceVersionItems (Id, FK_InvoiceVersionId, FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice, IsRemoved, CreatedAt, IsActive)
SELECT UUID(), v.Id, ii.FK_ShopPOItemId, ii.FK_ItemId, ii.Quantity, ii.UnitPrice, ii.IsRemoved, NOW(6), 1
FROM InvoiceVersions v
JOIN InvoiceItems ii ON ii.FK_InvoiceId = v.FK_InvoiceId AND ii.IsActive = 1
WHERE v.VersionNumber = 1;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InvoiceVersionItems");
migrationBuilder.DropTable(
name: "InvoiceVersions");
migrationBuilder.DropColumn(
name: "IsRemoved",
table: "InvoiceItems");
migrationBuilder.AlterColumn<Guid>(
name: "FK_ShopPOItemId",
table: "InvoiceItems",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
}
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -18,6 +18,7 @@ namespace Distribution.Models
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum InvoiceStatus { Draft, Printed, Finalized }
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
+1
View File
@@ -21,6 +21,7 @@ namespace Distribution.Models
public decimal TotalAmount { get; set; }
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class InvoiceItem : Template
{
public Guid FK_InvoiceId { get; set; }
[ForeignKey("FK_InvoiceId")]
public Invoice Invoice { get; set; }
public Guid? FK_ShopPOItemId { get; set; }
[ForeignKey("FK_ShopPOItemId")]
public ShopPOItem? ShopPOItem { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public bool IsRemoved { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
// Immutable snapshot of an invoice's lines at a point in its lifecycle.
public class InvoiceVersion : Template
{
public Guid FK_InvoiceId { get; set; }
[ForeignKey("FK_InvoiceId")]
public Invoice Invoice { get; set; }
public int VersionNumber { get; set; }
public InvoiceVersionStage Stage { get; set; }
public decimal TotalAmount { get; set; }
public Guid CreatedBy { get; set; }
public ICollection<InvoiceVersionItem> Items { get; set; } = new List<InvoiceVersionItem>();
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class InvoiceVersionItem : Template
{
public Guid FK_InvoiceVersionId { get; set; }
[ForeignKey("FK_InvoiceVersionId")]
public InvoiceVersion InvoiceVersion { get; set; }
public Guid? FK_ShopPOItemId { get; set; }
[ForeignKey("FK_ShopPOItemId")]
public ShopPOItem? ShopPOItem { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public bool IsRemoved { get; set; }
}
}
@@ -82,6 +82,89 @@ Invoice
├── IsActive, CreatedAt — from Template
```
### Invoice Line Items & Version History
> **Implemented.** Class/column names follow the code: the PO is `ShopPO`/`ShopPOItem`, and the
> invoice FK is `FK_ShopPOId` (not `FK_OrderId` as sketched above).
The invoice no longer bills straight from the PO. It owns its own lines, and every change to them is
snapshotted, so the original, printed and finalized quantities can all be compared.
```
InvoiceItem — the live billing lines
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
├── UnitPrice (decimal)
└── IsActive, CreatedAt — from Template
InvoiceVersion — immutable snapshot of the invoice
├── FK_InvoiceId, VersionNumber — unique per invoice, starts at 1
├── Stage (InvoiceVersionStage) — Created, DraftEdited, Printed, Finalized
├── TotalAmount, CreatedBy
└── Items → InvoiceVersionItem — FK_ShopPOItemId, FK_ItemId, Quantity, UnitPrice
```
- **Add / remove lines (Draft):** `PUT /api/invoices/{id}/items` takes edits keyed by `invoiceItemId`
(change quantity/price, or `isRemoved: true` to strike the line — it is kept, excluded from the
total, shown struck through on the invoice view and print copy in every status, and can be
restored while Draft) or by `itemId` (add a catalog item
that was not on the PO; `unitPrice` required, quantity > 0, no duplicate of a live line). Added
lines have no `ShopPOItem` (`FK_ShopPOItemId` is nullable → no `orderedQuantity`, no max). Versions
store `IsRemoved` per line. At `Finalize`, adjustments are keyed by `invoiceItemId` and can also
set `isRemoved`. When the loading sheet is linked to invoices, it must count added lines.
- **Original PO quantity** stays on `ShopPOItem.Quantity` (locked once the PO is confirmed) and is
returned as `orderedQuantity` on each invoice line.
- `InvoiceItem` rows are copied from the PO's active lines at invoice creation (Mode A and Mode B).
- A version is written, in the same transaction as the change, when the invoice is:
| Stage | Written when |
|---|---|
| `Created` (v1) | Invoice created |
| `DraftEdited` | `PUT /api/invoices/{id}/items` while `Draft` |
| `Printed` | `POST /api/invoices/{id}/print` — the quantities on the paper copy |
| `Finalized` | `POST /api/invoices/{id}/finalize` — after the agent's edits are applied |
- Comparing `Printed` vs `Finalized` shows what the agent changed on paper.
- Migrations `AddInvoiceItems` and `AddInvoiceVersions` backfill lines and one baseline version for
invoices that already existed (stage mapped from the invoice's status at migration time, so they
have no earlier history).
### Invoice Screens & Printing (frontend)
> **Implemented** in `distribution_fe`.
**Invoice document** — one shared component (`components/invoice/invoice-template.tsx`) renders the
invoice for the preview, the draft editor and the print tab:
- **A4**: `INVOICE` header with Invoice No. (`INV-xxxxxxxx`), date and PO ref; a Status row shown
until the invoice is Finalized; *Bill To* (store name, plus address/phone from the PO); item table
(Item · Qty · Unit Price · Amount); totals (Subtotal, Total, and Paid / Balance Due when payments
exist); payments list; notes lines for the agent's edits on non-finalized copies; signature lines.
Soft slate rules, no heavy colour.
- **80mm / 58mm receipts**: single black-and-white column with dashed rules (`*** DRAFT ***` /
`*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!").
- Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`.
- Deleted lines are shown **struck through in every status** and excluded from all totals.
- Not included (no data in the app): company/"From" block, logo, bank details, discount, tax, due date.
**Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a
paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own
URL/date header and footer are not printed; the A4 sheet pads itself with 14mm), and opens the
print dialog automatically. *Print* on a Draft asks who printed it, calls `POST /print`, then opens
this tab (the tab is opened inside the click so popup blockers allow it). Printed/Finalized
invoices have *Reprint*, which opens the tab without changing status.
**Draft editor** — pencil icon (Draft only, also *Edit Items* on the invoice page) opens a full page, `/sales/invoices/{id}/edit` (not a modal), showing the invoice template in edit mode: quantity/price
inputs, a trash icon per line (undo icon restores a deleted line), and an add-item row aligned to
the table columns (item dropdown excluding items already on the invoice, quantity, price
auto-filled from the store's price list, Enter or **+** to add). Save is disabled until something
changes, a summary shows what is pending (the browser warns before leaving with unsaved edits), and an item picked but not yet added is included on save. Non-Draft invoices redirect back to the invoice page.
Each save writes a `DraftEdited` version.
**Configuration** — *Invoice Creation* card: two toggles, **Auto** (Mode A) and **Manual** (Mode B,
default), mutually exclusive (turning one on turns the other off; the active one is locked on), each
with a one-line explanation and a process line (`Confirm PO → … → Edit draft → Print → Finalize`).
### New Enum — `InvoiceStatus`
```csharp
@@ -140,6 +223,7 @@ Draft → Printed → Finalized
|---|---|---|
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
| Draft (edit) | `PUT /api/invoices/{id}/items` | Office user edits the draft's lines (quantity `0..ordered`, price `>= 0`). Status stays `Draft`; `TotalAmount` recomputed; a `DraftEdited` version is saved. |
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
@@ -162,7 +246,10 @@ Draft → Printed → Finalized
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
reflects the current line items.
reflects the current invoice lines (`InvoiceItem`), not the PO lines.
8a. Invoice lines are editable only while `Draft`. At `Finalize`, only quantities can be adjusted
(never above the ordered quantity). Every change writes an `InvoiceVersion` snapshot — see
[Invoice Line Items & Version History](#invoice-line-items--version-history).
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
separate axis from `Invoice.Status` — not merged in this pass.
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
@@ -201,8 +288,9 @@ Draft → Printed → Finalized
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
also unresolved. Both cases need explicit cascade rules before implementation.
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
---
@@ -35,10 +35,28 @@ status transition and stock movement along the way.
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
(No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.)
The frontend calls it when **Configuration → Invoice Creation** is set to **Auto** (Mode A, the
confirm dialog and PO edit page read the mode); with **Manual** (Mode B, the default) it calls the
plain `Confirm` and the invoice is created later from `/sales/orders` → **Create Invoice**.
- Lifecycle: `Draft → Printed → Finalized`.
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
quantity) and recomputes `TotalAmount` from the adjusted lines.
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
created (either mode). The PO's original quantity stays on `ShopPOItem` and is shown as
`orderedQuantity`.
- While `Draft`, lines can be edited (`PUT /invoices/{id}/items`): change quantity (`0..ordered`
on PO lines) and price (`>= 0`), **delete** a line (it is kept, struck through, excluded from
the total, and can be restored), or **add** a catalog item that was not on the PO (no ordered
quantity / max). `TotalAmount` is recomputed from the non-deleted lines.
- Finalizing applies the agent's edits from the printed copy to the lines (quantity capped
between `0` and the ordered quantity, or a line removed) and recomputes `TotalAmount`.
- Every change is snapshotted as an `InvoiceVersion` — see
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-line-items--version-history).
- Frontend, `/sales/orders` row icons: **eye** = invoice preview, **pencil** (Draft) = open the edit page for the
draft invoice (`/sales/invoices/[id]/edit`: trash/undo icon per line, add-item row), **printer** (Draft) =
mark as printed and open the print tab; on Printed/Finalized the printer reprints without
changing status; plus finalize and record payment. `/sales/invoices/[id]` shows the items table
(ordered vs billed, Added/Removed badges), the version history, and the same actions.
- Invoice document and print tab: see
[refactor_docs/order_invoice_lifecycle.md](refactor_docs/order_invoice_lifecycle.md#invoice-screens--printing-frontend).
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
`Pending → PartiallyPaid → Paid`.
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { FieldDescription } from '@/components/ui/field'
import { Switch } from '@/components/ui/switch'
import { toast } from '@/components/ui/toast'
import {
invoiceCreationMode as invoiceCreationModeApi,
@@ -14,15 +13,11 @@ import {
} from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
const MODE_LABELS: Record<InvoiceCreationMode, string> = {
ModeA: 'Auto on confirm',
ModeB: 'Separate step',
}
const MODE_HINTS: Record<InvoiceCreationMode, string> = {
ModeA: 'Confirming a Shop PO immediately creates a draft invoice.',
ModeB: 'Confirmation and invoice creation are two distinct actions.',
}
// What each toggle stands for. Both end the same way: edit the draft, print, finalize.
const POSITIONS: { mode: InvoiceCreationMode; label: string; meaning: string; steps: string[] }[] = [
{ mode: 'ModeA', label: 'Auto', meaning: 'Confirming a PO also creates its draft invoice.', steps: ['Confirm PO', 'Draft created'] },
{ mode: 'ModeB', label: 'Manual', meaning: 'Confirm first, then create the invoice yourself.', steps: ['Confirm PO', 'Create invoice'] },
]
export function InvoiceCreationModeCard() {
const [loading, setLoading] = useState(true)
@@ -66,18 +61,34 @@ export function InvoiceCreationModeCard() {
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
<ToggleGroup
variant="outline"
value={[mode]}
onValueChange={(v: string[]) => v[0] && save(v[0] as InvoiceCreationMode)}
disabled={pending}
className="w-full"
<CardContent className="flex flex-col gap-3">
{POSITIONS.map((p) => {
const active = p.mode === mode
return (
<div
key={p.mode}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
active ? 'border-primary/40 bg-muted' : 'border-border/60'
}`}
>
<ToggleGroupItem value="ModeA" className="flex-1">{MODE_LABELS.ModeA}</ToggleGroupItem>
<ToggleGroupItem value="ModeB" className="flex-1">{MODE_LABELS.ModeB}</ToggleGroupItem>
</ToggleGroup>
<FieldDescription className="text-xs leading-relaxed">{MODE_HINTS[mode]}</FieldDescription>
<label htmlFor={`invoice-creation-${p.mode}`} className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{p.label}</span>
<span className="text-xs leading-relaxed text-muted-foreground">{p.meaning}</span>
</label>
{/* One mode is always on: turning one on turns the other off; the active one cannot be switched off. */}
<Switch
id={`invoice-creation-${p.mode}`}
checked={active}
disabled={pending || active}
onCheckedChange={(v) => v && save(p.mode)}
/>
</div>
)
})}
<p className="text-xs text-muted-foreground">
{[...(POSITIONS.find((p) => p.mode === mode)?.steps ?? []), 'Edit draft', 'Print', 'Finalize'].join(' → ')}
</p>
</CardContent>
</Card>
)
@@ -0,0 +1,342 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, ArrowLeft01Icon, Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
import { toast } from '@/components/ui/toast'
import {
invoices as invoicesApi,
users as usersApi,
items as itemsApi,
priceLists as priceListsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api'
interface AddedLine {
key: string
itemId: string
name: string
qty: string
price: string
}
const NEW_PREFIX = 'new:'
const isNew = (item: InvoiceItemResponse) => item.id.startsWith(NEW_PREFIX)
const addedKey = (item: InvoiceItemResponse) => item.id.slice(NEW_PREFIX.length)
// Full-page editor for a Draft invoice, on the invoice template: change quantity/price, delete (strike)
// lines and add items that were not on the PO. Each save is recorded as a new invoice version.
export default function InvoiceEditPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const invoiceId = params.id
const backToInvoice = () => router.push(`/sales/invoices/${invoiceId}`)
const [full, setFull] = useState<InvoiceResponse | null>(null)
const [loading, setLoading] = useState(true)
const [qty, setQty] = useState<Record<string, string>>({})
const [price, setPrice] = useState<Record<string, string>>({})
const [removed, setRemoved] = useState<Record<string, boolean>>({})
const [added, setAdded] = useState<AddedLine[]>([])
const [catalogue, setCatalogue] = useState<ItemSummaryResponse[]>([])
const [pickItemId, setPickItemId] = useState('')
const [pickQty, setPickQty] = useState('1')
const [pickPrice, setPickPrice] = useState('')
const [userId, setUserId] = useState('')
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
invoicesApi.get(invoiceId)
.then((inv) => {
if (inv.status !== 'Draft') {
toast.add({ title: 'Only Draft invoices can be edited', type: 'error' })
router.replace(`/sales/invoices/${invoiceId}`)
return
}
setFull(inv)
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
})
.catch((err) => {
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
router.push('/sales/orders')
})
.finally(() => setLoading(false))
usersApi.list()
.then(setUsersList)
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
itemsApi.list({ pageSize: 200 })
.then((res) => setCatalogue(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }))
}, [invoiceId, router])
// The template renders this copy so line amounts and the total follow what is typed.
const live = useMemo<InvoiceResponse | null>(() => {
if (!full) return null
const existing = full.items.map((i) => ({
...i,
quantity: Number(qty[i.id]) || 0,
unitPrice: Number(price[i.id]) || 0,
isRemoved: !!removed[i.id],
}))
const extra: InvoiceItemResponse[] = added.map((a) => ({
id: NEW_PREFIX + a.key,
shopPOItemId: null,
itemId: a.itemId,
itemName: a.name,
orderedQuantity: null,
quantity: Number(a.qty) || 0,
unitPrice: Number(a.price) || 0,
isRemoved: false,
}))
const items = [...existing, ...extra]
const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
return { ...full, items, totalAmount }
}, [full, qty, price, removed, added])
// Items not already on the invoice (a deleted line can be restored with Undo instead).
const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id))
// Picking an item fills in its price: the store's price list, falling back to the item's default price.
async function pickItem(itemId: string) {
setPickItemId(itemId)
const item = catalogue.find((c) => c.id === itemId)
if (!item || !full) return
setPickPrice(String(item.salePrice))
try {
const res = await priceListsApi.resolve({
itemId,
storeId: full.storeId,
saleDate: new Date().toISOString().slice(0, 10),
})
setPickPrice(String(res.price))
} catch {
// No price list entry for this store — keep the default price.
}
}
// The line currently typed into the add row, or null when nothing valid is picked.
function pendingLine(): AddedLine | null {
const item = catalogue.find((c) => c.id === pickItemId)
if (!item || !(Number(pickQty) > 0) || pickPrice === '' || Number(pickPrice) < 0) return null
return { key: crypto.randomUUID(), itemId: item.id, name: item.name, qty: pickQty, price: pickPrice }
}
function addItem() {
const line = pendingLine()
if (!line) {
toast.add({ title: 'Pick an item and enter a quantity above zero', type: 'error' })
return
}
setAdded((a) => [...a, line])
setPickItemId('')
setPickQty('1')
setPickPrice('')
}
const changeSummary = useMemo(() => {
if (!full) return []
const edited = full.items.filter(
(i) => !removed[i.id] && (Number(qty[i.id]) !== i.quantity || Number(price[i.id]) !== i.unitPrice),
).length
const struck = full.items.filter((i) => !!removed[i.id] !== i.isRemoved).length
return [
added.length && `${added.length} added`,
struck && `${struck} removed/restored`,
edited && `${edited} edited`,
].filter(Boolean) as string[]
}, [full, qty, price, removed, added])
const dirty = changeSummary.length > 0
// Warn before the tab is closed/reloaded with unsaved edits.
useEffect(() => {
if (!dirty) return
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
async function save() {
if (!full) return
if (!userId) {
toast.add({ title: 'Select who is editing', type: 'error' })
return
}
// An item picked in the add row but not yet added is included, so it is not silently lost.
const pending = pendingLine()
const newLines = pending ? [...added, pending] : added
setSaving(true)
try {
await invoicesApi.updateItems(
full.id,
[
...full.items.map((i) => ({
invoiceItemId: i.id,
quantity: Number(qty[i.id]),
unitPrice: Number(price[i.id]),
isRemoved: !!removed[i.id],
})),
...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
],
userId,
)
toast.add({ title: 'Invoice updated', type: 'success' })
// Navigating away right after a successful save; clear the unsaved-changes guard first.
setAdded([])
setQty(Object.fromEntries(full.items.map((i) => [i.id, String(i.quantity)])))
setPrice(Object.fromEntries(full.items.map((i) => [i.id, String(i.unitPrice)])))
setRemoved(Object.fromEntries(full.items.map((i) => [i.id, i.isRemoved])))
backToInvoice()
} catch (err) {
toast.add({ title: 'Failed to update invoice', description: getApiError(err), type: 'error' })
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={backToInvoice}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">Edit Draft Invoice</h1>
{full && <Badge variant="secondary">{full.status}</Badge>}
</div>
<p className="text-sm text-muted-foreground">
Change quantities and prices, delete lines, or add items. Quantity on a PO line cannot exceed the
ordered quantity. Every save creates a new version.
</p>
</div>
</div>
</div>
{loading || !live ? (
<p className="text-sm text-muted-foreground">Loading invoice…</p>
) : (
<>
<InvoiceTemplate
invoice={live}
renderQuantity={(item) => (
<Input
type="number" min={0} max={item.orderedQuantity ?? undefined}
className="ml-auto h-8 w-20 text-right"
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.qty ?? '' : qty[item.id] ?? ''}
onChange={(e) => isNew(item)
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, qty: e.target.value } : l))
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
/>
)}
renderUnitPrice={(item) => (
<Input
type="number" min={0} step="0.01"
className="ml-auto h-8 w-28 text-right"
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.price ?? '' : price[item.id] ?? ''}
onChange={(e) => isNew(item)
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, price: e.target.value } : l))
: setPrice((p) => ({ ...p, [item.id]: e.target.value }))}
/>
)}
renderActions={(item) => (
<Button
type="button" variant="ghost" size="icon-sm"
className={item.isRemoved ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
aria-label={item.isRemoved ? 'Restore line' : 'Delete line'}
title={item.isRemoved ? 'Restore line' : 'Delete line'}
onClick={() => isNew(item)
? setAdded((a) => a.filter((l) => l.key !== addedKey(item)))
: setRemoved((r) => ({ ...r, [item.id]: !r[item.id] }))}
>
<HugeiconsIcon icon={item.isRemoved ? Undo02Icon : Delete02Icon} size={16} />
</Button>
)}
footerRow={
// Cells line up with the table columns: Item · Qty · Unit Price · Amount · action.
<tr className="bg-neutral-50 print:hidden" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}>
<td className="px-2 py-3">
<Select value={pickItemId} onValueChange={(v) => pickItem(v ?? '')}>
<SelectTrigger className="h-8 w-full"><SelectValue placeholder="Add an item…" /></SelectTrigger>
<SelectContent>
{availableItems.length === 0
? <div className="px-3 py-2 text-sm text-muted-foreground">No more items to add</div>
: availableItems.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} <span className="text-muted-foreground">· {c.sku}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-3">
<Input
type="number" min={1} aria-label="Quantity" className="ml-auto h-8 w-20 text-right"
value={pickQty} onChange={(e) => setPickQty(e.target.value)}
/>
</td>
<td className="px-2 py-3">
<Input
type="number" min={0} step="0.01" aria-label="Unit price" placeholder="Price"
className="ml-auto h-8 w-28 text-right"
value={pickPrice} onChange={(e) => setPickPrice(e.target.value)}
/>
</td>
<td className="px-2 py-3 text-right text-sm text-neutral-500">
{pickItemId ? ((Number(pickQty) || 0) * (Number(pickPrice) || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 }) : ''}
</td>
<td className="px-2 py-3 text-right">
<Button
type="button" size="icon-sm" aria-label="Add item" title="Add item (Enter)"
onClick={addItem} disabled={!pickItemId}
>
<HugeiconsIcon icon={Add01Icon} size={16} />
</Button>
</td>
</tr>
}
/>
<div className="mx-auto flex w-full max-w-[210mm] flex-wrap items-center justify-between gap-3">
<div className="w-64">
<Select value={userId} onValueChange={(v) => setUserId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="Edited by (temporary)..." /></SelectTrigger>
<SelectContent>
{usersList.map((u) => <SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>)}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{dirty ? `Unsaved: ${changeSummary.join(', ')}` : 'No changes yet'}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button>
<Button type="button" onClick={save} disabled={saving || (!dirty && !pendingLine())}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
</>
)}
</div>
)
}
@@ -8,12 +8,19 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
import type { InvoiceResponse, InvoiceStatus, InvoiceVersionResponse, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
import { PaymentSheet } from '../../payments/_components/payment-sheet'
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
const STAGE_LABEL: Record<InvoiceVersionResponse['stage'], string> = {
Created: 'Created',
DraftEdited: 'Draft edited',
Printed: 'Printed',
Finalized: 'Finalized',
}
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
Draft: 'secondary',
Printed: 'outline',
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
const [actionOpen, setActionOpen] = useState(false)
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
const fetchInvoice = useCallback(async () => {
const inv = await invoicesApi.get(params.id)
const [inv, vers] = await Promise.all([invoicesApi.get(params.id), invoicesApi.versions(params.id)])
setInvoice(inv)
setVersions(vers)
return inv
}, [params.id])
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
</div>
<div className="flex items-center gap-2">
{invoice.status === 'Draft' && (
<Button type="button" variant="outline" onClick={() => router.push(`/sales/invoices/${invoice.id}/edit`)}>
Edit Items
</Button>
)}
{invoice.status === 'Draft' && (
<Button type="button" onClick={handlePrint}>
Print Invoice
</Button>
)}
{invoice.status !== 'Draft' && (
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
Reprint
</Button>
)}
{invoice.status === 'Printed' && (
<Button type="button" onClick={handleFinalize}>
Finalize Invoice
@@ -167,6 +186,84 @@ export default function InvoiceViewPage() {
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Items</h3>
<p className="text-sm text-muted-foreground">Ordered quantity is the original PO; quantity is what is billed now.</p>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-24 px-3 py-2 text-right font-medium">Billed</th>
<th className="w-28 px-3 py-2 text-right font-medium">Unit price</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
</tr>
</thead>
<tbody>
{invoice.items.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
</tr>
) : (
invoice.items.map((i) => (
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
<td className="px-3 py-2">
{i.itemName}
{i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
</td>
<td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td>
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.quantity}
</td>
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Version history</h3>
<p className="text-sm text-muted-foreground">
Quantities after each change, so you can compare the original, printed and finalized copies.
</p>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="w-16 px-3 py-2 text-left font-medium">Version</th>
<th className="w-32 px-3 py-2 text-left font-medium">Stage</th>
<th className="w-40 px-3 py-2 text-left font-medium">When</th>
<th className="px-3 py-2 text-left font-medium">Quantities</th>
<th className="w-32 px-3 py-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{versions.map((v) => (
<tr key={v.id} className="border-t align-top">
<td className="px-3 py-2 font-mono">v{v.versionNumber}</td>
<td className="px-3 py-2"><Badge variant="outline">{STAGE_LABEL[v.stage]}</Badge></td>
<td className="px-3 py-2">{fmtDateTime(v.createdAt)}</td>
<td className="px-3 py-2">
{v.items.map((i) => (i.isRemoved ? `${i.itemName} (removed)` : `${i.itemName} × ${i.quantity}`)).join(', ') || '—'}
</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(v.totalAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Payments</h3>
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { Input } from '@/components/ui/input'
import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { InvoiceResponse, UserResponse } from '@/types/api'
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
}
const COPY = {
print: { title: 'Mark Invoice as Printed', label: 'Printed By', action: 'Mark as Printed', success: 'Invoice marked as printed' },
print: { title: 'Print Invoice', label: 'Printed By', action: 'Print Invoice', success: 'Invoice printed' },
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
}
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const copy = COPY[mode]
const [qty, setQty] = useState<Record<string, string>>({})
const [removed, setRemoved] = useState<Record<string, boolean>>({})
// Rows from the list endpoint carry no line items, so the full invoice is loaded for the finalize table.
const [full, setFull] = useState<InvoiceResponse | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
useEffect(() => {
if (!open) return
form.reset({ userId: '' })
setFull(null)
setQty({})
setRemoved({})
if (invoice) {
invoicesApi.get(invoice.id)
.then((inv) => {
setFull(inv)
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
})
.catch((err) => toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' }))
}
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
@@ -62,21 +79,35 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
if (!invoice) return null
const lines = full?.items ?? []
async function onSubmit(values: FormValues) {
// Opened synchronously, while the click still counts as a user gesture, so the browser allows the new tab.
const printTab = mode === 'print' ? window.open('', '_blank') : null
try {
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
else await invoicesApi.finalize(invoice!.id, values.userId)
if (mode === 'print') {
await invoicesApi.print(invoice!.id, values.userId)
if (printTab) printTab.location.href = `/invoices/${invoice!.id}/print`
} else {
if (!full) throw new Error('Invoice is still loading')
// Only lines the agent changed on the printed copy are sent as adjustments.
const adjustments = lines
.filter((i) => Number(qty[i.id]) !== i.quantity || !!removed[i.id] !== i.isRemoved)
.map((i) => ({ invoiceItemId: i.id, adjustedQuantity: Number(qty[i.id]), isRemoved: !!removed[i.id] }))
await invoicesApi.finalize(invoice!.id, values.userId, adjustments)
}
toast.add({ title: copy.success, type: 'success' })
onSuccess()
onClose()
} catch (err) {
printTab?.close()
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
/>
</FieldGroup>
{mode === 'finalize' && lines.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Edits from the printed copy</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-20 px-3 py-2 text-right font-medium">Printed</th>
<th className="w-24 px-3 py-2 text-right font-medium">Final qty</th>
<th className="w-24 px-3 py-2" />
</tr>
</thead>
<tbody>
{lines.map((i) => (
<tr key={i.id} className={`border-t ${removed[i.id] ? "text-muted-foreground line-through" : ""}`}>
<td className="px-3 py-2">{i.itemName}</td>
<td className="px-3 py-2 text-right">{i.quantity}</td>
<td className="px-3 py-2">
<Input
type="number" min={0} max={i.orderedQuantity ?? undefined} disabled={!!removed[i.id]}
className="h-8 text-right"
value={qty[i.id] ?? ''}
onChange={(e) => setQty((q) => ({ ...q, [i.id]: e.target.value }))}
/>
</td>
<td className="px-3 py-2 text-right no-underline">
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}>
{removed[i.id] ? 'Undo' : 'Remove'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
@@ -1,35 +1,15 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary',
Printed: 'secondary',
Finalized: 'default',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyPaid: 'secondary',
Paid: 'default',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
import type { InvoiceResponse } from '@/types/api'
interface OrderViewSheetProps {
open: boolean
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
invoice: InvoiceResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
const doc = useInvoiceDocument(invoice?.id, open)
if (!invoice) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Invoice Details</DialogTitle>
<DialogTitle>Invoice Preview</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
<Row label="Store" value={invoice.storeName} />
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
{doc.invoice ? (
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
</p>
)}
{doc.invoice && doc.invoice.status !== 'Draft' && (
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
Open print view
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
@@ -35,6 +36,7 @@ const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'd
}
export function OrdersClient() {
const router = useRouter()
const [data, setData] = useState<InvoiceResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -76,6 +78,10 @@ export function OrdersClient() {
setViewOpen(true)
}
function handleEdit(row: InvoiceResponse) {
router.push(`/sales/invoices/${row.id}/edit`)
}
function handlePrint(row: InvoiceResponse) {
setSelectedInvoice(row)
setActionMode('print')
@@ -156,12 +162,24 @@ export function OrdersClient() {
label: 'View invoice',
onClick: handleView,
},
{
action: 'edit',
label: 'Edit draft items',
onClick: handleEdit,
visible: (target) => target.status === 'Draft',
},
{
action: 'print',
label: 'Mark as printed',
label: 'Print invoice',
onClick: handlePrint,
visible: (target) => target.status === 'Draft',
},
{
action: 'print',
label: 'Reprint invoice',
onClick: (target) => window.open(`/invoices/${target.id}/print`, '_blank'),
visible: (target) => target.status !== 'Draft',
},
{
action: 'finalize',
label: 'Finalize invoice',
@@ -0,0 +1,68 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InvoiceTemplate, type InvoicePaper } from '@/components/invoice/invoice-template'
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
// Browser page rule per paper size. Receipts are one continuous strip (auto height). Margin is 0 so the
// browser has no margin area to print its own header/footer (page title, URL, date) into; the sheet pads itself.
const PAGE_RULE: Record<InvoicePaper, string> = {
A4: '@page { size: A4; margin: 0; }',
'80mm': '@page { size: 80mm auto; margin: 0; }',
'58mm': '@page { size: 58mm auto; margin: 0; }',
}
// Standalone printable invoice (no dashboard chrome). Opened in a new tab after "Print".
export default function InvoicePrintPage() {
const params = useParams<{ id: string }>()
const { invoice, payments, customer, error } = useInvoiceDocument(params.id)
const [paper, setPaper] = useState<InvoicePaper>('A4')
// Open the browser print dialog once, when the invoice first appears.
useEffect(() => {
if (!invoice) return
const t = setTimeout(() => window.print(), 600)
return () => clearTimeout(t)
}, [invoice])
if (error) return <p className="p-8 text-sm text-destructive">Failed to load invoice: {error}</p>
if (!invoice) return <p className="p-8 text-sm text-muted-foreground">Loading invoice…</p>
return (
<div className="mx-auto max-w-4xl p-6 print:max-w-none print:p-0">
<style>{`${PAGE_RULE[paper]} @media print { html, body { background: #fff !important; } }`}</style>
<div className="mb-4 flex items-center justify-end gap-2 print:hidden">
<div className="w-32">
<Select value={paper} onValueChange={(v) => setPaper((v as InvoicePaper) ?? 'A4')}>
<SelectTrigger><SelectValue placeholder="Paper size" /></SelectTrigger>
<SelectContent>
<SelectItem value="A4">A4</SelectItem>
<SelectItem value="80mm">80mm receipt</SelectItem>
<SelectItem value="58mm">58mm receipt</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="button" variant="outline" onClick={() => window.close()}>Close</Button>
<Button type="button" onClick={() => window.print()}>Print</Button>
</div>
<InvoiceTemplate
invoice={invoice}
payments={payments}
customer={customer}
paper={paper}
showAgentEdits={invoice.status !== 'Finalized'}
/>
</div>
)
}
@@ -0,0 +1,19 @@
// Shared number/date formatting for printed invoices.
/** `Rs. 12,450.00` — thousands separator, exactly 2 decimals. */
export const fmtMoney = (n: number) =>
`Rs. ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
/** Quantity: up to 3 decimals, trailing zeros dropped. */
export const fmtQty = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 3 })
/** `dd/MM/yyyy` */
export function fmtDate(v: string | null | undefined) {
if (!v) return '—'
const d = new Date(v)
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}/${mm}/${d.getFullYear()}`
}
export const invoiceNo = (id: string) => `INV-${id.substring(0, 8).toUpperCase()}`
@@ -0,0 +1,304 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import type { InvoiceItemResponse, InvoiceResponse, PaymentResponse } from '@/types/api'
import { fmtDate, fmtMoney, fmtQty, invoiceNo } from './invoice-format'
export type InvoicePaper = 'A4' | '80mm' | '58mm'
export interface InvoiceCustomer {
address?: string | null
phone?: string | null
}
interface InvoiceTemplateProps {
invoice: InvoiceResponse
payments?: PaymentResponse[]
customer?: InvoiceCustomer
paper?: InvoicePaper
// Draft-editor hooks (A4 only): inputs in the qty / price cells, a per-row action, and an extra table row.
renderQuantity?: (item: InvoiceItemResponse) => ReactNode
renderUnitPrice?: (item: InvoiceItemResponse) => ReactNode
renderActions?: (item: InvoiceItemResponse) => ReactNode
footerRow?: ReactNode
// Blank lines for the agent's handwritten edits on the paper copy.
showAgentEdits?: boolean
className?: string
}
// Soft slate palette: light rules, muted section headings.
const RULE = '#cbd5e1'
const HEADING = '#64748b'
const statusLabel = (invoice: InvoiceResponse) =>
invoice.status === 'Finalized' ? null : invoice.status.toUpperCase()
// Removed lines stay on the document (struck through) but are excluded from every total.
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
const total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
const paid = payments.reduce((s, p) => s + p.amount, 0)
return { total, paid, balance: total - paid }
}
export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
return paper === 'A4' ? <A4Sheet {...props} /> : <ReceiptSheet {...props} paper={paper} />
}
// ─── A4 ───────────────────────────────────────────────────────────────────────
function A4Sheet({
invoice,
payments = [],
customer,
renderQuantity,
renderUnitPrice,
renderActions,
footerRow,
showAgentEdits,
className,
}: Omit<InvoiceTemplateProps, 'paper'>) {
const { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice)
const hasRemoved = invoice.items.some((i) => i.isRemoved)
return (
<div
className={cn(
'invoice-sheet relative mx-auto w-full max-w-[210mm] rounded-lg border bg-white p-10 text-sm leading-relaxed text-neutral-900 shadow-sm',
'print:max-w-none print:rounded-none print:border-0 print:p-[14mm] print:shadow-none',
className,
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-6 border-b pb-5" style={{ borderColor: RULE }}>
<h2 className="text-4xl font-bold tracking-wide">INVOICE</h2>
<dl className="grid grid-cols-[auto_auto] gap-x-6 gap-y-1 text-right text-sm">
<dt className="text-neutral-500">Invoice No.</dt>
<dd className="font-mono font-semibold">{invoiceNo(invoice.id)}</dd>
<dt className="text-neutral-500">Invoice Date</dt>
<dd>{fmtDate(invoice.createdAt)}</dd>
<dt className="text-neutral-500">PO Ref.</dt>
<dd className="font-mono">{invoice.shopPOId.substring(0, 8).toUpperCase()}</dd>
{status && (
<>
<dt className="text-neutral-500">Status</dt>
<dd className="font-semibold" style={{ color: HEADING }}>{status}</dd>
</>
)}
</dl>
</div>
{/* Bill to */}
<div className="mt-6">
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Bill To
</h3>
<p className="text-base font-semibold">{invoice.storeName}</p>
{customer?.address && <p className="text-neutral-600">{customer.address}</p>}
{customer?.phone && <p className="text-neutral-600">{customer.phone}</p>}
</div>
{/* Items */}
<div className="mt-6">
<h3 className="mb-2 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Item Details
</h3>
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
<th className="px-2 py-2 text-left font-medium">Item</th>
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
</tr>
</thead>
<tbody>
{invoice.items.length === 0 && !footerRow ? (
<tr>
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
</tr>
) : (
invoice.items.map((item) => (
<tr
key={item.id}
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
>
<td className="px-2 py-2.5">{item.itemName}</td>
<td className="px-2 py-2.5 text-right">
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
</td>
<td className="px-2 py-2.5 text-right">
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
</td>
<td className="px-2 py-2.5 text-right font-medium">{fmtMoney(item.quantity * item.unitPrice)}</td>
{renderActions && <td className="px-2 py-2.5 text-right no-underline print:hidden">{renderActions(item)}</td>}
</tr>
))
)}
{footerRow}
</tbody>
</table>
{hasRemoved && (
<p className="mt-2 text-xs text-neutral-500">Struck-through lines were removed and are not charged.</p>
)}
</div>
{/* Notes + totals */}
<div className="mt-6 grid grid-cols-[1fr_auto] gap-10">
<div>
{showAgentEdits && (
<>
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Notes / Agent Edits
</h3>
{[0, 1, 2, 3].map((n) => (
<div key={n} className="h-7 border-b border-dotted border-neutral-400" />
))}
</>
)}
</div>
<dl className="w-72 break-inside-avoid text-sm">
<div className="flex justify-between border-b py-1.5">
<dt className="text-neutral-500">Subtotal</dt>
<dd>{fmtMoney(total)}</dd>
</div>
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
<dt>Total</dt>
<dd>{fmtMoney(total)}</dd>
</div>
{paid > 0 && (
<>
<div className="flex justify-between py-1.5">
<dt className="text-neutral-500">Paid</dt>
<dd>{fmtMoney(paid)}</dd>
</div>
<div className="flex justify-between border-t py-1.5 font-semibold">
<dt>Balance Due</dt>
<dd>{fmtMoney(balance)}</dd>
</div>
</>
)}
</dl>
</div>
{/* Payments */}
<div className="mt-6 break-inside-avoid">
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Payments
</h3>
{payments.length === 0 ? (
<p className="text-neutral-500">No payment recorded yet.</p>
) : (
<table className="w-full text-sm">
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b border-neutral-100">
<td className="w-28 py-1">{fmtDate(p.collectedAt)}</td>
<td className="py-1">
{p.method}
{p.chequeNumber && <span className="text-neutral-500"> · Cheque {p.chequeNumber}</span>}
</td>
<td className="py-1 text-right font-medium">{fmtMoney(p.amount)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Signatures */}
<div className="mt-14 grid grid-cols-2 gap-16 text-xs text-neutral-600">
<div className="border-t border-neutral-400 pt-1">Authorised by</div>
<div className="border-t border-neutral-400 pt-1">Received by (customer signature)</div>
</div>
</div>
)
}
// ─── 80mm / 58mm thermal receipt ──────────────────────────────────────────────
function ReceiptSheet({
invoice,
payments = [],
customer,
paper,
showAgentEdits,
className,
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
const { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice)
const narrow = paper === '58mm'
const rule = <div className="my-2 border-t border-dashed border-black" />
const num = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return (
<div
className={cn(
'invoice-sheet mx-auto bg-white font-mono text-black shadow-sm print:shadow-none',
narrow ? 'w-[58mm] px-[4mm] py-[2.5mm] text-[9.5px]' : 'w-[80mm] px-[5mm] py-[4mm] text-[11px]',
className,
)}
>
<div className="text-center">
<p className="text-base font-bold tracking-widest">INVOICE</p>
{status && <p className="font-bold">*** {status} ***</p>}
</div>
{rule}
<div className="space-y-0.5">
<p>No: {invoiceNo(invoice.id)}</p>
<p>Date: {fmtDate(invoice.createdAt)}</p>
<p>Customer: {invoice.storeName}</p>
{customer?.phone && <p>Phone: {customer.phone}</p>}
</div>
{rule}
<div className="space-y-1.5">
{invoice.items.map((i) => (
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
<p>{i.itemName}</p>
<p className="flex justify-between">
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
<span>{num(i.quantity * i.unitPrice)}</span>
</p>
</div>
))}
</div>
{rule}
<p className="flex justify-between">
<span>Subtotal</span>
<span>{fmtMoney(total)}</span>
</p>
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
<p className="flex justify-between">
<span>TOTAL</span>
<span>{fmtMoney(total)}</span>
</p>
</div>
{paid > 0 && (
<>
<p className="flex justify-between"><span>Paid</span><span>{fmtMoney(paid)}</span></p>
<p className="flex justify-between font-bold"><span>Balance</span><span>{fmtMoney(balance)}</span></p>
</>
)}
{payments.length > 0 && (
<>
{rule}
{payments.map((p) => (
<p key={p.id} className="flex justify-between">
<span>{fmtDate(p.collectedAt)} {p.method}</span>
<span>{num(p.amount)}</span>
</p>
))}
</>
)}
{showAgentEdits && (
<>
{rule}
<p>Notes:</p>
{[0, 1, 2].map((n) => <div key={n} className="h-5 border-b border-dotted border-black" />)}
</>
)}
{rule}
<p className="text-center">Thank you!</p>
</div>
)
}
@@ -0,0 +1,41 @@
'use client'
import { useEffect, useState } from 'react'
import { invoices as invoicesApi, payments as paymentsApi, shopPos as shopPosApi, getApiError } from '@/lib/api/index'
import type { PaymentResponse, InvoiceResponse } from '@/types/api'
import type { InvoiceCustomer } from './invoice-template'
// Loads everything the printed invoice needs. Only the invoice is required — if payments or the
// customer details fail to load, that block is simply left out.
export function useInvoiceDocument(invoiceId: string | null | undefined, enabled = true) {
const [invoice, setInvoice] = useState<InvoiceResponse | null>(null)
const [payments, setPayments] = useState<PaymentResponse[]>([])
const [customer, setCustomer] = useState<InvoiceCustomer | undefined>()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !invoiceId) return
let cancelled = false
setInvoice(null)
setPayments([])
setCustomer(undefined)
setError(null)
invoicesApi.get(invoiceId)
.then((inv) => {
if (cancelled) return
setInvoice(inv)
paymentsApi.listByInvoice(inv.id).then((p) => !cancelled && setPayments(p)).catch(() => {})
shopPosApi.get(inv.shopPOId)
.then((po) => !cancelled && setCustomer({ address: po.storeAddress, phone: po.storeContactNumber }))
.catch(() => {})
})
.catch((err) => !cancelled && setError(getApiError(err)))
return () => {
cancelled = true
}
}, [invoiceId, enabled])
return { invoice, payments, customer, error }
}
+9 -2
View File
@@ -4,6 +4,7 @@ import type {
ShopPOResponse,
InvoiceResponse,
InvoiceStatus,
InvoiceVersionResponse,
PaymentStatus,
PaymentResponse,
PaymentMethod,
@@ -93,8 +94,14 @@ export const invoices = {
print: (id: string, printedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
finalize: (id: string, finalizedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId }).then(r => r.data),
finalize: (id: string, finalizedByUserId?: string | null, adjustments?: { invoiceItemId: string; adjustedQuantity: number; isRemoved?: boolean }[]) =>
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId, adjustments }).then(r => r.data),
updateItems: (id: string, items: { invoiceItemId?: string; itemId?: string; quantity: number; unitPrice?: number | null; isRemoved?: boolean }[], editedByUserId?: string | null) =>
api.put<InvoiceResponse>(`/invoices/${id}/items`, { items, editedByUserId }).then(r => r.data),
versions: (id: string) =>
api.get<InvoiceVersionResponse[]>(`/invoices/${id}/versions`).then(r => r.data),
}
// ─── Payments ─────────────────────────────────────────────────────────────────
+32
View File
@@ -12,6 +12,7 @@ export type PurchaseOrderStatus =
| 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled'
export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled'
export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
export type InvoiceVersionStage = 'Created' | 'DraftEdited' | 'Printed' | 'Finalized'
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
export type PaymentMethod = 'Cash' | 'Cheque' | 'Credit'
@@ -1001,6 +1002,36 @@ export interface ShopPOResponse {
cancelledByRole: string | null
}
export interface InvoiceItemResponse {
id: string
shopPOItemId: string | null
itemId: string
itemName: string
orderedQuantity: number | null
quantity: number
unitPrice: number
isRemoved: boolean
}
export interface InvoiceVersionItemResponse {
shopPOItemId: string | null
itemId: string
itemName: string
quantity: number
unitPrice: number
isRemoved: boolean
}
export interface InvoiceVersionResponse {
id: string
versionNumber: number
stage: InvoiceVersionStage
totalAmount: number
createdBy: string
createdAt: string
items: InvoiceVersionItemResponse[]
}
export interface InvoiceResponse {
id: string
shopPOId: string
@@ -1017,6 +1048,7 @@ export interface InvoiceResponse {
paidAmount: number
isActive: boolean
createdAt: string
items: InvoiceItemResponse[]
}
export interface OutstandingPaymentsResponse {