Invoice flow #82
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
|
||||
foreach (var adjustment in adjustments)
|
||||
{
|
||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||
decimal total = 0;
|
||||
foreach (var item in poItems)
|
||||
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||
|
||||
item.IsRemoved = adjustment.IsRemoved;
|
||||
if (!adjustment.IsRemoved)
|
||||
{
|
||||
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}");
|
||||
|
||||
total += billedQuantity * item.UnitPrice;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6080
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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
+21
-3
@@ -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`.
|
||||
|
||||
+34
-23
@@ -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"
|
||||
>
|
||||
<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>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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)} />
|
||||
</div>
|
||||
{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 }
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user