Merge pull request 'Invoice flow' (#82) from feat-notification into dev
Reviewed-on: #82
This commit was merged in pull request #82.
This commit is contained in:
@@ -22,6 +22,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<Invoice> Invoices { 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<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { 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)
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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>()
|
modelBuilder.Entity<Invoice>()
|
||||||
.HasMany(i => i.Payments)
|
.HasMany(i => i.Payments)
|
||||||
.WithOne(p => p.Invoice)
|
.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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||||
Task AddAsync(Models.Invoice invoice);
|
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);
|
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 }); }
|
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")]
|
[HttpPost("{id:guid}/print")]
|
||||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,35 @@
|
|||||||
namespace Distribution.Features.Invoice;
|
namespace Distribution.Features.Invoice;
|
||||||
|
|
||||||
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
|
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 FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = 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(
|
public record InvoiceResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ShopPOId,
|
Guid ShopPOId,
|
||||||
@@ -22,6 +47,7 @@ public record InvoiceResponse(
|
|||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
decimal PaidAmount,
|
decimal PaidAmount,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
List<InvoiceItemResponse> Items);
|
||||||
|
|
||||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
|||||||
await context.Invoices
|
await context.Invoices
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
.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))
|
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||||
.FirstOrDefaultAsync(i => i.Id == id && i.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) =>
|
public async Task AddAsync(Models.Invoice invoice) =>
|
||||||
await context.Invoices.AddAsync(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) =>
|
public void Update(Models.Invoice invoice) =>
|
||||||
context.Invoices.Update(invoice);
|
context.Invoices.Update(invoice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,61 @@ public class InvoiceService(
|
|||||||
i.FinalizedAt, i.FinalizedBy,
|
i.FinalizedAt, i.FinalizedBy,
|
||||||
i.TotalAmount,
|
i.TotalAmount,
|
||||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
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)
|
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
@@ -69,6 +123,9 @@ public class InvoiceService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.AddAsync(invoice);
|
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;
|
po.InvoiceId = invoice.Id;
|
||||||
shopPoRepo.Update(po);
|
shopPoRepo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
@@ -79,6 +136,85 @@ public class InvoiceService(
|
|||||||
return ToResponse(saved!);
|
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)
|
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoice = await repo.GetByIdAsync(id)
|
var invoice = await repo.GetByIdAsync(id)
|
||||||
@@ -92,7 +228,12 @@ public class InvoiceService(
|
|||||||
invoice.PrintedBy = printedBy;
|
invoice.PrintedBy = printedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
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; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
@@ -106,31 +247,37 @@ public class InvoiceService(
|
|||||||
if (invoice.Status != InvoiceStatus.Printed)
|
if (invoice.Status != InvoiceStatus.Printed)
|
||||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||||
|
|
||||||
|
var items = invoice.Items.Where(i => i.IsActive).ToList();
|
||||||
var adjustments = request.Adjustments ?? [];
|
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();
|
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
|
||||||
decimal total = 0;
|
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
|
||||||
foreach (var item in poItems)
|
|
||||||
|
item.IsRemoved = adjustment.IsRemoved;
|
||||||
|
if (!adjustment.IsRemoved)
|
||||||
{
|
{
|
||||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
ValidateQuantity(item, adjustment.AdjustedQuantity);
|
||||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
item.Quantity = adjustment.AdjustedQuantity;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
invoice.TotalAmount = total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
invoice.TotalAmount = BillableTotal(items);
|
||||||
|
|
||||||
invoice.Status = InvoiceStatus.Finalized;
|
invoice.Status = InvoiceStatus.Finalized;
|
||||||
invoice.FinalizedAt = DateTime.Now;
|
invoice.FinalizedAt = DateTime.Now;
|
||||||
invoice.FinalizedBy = finalizedBy;
|
invoice.FinalizedBy = finalizedBy;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
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; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToResponse(invoice);
|
return ToResponse(invoice);
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ public class ShopPOService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await invoiceRepo.AddAsync(invoice);
|
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;
|
po.InvoiceId = invoice.Id;
|
||||||
repo.Update(po);
|
repo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
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 ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
public enum InvoiceStatus { Draft, Printed, Finalized }
|
public enum InvoiceStatus { Draft, Printed, Finalized }
|
||||||
|
public enum InvoiceVersionStage { Created, DraftEdited, Printed, Finalized }
|
||||||
|
|
||||||
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace Distribution.Models
|
|||||||
|
|
||||||
public decimal TotalAmount { get; set; }
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||||
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
|
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
|
├── 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`
|
### New Enum — `InvoiceStatus`
|
||||||
|
|
||||||
```csharp
|
```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 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. |
|
| (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. |
|
| 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. |
|
| 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.
|
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.
|
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
|
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
|
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
|
||||||
separate axis from `Invoice.Status` — not merged in this pass.
|
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.
|
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.
|
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
|
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.
|
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
(`{ 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.
|
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
|
- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an
|
||||||
`Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`.
|
`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`.
|
- Lifecycle: `Draft → Printed → Finalized`.
|
||||||
- Finalizing can adjust the billed quantity per line (capped between `0` and the ordered
|
- The invoice has its own lines (`InvoiceItem`), copied from the PO's active lines when it is
|
||||||
quantity) and recomputes `TotalAmount` from the adjusted lines.
|
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`:
|
- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`:
|
||||||
`Pending → PartiallyPaid → Paid`.
|
`Pending → PartiallyPaid → Paid`.
|
||||||
|
|
||||||
+34
-23
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { FieldDescription } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import {
|
import {
|
||||||
invoiceCreationMode as invoiceCreationModeApi,
|
invoiceCreationMode as invoiceCreationModeApi,
|
||||||
@@ -14,15 +13,11 @@ import {
|
|||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
|
||||||
|
|
||||||
const MODE_LABELS: Record<InvoiceCreationMode, string> = {
|
// What each toggle stands for. Both end the same way: edit the draft, print, finalize.
|
||||||
ModeA: 'Auto on confirm',
|
const POSITIONS: { mode: InvoiceCreationMode; label: string; meaning: string; steps: string[] }[] = [
|
||||||
ModeB: 'Separate step',
|
{ 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'] },
|
||||||
|
]
|
||||||
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.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvoiceCreationModeCard() {
|
export function InvoiceCreationModeCard() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -66,18 +61,34 @@ export function InvoiceCreationModeCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2.5">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<ToggleGroup
|
{POSITIONS.map((p) => {
|
||||||
variant="outline"
|
const active = p.mode === mode
|
||||||
value={[mode]}
|
return (
|
||||||
onValueChange={(v: string[]) => v[0] && save(v[0] as InvoiceCreationMode)}
|
<div
|
||||||
disabled={pending}
|
key={p.mode}
|
||||||
className="w-full"
|
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
|
||||||
>
|
active ? 'border-primary/40 bg-muted' : 'border-border/60'
|
||||||
<ToggleGroupItem value="ModeA" className="flex-1">{MODE_LABELS.ModeA}</ToggleGroupItem>
|
}`}
|
||||||
<ToggleGroupItem value="ModeB" className="flex-1">{MODE_LABELS.ModeB}</ToggleGroupItem>
|
>
|
||||||
</ToggleGroup>
|
<label htmlFor={`invoice-creation-${p.mode}`} className="flex flex-col gap-0.5">
|
||||||
<FieldDescription className="text-xs leading-relaxed">{MODE_HINTS[mode]}</FieldDescription>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</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 { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
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 { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||||
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
||||||
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
|
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'> = {
|
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
||||||
Draft: 'secondary',
|
Draft: 'secondary',
|
||||||
Printed: 'outline',
|
Printed: 'outline',
|
||||||
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
|
|||||||
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
||||||
const [actionOpen, setActionOpen] = useState(false)
|
const [actionOpen, setActionOpen] = useState(false)
|
||||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||||
|
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
|
||||||
|
|
||||||
const fetchInvoice = useCallback(async () => {
|
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)
|
setInvoice(inv)
|
||||||
|
setVersions(vers)
|
||||||
return inv
|
return inv
|
||||||
}, [params.id])
|
}, [params.id])
|
||||||
|
|
||||||
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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' && (
|
{invoice.status === 'Draft' && (
|
||||||
<Button type="button" onClick={handlePrint}>
|
<Button type="button" onClick={handlePrint}>
|
||||||
Print Invoice
|
Print Invoice
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{invoice.status !== 'Draft' && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
|
||||||
|
Reprint
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{invoice.status === 'Printed' && (
|
{invoice.status === 'Printed' && (
|
||||||
<Button type="button" onClick={handleFinalize}>
|
<Button type="button" onClick={handleFinalize}>
|
||||||
Finalize Invoice
|
Finalize Invoice
|
||||||
@@ -167,6 +186,84 @@ export default function InvoiceViewPage() {
|
|||||||
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
||||||
</div>
|
</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="rounded-2xl border p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
import { toast } from '@/components/ui/toast'
|
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 { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { InvoiceResponse, UserResponse } from '@/types/api'
|
import type { InvoiceResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COPY = {
|
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' },
|
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
||||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
const copy = COPY[mode]
|
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>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ userId: '' })
|
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()
|
usersApi.list()
|
||||||
.then((res) => setUsersList(res))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.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
|
if (!invoice) return null
|
||||||
|
|
||||||
|
const lines = full?.items ?? []
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
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 {
|
try {
|
||||||
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
|
if (mode === 'print') {
|
||||||
else await invoicesApi.finalize(invoice!.id, values.userId)
|
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' })
|
toast.add({ title: copy.success, type: 'success' })
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
printTab?.close()
|
||||||
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{copy.title}</DialogTitle>
|
<DialogTitle>{copy.title}</DialogTitle>
|
||||||
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
||||||
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
|||||||
@@ -1,35 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||||
|
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
|
import type { InvoiceResponse } from '@/types/api'
|
||||||
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',
|
|
||||||
})
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
interface OrderViewSheetProps {
|
interface OrderViewSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
|
|||||||
invoice: InvoiceResponse | null
|
invoice: InvoiceResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
||||||
|
const doc = useInvoiceDocument(invoice?.id, open)
|
||||||
|
|
||||||
if (!invoice) return null
|
if (!invoice) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>Invoice Details</DialogTitle>
|
<DialogTitle>Invoice Preview</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{doc.invoice ? (
|
||||||
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
|
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
|
||||||
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
|
) : (
|
||||||
<Row label="Store" value={invoice.storeName} />
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
|
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
|
||||||
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
|
</p>
|
||||||
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
|
)}
|
||||||
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
|
|
||||||
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
|
{doc.invoice && doc.invoice.status !== 'Draft' && (
|
||||||
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
|
||||||
|
Open print view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
import { DataTable } from '@/components/shared/data-table'
|
import { DataTable } from '@/components/shared/data-table'
|
||||||
import type { TableConfig } 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() {
|
export function OrdersClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<InvoiceResponse[]>([])
|
const [data, setData] = useState<InvoiceResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -76,6 +78,10 @@ export function OrdersClient() {
|
|||||||
setViewOpen(true)
|
setViewOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(row: InvoiceResponse) {
|
||||||
|
router.push(`/sales/invoices/${row.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
function handlePrint(row: InvoiceResponse) {
|
function handlePrint(row: InvoiceResponse) {
|
||||||
setSelectedInvoice(row)
|
setSelectedInvoice(row)
|
||||||
setActionMode('print')
|
setActionMode('print')
|
||||||
@@ -156,12 +162,24 @@ export function OrdersClient() {
|
|||||||
label: 'View invoice',
|
label: 'View invoice',
|
||||||
onClick: handleView,
|
onClick: handleView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: 'edit',
|
||||||
|
label: 'Edit draft items',
|
||||||
|
onClick: handleEdit,
|
||||||
|
visible: (target) => target.status === 'Draft',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'print',
|
action: 'print',
|
||||||
label: 'Mark as printed',
|
label: 'Print invoice',
|
||||||
onClick: handlePrint,
|
onClick: handlePrint,
|
||||||
visible: (target) => target.status === 'Draft',
|
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',
|
action: 'finalize',
|
||||||
label: 'Finalize invoice',
|
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,
|
ShopPOResponse,
|
||||||
InvoiceResponse,
|
InvoiceResponse,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
|
InvoiceVersionResponse,
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
PaymentResponse,
|
PaymentResponse,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -93,8 +94,14 @@ export const invoices = {
|
|||||||
print: (id: string, printedByUserId?: string | null) =>
|
print: (id: string, printedByUserId?: string | null) =>
|
||||||
api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
|
api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
|
||||||
|
|
||||||
finalize: (id: string, finalizedByUserId?: string | null) =>
|
finalize: (id: string, finalizedByUserId?: string | null, adjustments?: { invoiceItemId: string; adjustedQuantity: number; isRemoved?: boolean }[]) =>
|
||||||
api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId }).then(r => r.data),
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Payments ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type PurchaseOrderStatus =
|
|||||||
| 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled'
|
| 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled'
|
||||||
export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled'
|
export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled'
|
||||||
export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
|
export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
|
||||||
|
export type InvoiceVersionStage = 'Created' | 'DraftEdited' | 'Printed' | 'Finalized'
|
||||||
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
|
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
|
||||||
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
|
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
|
||||||
export type PaymentMethod = 'Cash' | 'Cheque' | 'Credit'
|
export type PaymentMethod = 'Cash' | 'Cheque' | 'Credit'
|
||||||
@@ -1001,6 +1002,36 @@ export interface ShopPOResponse {
|
|||||||
cancelledByRole: string | null
|
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 {
|
export interface InvoiceResponse {
|
||||||
id: string
|
id: string
|
||||||
shopPOId: string
|
shopPOId: string
|
||||||
@@ -1017,6 +1048,7 @@ export interface InvoiceResponse {
|
|||||||
paidAmount: number
|
paidAmount: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
items: InvoiceItemResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutstandingPaymentsResponse {
|
export interface OutstandingPaymentsResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user