diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 8972da9..af1ecb5 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -1,4 +1,5 @@ using ERPCore.Domain.Entities; +using ERPCore.Domain; using ERPCore.Domain.Enums; using Microsoft.EntityFrameworkCore; @@ -42,7 +43,11 @@ public static class DataSeeder { var dirty = await SeedReasonCodesAsync(db, ct); dirty |= await SeedItemTypesAsync(db, ct); + dirty |= await SeedCompanyProfileAsync(db, ct); dirty |= await SeedProductConfigAsync(db, ct); + dirty |= await SeedSalesMastersAsync(db, ct); + dirty |= await SeedSalesStockAsync(db, ct); + dirty |= await SeedSalesAsync(db, ct); if (dirty) await db.SaveChangesAsync(ct); } @@ -99,4 +104,541 @@ public static class DataSeeder }); return true; } + + /// + /// Seeds a printable company profile with reasonable defaults for invoice headers. + /// These values are intentionally editable later through the API. + /// + private static async Task SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false; + + db.CompanyProfiles.Add(new CompanyProfile + { + CompanyProfileId = CompanyProfile.SingletonId, + LegalName = "ERP Core Trading (Pvt) Ltd", + TradeName = "ERP Core Trading", + TaxRegistrationNo = "TAX-DEFAULT-001", + VatRegistrationNo = "VAT-DEFAULT-001", + AddressLine1 = "1 Demo Street", + City = "Colombo", + Country = "Sri Lanka", + Phone = "+94 11 000 0000", + Email = "accounts@example.com", + BankName = "Demo Bank", + BankBranch = "Colombo Main", + AccountName = "ERP Core Trading (Pvt) Ltd", + AccountNumber = "000123456789", + SwiftCode = "DEMO1234", + FooterNote = "Thank you for your business." + }); + return true; + } + + /// + /// Seeds the minimum catalog data required for the sales demo rows to exist. + /// These are safe additive rows and do not alter any existing data. + /// + private static async Task SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct) + { + var dirty = false; + + dirty |= await SeedWarehousesAsync(db, ct); + dirty |= await SeedUomsAsync(db, ct); + dirty |= await SeedCategoriesAsync(db, ct); + dirty |= await SeedItemsAsync(db, ct); + + return dirty; + } + + /// + /// Seeds a simple on-hand FIFO layer for the demo sales item so the sample + /// invoices can be posted without immediately failing stock validation. + /// This keeps the stock-check and posting flows testable on a fresh database. + /// + private static async Task SeedSalesStockAsync(ErpDbContext db, CancellationToken ct) + { + var warehouse = await db.Warehouses.AsNoTracking() + .OrderBy(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var item = await db.Items.AsNoTracking() + .OrderBy(i => i.ItemId) + .FirstOrDefaultAsync(ct); + var secondItem = await db.Items.AsNoTracking() + .OrderByDescending(i => i.ItemId) + .FirstOrDefaultAsync(ct); + + if (warehouse is null || item is null || secondItem is null) + return false; + + var existing = await db.StockLayers.AnyAsync( + l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m, + ct); + if (existing) return false; + + db.StockLayers.Add(new StockLayer + { + ItemId = item.ItemId, + WarehouseId = warehouse.WarehouseId, + QtyReceived = 100m, + QtyRemaining = 100m, + UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m, + ReceiptDate = DateTime.UtcNow.AddDays(-7) + }); + db.StockLayers.Add(new StockLayer + { + ItemId = secondItem.ItemId, + WarehouseId = warehouse.WarehouseId, + QtyReceived = 5m, + QtyRemaining = 5m, + UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m, + ReceiptDate = DateTime.UtcNow.AddDays(-6) + }); + return true; + } + + private static async Task SeedWarehousesAsync(ErpDbContext db, CancellationToken ct) + { + var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct); + var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Warehouse { Code = "MAIN", Name = "Main Warehouse" }, + new Warehouse { Code = "SHOP", Name = "Sales Counter" } + }; + + var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList(); + if (toAdd.Count == 0) return false; + + db.Warehouses.AddRange(toAdd); + return true; + } + + private static async Task SeedUomsAsync(ErpDbContext db, CancellationToken ct) + { + var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct); + var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Uom { Name = "PCS" }, + new Uom { Name = "BOX" } + }; + + var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList(); + if (toAdd.Count == 0) return false; + + db.Uoms.AddRange(toAdd); + return true; + } + + private static async Task SeedCategoriesAsync(ErpDbContext db, CancellationToken ct) + { + var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct); + var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }, + new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow } + }; + + var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList(); + if (toAdd.Count == 0) return false; + + db.Categories.AddRange(toAdd); + return true; + } + + private static async Task SeedItemsAsync(ErpDbContext db, CancellationToken ct) + { + var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct); + var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var category = await db.Categories.AsNoTracking() + .OrderBy(c => c.CategoryId) + .FirstOrDefaultAsync(ct); + var uom = await db.Uoms.AsNoTracking() + .OrderBy(u => u.UomId) + .FirstOrDefaultAsync(ct); + + if (category is null || uom is null) + return false; + + var now = DateTime.UtcNow; + var seeds = new[] + { + new Item + { + Sku = "SKU-DEMO-001", + Name = "Demo Item 1", + Description = "Seeded sample item for sales documents", + CategoryId = category.CategoryId, + BaseUomId = uom.UomId, + StockNature = StockNature.Stocked, + TrackingMode = TrackingMode.None, + SalePrice = 100m, + Status = EntityStatus.Active, + CreatedAt = now + }, + new Item + { + Sku = "SKU-DEMO-002", + Name = "Demo Item 2", + Description = "Secondary seeded sample item for sales documents", + CategoryId = category.CategoryId, + BaseUomId = uom.UomId, + StockNature = StockNature.Stocked, + TrackingMode = TrackingMode.None, + SalePrice = 50m, + Status = EntityStatus.Active, + CreatedAt = now + } + }; + + var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList(); + if (toAdd.Count == 0) return false; + + db.Items.AddRange(toAdd); + return true; + } + + /// + /// Seeds the minimum sales bootstrap data needed for UI/backend development: + /// a couple of customer rows, current-year document counters, and a few draft + /// invoice/slip samples when the required master data already exists. + /// This intentionally never clears or rewrites any existing rows. + /// + private static async Task SeedSalesAsync(ErpDbContext db, CancellationToken ct) + { + var dirty = false; + + dirty |= await SeedSalesCustomersAsync(db, ct); + dirty |= await SeedSalesSequencesAsync(db, ct); + dirty |= await SeedSampleSalesDocsAsync(db, ct); + + return dirty; + } + + private static async Task SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct) + { + var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct); + var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new Customer + { + CustomerCode = "CUST-WALKIN", + CustomerType = CustomerType.B2C, + Name = "Walk-in Customer", + DisplayName = "Walk-in Customer", + CreditLimit = 0m, + CreditDays = 0, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }, + new Customer + { + CustomerCode = "CUST-DEMO", + CustomerType = CustomerType.B2B, + Name = "Demo Retail Ltd", + DisplayName = "Demo Retail Ltd", + Phone = "+94 11 000 0000", + Email = "sales@example.com", + AddressLine1 = "1 Demo Street", + City = "Colombo", + Country = "Sri Lanka", + TaxRegistrationNo = "VAT-DEMO-001", + CreditLimit = 250000m, + CreditDays = 30, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + } + }; + + var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList(); + if (toAdd.Count == 0) return false; + + db.Customers.AddRange(toAdd); + return true; + } + + private static async Task SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct) + { + var year = DateTime.UtcNow.Year; + var existing = await db.NumberSequences + .Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip)) + .Select(s => s.DocType) + .ToListAsync(ct); + var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seeds = new[] + { + new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 }, + new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 } + }; + + var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList(); + if (toAdd.Count == 0) return false; + + db.NumberSequences.AddRange(toAdd); + return true; + } + + private static async Task SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct)) + return false; + + var customer = await db.Customers.AsNoTracking() + .OrderBy(c => c.CustomerId) + .FirstOrDefaultAsync(ct); + var warehouse = await db.Warehouses.AsNoTracking() + .OrderBy(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var secondaryWarehouse = await db.Warehouses.AsNoTracking() + .OrderByDescending(w => w.WarehouseId) + .FirstOrDefaultAsync(ct); + var items = await db.Items.AsNoTracking() + .OrderBy(i => i.ItemId) + .Take(2) + .ToListAsync(ct); + var uom = await db.Uoms.AsNoTracking() + .OrderBy(u => u.UomId) + .FirstOrDefaultAsync(ct); + var user = await db.Users.AsNoTracking() + .OrderBy(u => u.UserId) + .FirstOrDefaultAsync(ct); + + if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null) + return false; + + var postableItem = items[0]; + var shortageItem = items[1]; + var today = DateTime.UtcNow.Date; + var createdAt = DateTime.UtcNow.AddDays(-1); + + db.SalesInvoices.AddRange( + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00001", + InvoiceDate = today.AddDays(-2), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = warehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2C, + Status = SalesInvoiceStatus.Draft, + Subtotal = 200m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 200m, + RoundOff = 0m, + NetPayable = 200m, + PaidAmount = 0m, + BalanceAmount = 200m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + Lines = + [ + new SalesInvoiceLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 2m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 100m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 100m, + LineTotal = 200m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00002", + InvoiceDate = today.AddDays(-1), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = secondaryWarehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2B, + Status = SalesInvoiceStatus.Draft, + Subtotal = 300m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 300m, + RoundOff = 0m, + NetPayable = 300m, + PaidAmount = 0m, + BalanceAmount = 300m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + Lines = + [ + new SalesInvoiceLine + { + ItemId = shortageItem.ItemId, + Description = shortageItem.Name, + Qty = 6m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = secondaryWarehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 50m, + LineTotal = 300m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesInvoice + { + InvoiceNo = $"SI-{today:yyyy}-00003", + InvoiceDate = today, + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + CustomerSnapshotTaxNo = customer.TaxRegistrationNo, + WarehouseId = warehouse.WarehouseId, + InvoiceType = SalesInvoiceType.B2C, + Status = SalesInvoiceStatus.Posted, + Subtotal = 100m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 100m, + RoundOff = 0m, + NetPayable = 100m, + PaidAmount = 100m, + BalanceAmount = 0m, + CreatedBy = user.UserId, + CreatedAt = createdAt, + UpdatedAt = DateTime.UtcNow, + Lines = + [ + new SalesInvoiceLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 1m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 100m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 100m, + LineTotal = 100m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + } + ); + + db.SalesSlips.AddRange( + new SalesSlip + { + SlipNo = $"SSL-{today:yyyy}-00001", + SlipDate = today.AddDays(-2), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + WarehouseId = warehouse.WarehouseId, + CashierUserId = user.UserId, + Status = SalesSlipStatus.Draft, + Subtotal = 50m, + DiscountTotal = 0m, + TaxTotal = 0m, + GrandTotal = 50m, + PaidAmount = 0m, + BalanceAmount = 50m, + CreatedAt = createdAt, + Lines = + [ + new SalesSlipLine + { + ItemId = postableItem.ItemId, + Description = postableItem.Name, + Qty = 1m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = warehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 0m, + DiscountAmount = 0m, + NetUnitPrice = 50m, + LineTotal = 50m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + }, + new SalesSlip + { + SlipNo = $"SSL-{today:yyyy}-00002", + SlipDate = today.AddDays(-1), + CustomerId = customer.CustomerId, + CustomerSnapshotName = customer.Name, + WarehouseId = secondaryWarehouse.WarehouseId, + CashierUserId = user.UserId, + Status = SalesSlipStatus.Draft, + Subtotal = 150m, + DiscountTotal = 15m, + TaxTotal = 0m, + GrandTotal = 135m, + PaidAmount = 0m, + BalanceAmount = 135m, + CreatedAt = createdAt, + Lines = + [ + new SalesSlipLine + { + ItemId = shortageItem.ItemId, + Description = shortageItem.Name, + Qty = 3m, + FreeQty = 0m, + UomId = uom.UomId, + WarehouseId = secondaryWarehouse.WarehouseId, + UnitPrice = 50m, + BaseCost = 0m, + PriceSource = "seed", + DiscountMode = SalesDiscountMode.Percentage, + DiscountPct = 10m, + DiscountAmount = 15m, + NetUnitPrice = 45m, + LineTotal = 135m, + TaxPct = 0m, + TaxAmount = 0m, + IsFreeIssue = false + } + ] + } + ); + + return true; + } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index ec71934..83c0d87 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -15,6 +15,7 @@ namespace ERPCore.Infra.Persistence; public class ErpDbContext : DbContext { private readonly ICurrentUser _currentUser; + private bool _writingAuditLogs; public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options) { @@ -35,6 +36,8 @@ public class ErpDbContext : DbContext public DbSet Vendors => Set(); public DbSet Warehouses => Set(); public DbSet Bins => Set(); + /// Singleton business profile used by printable documents. + public DbSet CompanyProfiles => Set(); /// Singleton row (FR-MD-11). public DbSet ProductConfig => Set(); @@ -185,24 +188,44 @@ public class ErpDbContext : DbContext // persists the logs without re-auditing them. public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) { - var pending = AuditScribe.Capture(ChangeTracker); + IReadOnlyList pending = _writingAuditLogs + ? Array.Empty() + : AuditScribe.Capture(ChangeTracker); var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); if (pending.Count > 0) { - WriteAuditLogs(pending); - await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + try + { + _writingAuditLogs = true; + WriteAuditLogs(pending); + await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + } + finally + { + _writingAuditLogs = false; + } } return result; } public override int SaveChanges(bool acceptAllChangesOnSuccess) { - var pending = AuditScribe.Capture(ChangeTracker); + IReadOnlyList pending = _writingAuditLogs + ? Array.Empty() + : AuditScribe.Capture(ChangeTracker); var result = base.SaveChanges(acceptAllChangesOnSuccess); if (pending.Count > 0) { - WriteAuditLogs(pending); - base.SaveChanges(acceptAllChangesOnSuccess); + try + { + _writingAuditLogs = true; + WriteAuditLogs(pending); + base.SaveChanges(acceptAllChangesOnSuccess); + } + finally + { + _writingAuditLogs = false; + } } return result; } diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 4df0ad6..8b2f945 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -11,7 +11,6 @@ using ERPCore.Services; using ERPCore.Services.Interfaces; using ERPCore.Services.Auth; using ERPCore.Services.Hrm; -using ERPCore.Services.Interfaces; using ERPCore.Services.Production; using ERPCore.Services.Stock; using ERPCore.System.Errors; @@ -79,6 +78,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -102,6 +102,9 @@ builder.Services.AddScoped(); // Sales (Phase 1) builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); @@ -177,7 +180,15 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - await DataSeeder.SeedAsync(db); + await db.Database.MigrateAsync(); + try + { + await DataSeeder.SeedAsync(db); + } + catch (Exception ex) + { + throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex); + } } app.UseSerilogRequestLogging(); diff --git a/Backend/ERPCore/Services/Interfaces/ICompanyProfileService.cs b/Backend/ERPCore/Services/Interfaces/ICompanyProfileService.cs new file mode 100644 index 0000000..c63ddec --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ICompanyProfileService.cs @@ -0,0 +1,10 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Config; + +namespace ERPCore.Services.Interfaces; + +public interface ICompanyProfileService +{ + Task> GetAsync(CancellationToken ct = default); + Task> UpdateAsync(UpdateCompanyProfileRequest request, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index f1e9752..f65e8f9 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,6 +4,11 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API) Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. +## 8. Sales +- [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved. +- [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation. +- [x] Free-issue CRUD exposed as `api/v1/free-issues` as a thin alias over sales slips. Free issue remains a line-level `IsFreeIssue` / `FreeQty` behavior, not a separate table. + ## 0. Bootstrap - [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4) - [x] Folder structure per 00-CORE §5.3 diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index b6cb645..0d154fb 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -42,6 +42,13 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry. - [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page +## 3.5 Sales screens +- [~] Sales hub (`app/dashboard/sales`) — new module entry point linking to invoices, slips, free issues, and reports +- [~] Sales invoices (`app/dashboard/sales/invoices`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales invoice API, including save/post/cancel on the detail page +- [~] Sales slips (`app/dashboard/sales/slips`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales slip API, including save/post/cancel on the detail page +- [~] Free issues (`app/dashboard/sales/free-issues`, `/new`, `/[id]`) — alias-only surface over sales slips for free-issue handling; edit/save/post/cancel stays on the slip screen +- [~] Sales reports (`app/dashboard/sales/reports`, `/[reportId]`) — report catalog + report metadata view wired to `/reports/sales` + ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail - [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry. diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx new file mode 100644 index 0000000..8e1bf9c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx @@ -0,0 +1,493 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { toast } from "@/components/ui/toast" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" + +type Line = CreateSalesSlipLineRequest & { key: string } + +type FreeIssueRow = { + salesSlipId: number + slipNo: string + status: string + etag: string + warehouseName: string + itemName: string + itemSku: string + uomName: string + qty: number + freeQty: number +} + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +export default function NewFreeIssuePage() { + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [rows, setRows] = useState([]) + const [editingRowId, setEditingRowId] = useState(null) + const [editingLines, setEditingLines] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + async function refreshRows() { + const list = await salesApi.listFreeIssues({ pageSize: 50 }) + const details = await Promise.all( + list.items.map(async (summary) => { + const detail = await salesApi.getFreeIssue(summary.salesSlipId) + const firstLine = detail.data.lines[0] + const item = items.find((x) => x.itemId === firstLine?.itemId) + const uom = uoms.find((x) => x.uomId === firstLine?.uomId) + const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId) + return { + salesSlipId: detail.data.salesSlipId, + slipNo: detail.data.slipNo, + status: detail.data.status, + etag: detail.etag ?? "", + warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`, + itemName: item?.name ?? firstLine?.description ?? "—", + itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`, + uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`, + qty: firstLine?.qty ?? 0, + freeQty: firstLine?.freeQty ?? 0, + } satisfies FreeIssueRow + }), + ) + setRows(details.filter((row) => row.status !== "Cancelled")) + } + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(async ([cust, itemRes, uomRes, whRes, userRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(whRes.items[0]?.warehouseId ?? null) + setCashierUserId(userRes.items[0]?.userId ?? null) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: whRes.items[0]?.warehouseId ?? 0, + }, + ]) + await refreshRows() + }) + .catch((err) => setError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateEditingLine(key: string, patch: Partial) { + setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + } + + function selectEditingItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + } + + async function submit() { + const activeLines = editingRowId ? editingLines : lines + if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.") + if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.") + if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.") + if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.") + + setSaving(true) + setError(null) + try { + const payload: CreateSalesSlipRequest = { + customerId, + warehouseId, + cashierUserId, + lines: activeLines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + + if (editingRowId) { + const latest = await salesApi.getFreeIssue(editingRowId) + await salesApi.updateFreeIssue(editingRowId, payload, latest.etag) + toast.success("Free issue updated") + setEditingRowId(null) + setEditingLines([]) + } else { + const created = await salesApi.createFreeIssue(payload) + toast.success("Free issue created", created.data.slipNo) + } + + setLines([blankLine("line-1")]) + await refreshRows() + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function startEdit(row: FreeIssueRow) { + try { + const detail = await salesApi.getFreeIssue(row.salesSlipId) + const detailLine = detail.data.lines[0] + setEditingRowId(row.salesSlipId) + setCustomerId(detail.data.customerId) + setWarehouseId(detail.data.warehouseId) + setCashierUserId(detail.data.cashierUserId) + setEditingLines([ + { + key: "edit-line-1", + itemId: detailLine?.itemId ?? 0, + uomId: detailLine?.uomId ?? 0, + warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId, + qty: detailLine?.qty ?? 1, + freeQty: detailLine?.freeQty ?? 0, + unitPrice: detailLine?.unitPrice ?? null, + allowManualPriceOverride: true, + discountMode: detailLine?.discountMode ?? "Percentage", + discountPct: detailLine?.discountPct ?? 0, + discountAmount: detailLine?.discountAmount ?? 0, + discountValue: 0, + taxPct: detailLine?.taxPct ?? 0, + isFreeIssue: detailLine?.isFreeIssue ?? false, + parentLineId: detailLine?.parentLineId ?? null, + }, + ]) + } catch (err) { + setError(errorMessage(err)) + } + } + + function cancelEdit() { + setEditingRowId(null) + setEditingLines([]) + } + + async function deleteRow(row: FreeIssueRow) { + if (row.status !== "Draft") return + try { + await salesApi.cancelFreeIssue(row.salesSlipId) + toast.success("Free issue cancelled", row.slipNo) + if (editingRowId === row.salesSlipId) cancelEdit() + await refreshRows() + } catch (err) { + setError(errorMessage(err)) + } + } + + if (loading) { + return
Loading masters...
+ } + + const activeLines = editingRowId ? editingLines : lines + + return ( +
+
+
+ + + +
+

Free Issues

+

Create and manage free-issue sales slips from the backend.

+
+
+ +
+ + {error ?
{error}
: null} + + {editingRowId ? ( +
+
+

Inline edit free issue

+ +
+
+ + + + ID + Item + UOM + Qty + Free + + + + + {activeLines.map((line, idx) => ( + + {idx + 1} + + + + + + + + updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + editing + + ))} + +
+
+
+ ) : ( +
+
+

Free issue lines

+ +
+
+ + + + ID + Item + UOM + Qty + Free + + + + + {lines.map((line, idx) => ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + + + + + + ))} + +
+
+
+ )} + +
+
+
+

Created free issues

+

Persisted backend records with draft-only cancellation.

+
+ +
+
+ + + + Promotion + Warehouse + Product + Qty + Free + Actions + + + + {rows.length > 0 ? ( + rows.map((row) => ( + + +
Buy {row.qty} Get {row.freeQty || 0}
+
{row.slipNo}
+
+ {row.warehouseName} + +
{row.itemName}
+
{row.itemSku} · {row.uomName}
+
+ {row.qty} + {row.freeQty} + +
+ + {row.status === "Draft" ? ( + + ) : null} +
+
+
+ )) + ) : ( + + + No free issues created yet. + + + )} +
+
+
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx new file mode 100644 index 0000000..474a1ae --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/page.tsx @@ -0,0 +1,3 @@ +"use client" + +export { default } from "./new/page" diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx new file mode 100644 index 0000000..113ec6d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -0,0 +1,605 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react" + +import { companyApi } from "@/lib/api/company" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { CompanyProfile } from "@/types/company" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales" +import { toast } from "@/components/ui/toast" +import { Label } from "@/components/ui/label" + +type Line = CreateSalesInvoiceLineRequest & { key: string } + +const money = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +function statusClass(status: SalesInvoiceStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { + const router = useRouter() + const resolvedParams = use(params) + const invoiceId = Number(resolvedParams.id) + + const [invoice, setInvoice] = useState(null) + const [company, setCompany] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [lines, setLines] = useState([blankLine("line-1")]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [invoiceType, setInvoiceType] = useState("B2C") + const [etag, setEtag] = useState(null) + const [postingCheck, setPostingCheck] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [busy, setBusy] = useState<"post" | "cancel" | null>(null) + + useEffect(() => { + if (!Number.isFinite(invoiceId)) { + setError(`Invalid invoice id '${resolvedParams.id}'.`) + return + } + + Promise.all([ + companyApi.getProfile(), + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.getInvoice(invoiceId), + ]) + .then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => { + setCompany(companyRes.data) + setCustomers(customerRes.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(warehouseRes.items) + setInvoice(doc.data) + setEtag(doc.etag) + setCustomerId(doc.data.customerId) + setWarehouseId(doc.data.warehouseId) + setInvoiceType(doc.data.invoiceType) + setLines( + doc.data.lines.map((line) => ({ + key: String(line.salesInvoiceLineId), + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + freeQty: line.freeQty, + unitPrice: line.unitPrice, + allowManualPriceOverride: true, + discountMode: line.discountMode, + discountPct: line.discountPct, + discountAmount: line.discountAmount, + discountValue: 0, + taxPct: line.taxPct, + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId, + })) + ) + }) + .catch((err) => setError(errorMessage(err))) + }, [invoiceId, resolvedParams.id]) + + useEffect(() => { + if (!invoice || invoice.status !== "Draft") { + setPostingCheck(null) + return + } + salesApi + .checkInvoicePosting(invoiceId) + .then((result) => setPostingCheck(result)) + .catch(() => setPostingCheck(null)) + }, [invoice, invoiceId]) + + const isDraft = invoice?.status === "Draft" + const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId]) + const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId]) + const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0 + const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateHeaderWarehouse(nextWarehouseId: number | null) { + setWarehouseId(nextWarehouseId) + setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId }))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function save() { + if (!customerId || !warehouseId || !etag) return + if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) { + setError("Select item, UOM and warehouse for every line.") + return + } + + setSaving(true) + setError(null) + try { + const updated = await salesApi.updateInvoice( + invoiceId, + { + customerId, + warehouseId, + invoiceType, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + }, + etag, + ) + setInvoice(updated.data) + setEtag(updated.etag) + toast.success("Invoice saved", updated.data.invoiceNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function post() { + if (!postingCheck?.canPost) { + setError("Resolve stock shortages before posting this invoice.") + return + } + setBusy("post") + setError(null) + try { + const posted = await salesApi.postInvoice(invoiceId) + setInvoice(posted) + toast.success("Invoice posted", posted.invoiceNo) + router.refresh() + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(null) + } + } + + async function cancel() { + setBusy("cancel") + setError(null) + try { + const cancelled = await salesApi.cancelInvoice(invoiceId) + setInvoice(cancelled) + toast.success("Invoice cancelled", cancelled.invoiceNo) + router.refresh() + } catch (err) { + setError(errorMessage(err)) + } finally { + setBusy(null) + } + } + + if (error && !invoice) { + return
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice data is loading or unavailable."}
+ } + + return ( +
+
+
+ + + +
+

Invoice Details

+

{invoice.invoiceNo}

+
+
+ + + Print + +
+ + {error ?
{error}
: null} + +
+
+
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
+ Status: + {invoice.status} + Type: {invoice.invoiceType} + Date: {new Date(invoice.invoiceDate).toLocaleDateString()} +
+
+
+
{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}
+
+ {company?.addressLine1 ?? ""} + {company?.city ? `, ${company.city}` : ""} +
+ {company?.taxRegistrationNo ?
Tax No: {company.taxRegistrationNo}
: null} +
+
+
+ +
+
+
Bill To
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {customer?.displayName ?
Registered name: {customer.displayName}
: null} + {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
Location: {warehouse?.location ?? "—"}
+
+
+
Totals
+
+
Subtotal
+
{money.format(invoice.totals.subtotal)}
+
Discount
+
{money.format(invoice.totals.discountTotal)}
+
Free qty
+
{freeQtyTotal.toFixed(0)}
+
Tax
+
{money.format(invoice.totals.taxTotal)}
+
Net payable
+
{money.format(invoice.totals.netPayable)}
+
+
+
+ +
+ + + + + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + + + + + ))} + +
ItemUOMQtyFreeUnit priceDiscountTaxLine total
+
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}{line.qty.toFixed(0)}{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}{money.format(line.unitPrice)}{money.format(line.discountAmount)}{money.format(line.taxAmount)}{money.format(line.lineTotal)}
+
+ + {invoice.lines.some((line) => line.freeQty > 0) ? ( +
+
Free issue summary
+
+ {invoice.lines.filter((line) => line.freeQty > 0).map((line) => ( +
+
{line.description}
+
Free qty: {line.freeQty.toFixed(0)}
+
+ ))} +
+
+ ) : null} + + {invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? ( +
+
+
Stock shortage detected before posting
+
The invoice cannot be posted until every line has enough available stock in the selected warehouse.
+
+ + + + + + + + + + + + {postingCheck.issues.map((issue) => ( + + + + + + + + ))} + +
ItemWarehouseRequestedAvailableShort
+
{issue.itemSku}
+
{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}
+
{issue.warehouseId}{issue.requestedQty.toFixed(0)}{issue.availableQty.toFixed(0)}{issue.shortQty.toFixed(0)}
+
+
+
+ ) : null} + +
+
Notes
+

{company?.footerNote ?? "Standard invoice template view."}

+
+ +
+
+

Actions

+ {isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."} +
+ + {isDraft ? ( + <> +
+ + + + +
+ +
+ + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + + + ))} + +
ItemUOMQtyFreeUnit priceRemove
+ + + + + updateLine(line.key, { qty: Number(e.target.value) || 0 })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + updateLine(line.key, { freeQty: Number(e.target.value) || 0 })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" + /> + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + ) : ( +
+ This invoice is {invoice.status.toLowerCase()} and cannot be edited. +
+ )} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx new file mode 100644 index 0000000..6fca4fb --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx @@ -0,0 +1,218 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { companyApi } from "@/lib/api/company" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { Customer } from "@/types/customers" +import { CompanyProfile } from "@/types/company" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { SalesInvoice } from "@/types/sales" + +export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) { + const invoiceId = Number(params.id) + const [invoice, setInvoice] = useState(null) + const [company, setCompany] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!Number.isFinite(invoiceId)) { + setError(`Invalid invoice id '${params.id}'.`) + return + } + Promise.all([ + companyApi.getProfile(), + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.getInvoice(invoiceId), + ]) + .then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => { + setCompany(companyRes.data) + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setInvoice(doc.data) + }) + .catch((err) => setError(errorMessage(err))) + }, [params.id, invoiceId]) + + const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice]) + const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0 + + if (error && !invoice) { + return
{error}
+ } + + if (!invoice) { + return
{error ?? "Invoice print data is loading or unavailable."}
+ } + + const customer = customers.find((c) => c.customerId === invoice.customerId) + const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId) + + return ( +
+
+
+ + + +
+

Invoice Print

+

{invoice.invoiceNo}

+
+
+ +
+ +
+
+
+
Sales Invoice
+

{invoice.invoiceNo}

+
Status: {invoice.status} · Type: {invoice.invoiceType}
+
+
+
{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}
+ {company?.logoUrl ? {company.tradeName : null} +
{company?.addressLine1}{company?.city ? `, ${company.city}` : ""}
+ {company?.taxRegistrationNo ?
Tax No: {company.taxRegistrationNo}
: null} +
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
+
Printed: {new Date().toLocaleString()}
+
Free qty total: {freeQtyTotal.toFixed(2)}
+
+
+ +
+
+
Customer
+
{invoice.customerSnapshotName}
+
Customer ID: {invoice.customerId}
+ {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} + {customer?.displayName ?
Customer: {customer.displayName}
: null} +
+
+
Warehouse
+
{warehouse?.name ?? `#${invoice.warehouseId}`}
+
Code: {warehouse?.code ?? invoice.warehouseId}
+
+
+
Totals
+
+
Subtotal
{invoice.totals.subtotal.toFixed(2)}
+
Discount
{invoice.totals.discountTotal.toFixed(2)}
+
Free qty
{freeQtyTotal.toFixed(2)}
+
Tax
{invoice.totals.taxTotal.toFixed(2)}
+
Net payable
{invoice.totals.netPayable.toFixed(2)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Discount + Tax + Line total + + + + {invoice.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(2)} + {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} + {line.unitPrice.toFixed(2)} + {line.discountAmount.toFixed(2)} + {line.taxAmount.toFixed(2)} + {line.lineTotal.toFixed(2)} +
+ ))} +
+
+
+ + {invoice.lines.some((line) => line.freeQty > 0) ? ( +
+
Free issue summary
+
+ {invoice.lines.filter((line) => line.freeQty > 0).map((line) => ( +
+
{line.description}
+
Invoice qty: {line.qty.toFixed(2)}
+
Free qty: {line.freeQty.toFixed(2)}
+
+ ))} +
+
+ ) : null} + +
+
+
Notes
+

+ {company?.footerNote ?? "Standard invoice print view."} +

+
+
+
Totals
+
+
Subtotal{invoice.totals.subtotal.toFixed(2)}
+
Discount total{invoice.totals.discountTotal.toFixed(2)}
+
Free qty total{invoice.totals.freeQtyTotal.toFixed(2)}
+
Tax total{invoice.totals.taxTotal.toFixed(2)}
+
Net payable{invoice.totals.netPayable.toFixed(2)}
+
Paid{invoice.totals.paidAmount.toFixed(2)}
+
Balance{invoice.totals.balanceAmount.toFixed(2)}
+
+
+
+ + {company ? ( +
+
Bank Details
+
+
Bank {company.bankName ?? "—"}
+
Branch {company.bankBranch ?? "—"}
+
Account Name {company.accountName ?? "—"}
+
Account No {company.accountNumber ?? "—"}
+
SWIFT {company.swiftCode ?? "—"}
+
VAT {company.vatRegistrationNo ?? "—"}
+
+
+ ) : null} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx new file mode 100644 index 0000000..6da9053 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -0,0 +1,490 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesInvoiceLineRequest & { key: string } +type ActiveFocScheme = { + id: number + slipNo: string + schemeLabel: string + warehouseName: string + productLabel: string +} + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +const lkr = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +export default function NewSalesInvoicePage() { + const router = useRouter() + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [invoiceType, setInvoiceType] = useState("B2C") + const [lines, setLines] = useState([blankLine("line-1")]) + const [activeFocSchemes, setActiveFocSchemes] = useState([]) + const [loading, setLoading] = useState(true) + const [submitError, setSubmitError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + salesApi.listFreeIssues({ pageSize: 20 }), + ]) + .then(([cust, itemRes, uomRes, whRes, freeIssueRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(defaultWarehouseId) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: defaultWarehouseId ?? 0, + unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), + }, + ]) + setActiveFocSchemes( + freeIssueRes.items.flatMap((issue) => { + const firstLine = issue.lines?.[0] + if (!firstLine) return [] + const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId) + const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId) + return [ + { + id: issue.salesSlipId, + slipNo: issue.slipNo, + schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`, + warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`, + productLabel: `${item?.sku ?? `SKU-${firstLine.itemId}`} - ${item?.name ?? firstLine.itemName ?? `Item ${firstLine.itemId}`}`, + }, + ] + }), + ) + }) + .catch((err) => setSubmitError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function updateHeaderWarehouse(nextWarehouseId: number | null) { + setWarehouseId(nextWarehouseId) + setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId }))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [ + ...prev, + { + ...blankLine(`line-${Date.now()}`), + warehouseId: warehouseId ?? 0, + }, + ]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + const grossTotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0), + [lines], + ) + const discountTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)) + }, 0), + [lines], + ) + const taxTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100) + const taxable = Math.max(0, gross - discount) + return sum + taxable * (Number(line.taxPct || 0) / 100) + }, 0), + [lines], + ) + const netTotal = Math.max(0, grossTotal - discountTotal) + const payableTotal = netTotal + taxTotal + + async function submit() { + if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") + if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.") + if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") + if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.") + + const payload: CreateSalesInvoiceRequest = { + customerId, + warehouseId, + invoiceType, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + + setSaving(true) + setSubmitError(null) + try { + const created = await salesApi.createInvoice(payload) + toast.success("Invoice created", created.data.invoiceNo) + router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`) + } catch (err) { + if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) { + router.push(`/login?next=/dashboard/sales/invoices/new`) + return + } + setSubmitError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loading) { + return
Loading masters...
+ } + + return ( +
+
+
+ + + +
+

New invoice

+

Select items, set quantity, unit price and line discount.

+
+
+
+ + +
+
+ + {submitError ?
{submitError}
: null} + +
+

Invoice header

+
+
+ + +
+
+ + +
+
+ + value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}> + + + + + B2B + B2C + Cash + Credit + + +
+
+ +
+ + Draft + + {lkr.format(payableTotal)} +
+
+
+
+ +
+
+

Invoice lines

+ +
+
+ + + + # + Item + UOM + Qty + Free + Unit price + Discount % + Line total + + + + + {lines.map((line, idx) => { + const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const lineDiscount = + line.discountMode === "Amount" + ? Number(line.discountAmount || 0) + : lineGross * (Number(line.discountPct || 0) / 100) + const lineNet = Math.max(0, lineGross - lineDiscount) + + return ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-9 w-28 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { discountPct: Number(e.target.value) || 0 })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + {lkr.format(lineNet)} + + + + + ) + })} + +
+
+
+ +
+ {/*
+

Active FOC schemes

+
    + {activeFocSchemes.length > 0 ? ( + activeFocSchemes.map((scheme) => ( +
  • +
    + {scheme.schemeLabel} + {scheme.slipNo} +
    +
    + {scheme.productLabel} + {scheme.warehouseName} +
    +
  • + )) + ) : ( +
  • + No active free-issue schemes found. +
  • + )} +
+
*/} + +
+ {[ + ["Gross", lkr.format(grossTotal)], + ["Discount", `-${lkr.format(discountTotal)}`], + ["Net", lkr.format(netTotal)], + ["Tax", lkr.format(taxTotal)], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
Payable
+
{lkr.format(payableTotal)}
+
+
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx new file mode 100644 index 0000000..5e306e3 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -0,0 +1,233 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { PaginationMeta } from "@/types/common" +import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" +import { cn } from "@/lib/utils" + +type StatusFilter = SalesInvoiceStatus | "All" + +const PAGE_SIZE = 10 +const tabs = ["All", "Draft", "Posted", "Cancelled"] as const + +function statusClass(status: SalesInvoiceStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesInvoicesPage() { + const [rows, setRows] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + useEffect(() => { + setError(null) + salesApi + .listInvoices({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRows(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + }, [page, status]) + + const visibleRows = useMemo( + () => + rows?.filter((row) => + `${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + ) ?? [], + [rows, query] + ) + const hasFilters = status !== "All" || query.length > 0 + const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) + const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) + const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + + return ( +
+
+
+

Sales Invoices

+

Invoice register with filters, posting flow, and settlement tracking.

+
+
+ + + + New Invoice + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+ +
+ setSearchInput(e.target.value)} + placeholder="Filter by customer or invoice number" + className="h-12 w-full lg:max-w-sm" + /> + +
+
+ + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No invoices match your filters." : "No invoices yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Invoice + Customer + Date + Due + Lines + Gross + Discount + Net + Status + View + + + + {visibleRows.map((row) => ( + + + + {row.invoiceNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.invoiceDate).toLocaleDateString()} + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + + + {row.status} + + + + + + + + + ))} + +
+
+ +
+
+
+
Gross total
+
{grossTotal.toFixed(2)}
+
+
+
Discount total
+
-{discountTotal.toFixed(2)}
+
+
+
Net total
+
{netTotal.toFixed(2)}
+
+
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/page.tsx b/Frontend/erp-system/app/dashboard/sales/page.tsx new file mode 100644 index 0000000..5d9fcbd --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link" +import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +const sections = [ + { + title: "Invoices", + description: "Create and manage sales invoices.", + href: "/dashboard/sales/invoices", + icon: FileText, + }, + { + title: "Slips", + description: "Counter-style sales documents.", + href: "/dashboard/sales/slips", + icon: ShoppingCart, + }, + { + title: "Free Issues", + description: "Promotional free-issue slips.", + href: "/dashboard/sales/free-issues", + icon: PackageX, + }, + { + title: "Reports", + description: "Sales report catalog and query entry point.", + href: "/dashboard/sales/reports", + icon: FileBarChart, + }, +] + +export default function SalesHubPage() { + return ( +
+
+
+
+ + Sales +
+

Sales

+

+ Invoices, slips, free issues, and reporting in one place. +

+
+
+ +
+ {sections.map((section) => { + const Icon = section.icon + return ( + +
+ +
+

{section.title}

+

{section.description}

+
+ Open +
+ + ) + })} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx b/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx new file mode 100644 index 0000000..3b0677c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/reports/[reportId]/page.tsx @@ -0,0 +1,242 @@ +"use client" + +import { use, useEffect, useRef, useState } from "react" +import Link from "next/link" +import { ArrowLeft, FileBarChart } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { SalesReportDefinition } from "@/types/sales" + +function formatHeader(key: string) { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/_/g, " ") + .trim() +} + +function formatCell(value: unknown) { + if (value === null || value === undefined) return "" + if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 }) + if (typeof value === "string") { + const isoDate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value) + if (isoDate) { + const parsed = new Date(value) + if (!Number.isNaN(parsed.getTime())) { + return new Intl.DateTimeFormat("en-LK", { + year: "numeric", + month: "short", + day: "2-digit", + }).format(parsed) + } + } + return value + } + if (typeof value === "boolean") return value ? "Yes" : "No" + if (Array.isArray(value)) return value.map((item) => formatCell(item)).join(", ") + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +const reportColumns: Record = { + "daily-summary": ["date", "invoiceCount", "slipCount", "invoiceSubtotal", "slipSubtotal", "discountTotal", "freeQtyTotal", "taxTotal", "grandTotal"], + "item-summary": ["itemId", "itemName", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "customer-summary": ["customerId", "customerName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "warehouse-summary": ["warehouseId", "warehouseName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"], + "discount-summary": ["documentType", "documentNo", "documentDate", "customerName", "subtotal", "discountTotal", "taxTotal", "netAmount"], + "free-issue-summary": ["documentType", "documentNo", "documentDate", "customerName", "itemId", "itemName", "freeQty", "freeValue", "warehouseId", "warehouseName"], +} + +export default function SalesReportDetailPage({ params }: { params: Promise<{ reportId: string }> }) { + const resolvedParams = use(params) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [from, setFrom] = useState("") + const [to, setTo] = useState("") + const [queryLoading, setQueryLoading] = useState(false) + const [rows, setRows] = useState(null) + const [queryError, setQueryError] = useState(null) + const lastAutoRunKey = useRef("") + + useEffect(() => { + setError(null) + setLoading(true) + salesApi.getReport(resolvedParams.reportId) + .then(setReport) + .catch((err) => setError(errorMessage(err))) + .finally(() => setLoading(false)) + }, [resolvedParams.reportId]) + + useEffect(() => { + const now = new Date() + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1) + setFrom(firstDay.toISOString().slice(0, 10)) + setTo(now.toISOString().slice(0, 10)) + }, [resolvedParams.reportId]) + + useEffect(() => { + if (!report || !from || !to) return + + const runKey = `${report.id}:${from}:${to}` + if (lastAutoRunKey.current === runKey) return + + lastAutoRunKey.current = runKey + setQueryError(null) + setQueryLoading(true) + salesApi + .queryReport({ + reportType: report.id, + from, + to, + }) + .then((response) => setRows(response.rows)) + .catch((err) => { + setRows(null) + setQueryError(errorMessage(err)) + }) + .finally(() => setQueryLoading(false)) + }, [report, from, to]) + + const runReport = () => { + if (!report) return + if (!from || !to) { + setQueryError("Select both from and to dates.") + setRows(null) + return + } + + lastAutoRunKey.current = `${report.id}:${from}:${to}` + setQueryError(null) + setQueryLoading(true) + salesApi + .queryReport({ + reportType: report.id, + from, + to, + }) + .then((response) => setRows(response.rows)) + .catch((err) => { + setRows(null) + setQueryError(errorMessage(err)) + }) + .finally(() => setQueryLoading(false)) + } + + const columns = report ? (reportColumns[report.id] ?? (rows && rows.length > 0 ? Object.keys(rows[0] as Record) : [])) : [] + + return ( +
+
+ + + +
+

Report Details

+

Metadata for the selected sales report.

+
+
+ + {error &&
{error}
} + + {!error && loading && } + + {!error && !loading && report === null && ( +
+ Report metadata could not be loaded. The report id may be invalid, or your session may have expired. +
+ )} + + {!error && report && ( +
+ + +
+
+ +
+
+ {report.name} +

{report.id}

+
+
+
+ +

{report.description}

+
+
+ + {resolvedParams.reportId !== "daily-summary" && ( + + +
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+
+ +
+
+
+ )} + + {queryError &&
{queryError}
} + + {queryLoading && } + + {!queryLoading && rows && rows.length === 0 && ( +
+ No rows returned for the selected date range. +
+ )} + + {!queryLoading && rows && rows.length > 0 && ( + + + Report Results + + + + + + {columns.map((key) => ( + + ))} + + + + {rows.map((row, index) => { + const record = row as Record + return ( + + {columns.map((key) => ( + + ))} + + ) + })} + +
+ {formatHeader(key)} +
+ {formatCell(record[key])} +
+
+
+ )} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/reports/page.tsx b/Frontend/erp-system/app/dashboard/sales/reports/page.tsx new file mode 100644 index 0000000..3a6797f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/reports/page.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { BarChart3, CalendarRange, FileBarChart, ShoppingCart, type LucideIcon } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { SalesReportDefinition } from "@/types/sales" + +const reportIcons: Record = { + sales: BarChart3, + invoice: FileBarChart, + product: ShoppingCart, + period: CalendarRange, +} + +export default function SalesReportsPage() { + const [reports, setReports] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + setError(null) + salesApi.listReports().then(setReports).catch((err) => setError(errorMessage(err))) + }, []) + + return ( +
+
+

Sales Reports

+

+ Card-based report hub with the same layout and handling style used across stock management. +

+
+ + {error &&
{error}
} + + {!error && reports === null && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!error && reports && reports.length === 0 && ( +
+ +

No sales reports are available.

+
+ )} + + {!error && reports && reports.length > 0 && ( +
+ {reports.map((report) => ( + + + +
+
+ {(() => { + const Icon = reportIcons[report.id.toLowerCase()] ?? FileBarChart + return + })()} +
+ {report.name} +
+
+ +

{report.description}

+
+
+ + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx new file mode 100644 index 0000000..3d7f552 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/[id]/page.tsx @@ -0,0 +1,456 @@ +"use client" + +import { use, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save, Send, X } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { FreeIssuePromotionSuggestions } from "@/components/sales/FreeIssuePromotionSuggestions" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, SalesFreeIssueSuggestion, SalesSlip, SalesSlipPostingCheck } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesSlipLineRequest & { key: string } + +const money = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = use(params) + const slipId = Number(resolvedParams.id) + const [slip, setSlip] = useState(null) + const [etag, setEtag] = useState(null) + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [promotionSuggestion, setPromotionSuggestion] = useState(null) + const [postingCheck, setPostingCheck] = useState(null) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null) + + useEffect(() => { + if (!Number.isFinite(slipId)) { + setError(`Invalid slip id '${resolvedParams.id}'.`) + return + } + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + salesApi.getSlip(slipId), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes, doc]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setSlip(doc.data) + setEtag(doc.etag) + setCustomerId(doc.data.customerId) + setWarehouseId(doc.data.warehouseId) + setCashierUserId(doc.data.cashierUserId) + setPromotionSuggestion(null) + setLines( + doc.data.lines.map((line) => ({ + key: String(line.salesSlipLineId), + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + freeQty: line.freeQty, + unitPrice: line.unitPrice, + allowManualPriceOverride: true, + discountMode: line.discountMode, + discountPct: line.discountPct, + discountAmount: line.discountAmount, + discountValue: 0, + taxPct: line.taxPct, + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId, + })) + ) + }) + .then(async () => { + try { + const suggestions = await salesApi.getFreeIssueSuggestions(slipId) + setPromotionSuggestion(suggestions) + } catch { + setPromotionSuggestion(null) + } + }) + .catch((err) => setError(errorMessage(err))) + }, [resolvedParams.id, slipId]) + + useEffect(() => { + if (!slip || slip.status !== "Draft") { + setPostingCheck(null) + return + } + salesApi + .checkSlipPosting(slipId) + .then((result) => setPostingCheck(result)) + .catch(() => setPostingCheck(null)) + }, [slip, slipId]) + + const subtotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice ?? 0), 0), + [lines] + ) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function selectItem(key: string, itemId: number) { + updateLine(key, { + itemId, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + async function save() { + if (!customerId || !warehouseId || !cashierUserId || !etag) return + setSaving(true) + setError(null) + try { + const payload = { + customerId, + warehouseId, + cashierUserId, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + const updated = await salesApi.updateSlip(slipId, payload, etag) + setSlip(updated.data) + setEtag(updated.etag) + toast.success("Slip saved", updated.data.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function post() { + if (!postingCheck?.canPost) { + setError("Resolve stock shortages before posting this slip.") + return + } + setActionBusy("post") + setError(null) + try { + const posted = await salesApi.postSlip(slipId) + setSlip(posted) + toast.success("Slip posted", posted.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setActionBusy(null) + } + } + + async function cancel() { + setActionBusy("cancel") + setError(null) + try { + const cancelled = await salesApi.cancelSlip(slipId) + setSlip(cancelled) + toast.success("Slip cancelled", cancelled.slipNo) + } catch (err) { + setError(errorMessage(err)) + } finally { + setActionBusy(null) + } + } + + if (error && !slip) return
{error}
+ if (!slip) return
{error ?? "Slip data is loading or unavailable."}
+ + const locked = slip.status !== "Draft" + const canPost = slip.status === "Draft" && (postingCheck?.canPost ?? true) && actionBusy === null + + return ( +
+
+
+ + + +
+

Sales Slip

+

{slip.slipNo} · {slip.status}

+
+
+
+ + + +
+
+ + {error &&
{error}
} + +
+
+
+
+
Sales Slip
+

{slip.slipNo}

+
+ Status: + + {slip.status} + + Date: {new Date(slip.slipDate).toLocaleDateString()} + Cashier: {users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`} +
+
+
+
{customers.find((c) => c.customerId === slip.customerId)?.displayName ?? customers.find((c) => c.customerId === slip.customerId)?.name ?? slip.customerSnapshotName}
+
Warehouse: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}
+
+
+
+ +
+
+
Customer
+
{slip.customerSnapshotName}
+
Customer ID: {slip.customerId}
+
+
+
Warehouse
+
{warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}
+
Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}
+
+
+
Totals
+
+
Subtotal
+
{money.format(slip.totals.subtotal)}
+
Discount
+
{money.format(slip.totals.discountTotal)}
+
Free qty
+
{slip.totals.freeQtyTotal.toFixed(0)}
+
Grand total
+
{money.format(slip.totals.grandTotal)}
+
+
+
+ +
+ + + + Item + UOM + Qty + Free + Unit price + Line total + + + + {slip.lines.map((line) => ( + + +
{line.description}
+
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
+
+ {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {line.qty.toFixed(0)} + {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} + {money.format(line.unitPrice)} + {money.format(line.lineTotal)} +
+ ))} +
+
+
+
+ + {slip.status === "Draft" && postingCheck && !postingCheck.canPost ? ( +
+
Stock shortage detected before posting
+
This slip cannot be posted until every line has enough available stock in the selected warehouse.
+
+ + + + + + + + + + + + {postingCheck.issues.map((issue) => ( + + + + + + + + ))} + +
ItemWarehouseRequestedAvailableShort
+
{issue.itemSku}
+
{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}
+
{issue.warehouseId}{issue.requestedQty.toFixed(0)}{issue.availableQty.toFixed(0)}{issue.shortQty.toFixed(0)}
+
+
+ ) : null} + + {slip.status === "Draft" ? ( + <> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Lines

+ +
+ + + + Item + UOM + Qty + Free + Unit price + + + + + {lines.map((line) => ( + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /> + updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /> + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /> + + + ))} + +
+
Subtotal: {subtotal.toFixed(2)}
+
+ + + +
+ Back + + +
+ + ) : null} +
+ ) +} + diff --git a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx new file mode 100644 index 0000000..b3b3088 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx @@ -0,0 +1,440 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { errorMessage } from "@/lib/error-map" +import { salesApi } from "@/lib/api/sales" +import { customersApi } from "@/lib/api/customers" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { usersApi } from "@/lib/api/users" +import { Customer } from "@/types/customers" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ManagedUser } from "@/types/users" +import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" +import { toast } from "@/components/ui/toast" + +type Line = CreateSalesSlipLineRequest & { key: string } + +const blankLine = (key: string): Line => ({ + key, + itemId: 0, + uomId: 0, + warehouseId: 0, + qty: 1, + freeQty: 0, + unitPrice: null, + allowManualPriceOverride: true, + discountMode: "Percentage", + discountPct: 0, + discountAmount: 0, + discountValue: 0, + taxPct: 0, + isFreeIssue: false, + parentLineId: null, +}) + +const lkr = new Intl.NumberFormat("en-LK", { + style: "currency", + currency: "LKR", + minimumFractionDigits: 2, +}) + +export default function NewSalesSlipPage() { + const router = useRouter() + const [customers, setCustomers] = useState([]) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [users, setUsers] = useState([]) + const [customerId, setCustomerId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [cashierUserId, setCashierUserId] = useState(null) + const [lines, setLines] = useState([blankLine("line-1")]) + const [loading, setLoading] = useState(true) + const [submitError, setSubmitError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + Promise.all([ + customersApi.list({ pageSize: 200 }), + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([cust, itemRes, uomRes, whRes, userRes]) => { + setCustomers(cust.items) + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(whRes.items) + setUsers(userRes.items) + setCustomerId(cust.items[0]?.customerId ?? null) + setWarehouseId(whRes.items[0]?.warehouseId ?? null) + setCashierUserId(userRes.items[0]?.userId ?? null) + setLines([ + { + ...blankLine("line-1"), + itemId: itemRes.items[0]?.itemId ?? 0, + uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, + warehouseId: whRes.items[0]?.warehouseId ?? 0, + unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), + }, + ]) + }) + .catch((err) => setSubmitError(errorMessage(err))) + .finally(() => setLoading(false)) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) + } + + function selectItem(key: string, itemId: number) { + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(key, { + itemId, + uomId: item?.baseUomId ?? 0, + unitPrice: getSuggestedUnitPrice(items, itemId), + }) + } + + function addLine() { + setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) + } + + function removeLine(key: string) { + setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) + } + + const grossTotal = useMemo( + () => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0), + [lines], + ) + const discountTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)) + }, 0), + [lines], + ) + const taxTotal = useMemo( + () => + lines.reduce((sum, line) => { + const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const mode = String(line.discountMode) + const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100) + const taxable = Math.max(0, gross - discount) + return sum + taxable * (Number(line.taxPct || 0) / 100) + }, 0), + [lines], + ) + const netTotal = Math.max(0, grossTotal - discountTotal) + const payableTotal = netTotal + taxTotal + + async function submit() { + if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.") + if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.") + if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.") + if (lines.some((line) => Number(line.uomId) === 0)) return setSubmitError("Select a valid UOM for every line.") + + const payload: CreateSalesSlipRequest = { + customerId, + warehouseId, + cashierUserId, + lines: lines.map((line) => ({ + itemId: Number(line.itemId), + uomId: Number(line.uomId), + warehouseId: Number(line.warehouseId), + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice === null ? null : Number(line.unitPrice), + allowManualPriceOverride: line.allowManualPriceOverride, + discountMode: line.discountMode, + discountPct: Number(line.discountPct), + discountAmount: Number(line.discountAmount), + discountValue: Number(line.discountValue), + taxPct: Number(line.taxPct), + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId || null, + })), + } + + setSaving(true) + setSubmitError(null) + try { + const created = await salesApi.createSlip(payload) + toast.success("Slip created", created.data.slipNo) + router.push(`/dashboard/sales/slips/${created.data.salesSlipId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loading) { + return
Loading masters...
+ } + + return ( +
+
+
+ + + +
+

New sales slip

+

Create counter sales slips from the live backend.

+
+
+
+ + +
+
+ + {submitError ?
{submitError}
: null} + +
+

Slip header

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Draft + + {lkr.format(payableTotal)} +
+
+
+
+ +
+
+

Slip lines

+ +
+
+ + + + # + Item + UOM + Qty + Free + Unit price + Discount % + Line total + + + + + {lines.map((line, idx) => { + const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0) + const lineDiscount = + String(line.discountMode) === "Amount" + ? Number(line.discountAmount || 0) + : lineGross * (Number(line.discountPct || 0) / 100) + const lineNet = Math.max(0, lineGross - lineDiscount) + + return ( + + {idx + 1} + + + + + + + + updateLine(line.key, { qty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { freeQty: Number(e.target.value) })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-9 w-28 text-right font-mono text-sm tabular-nums" + /> + + + updateLine(line.key, { discountPct: Number(e.target.value) || 0 })} + className="h-9 w-24 text-right font-mono text-sm tabular-nums" + /> + + {lkr.format(lineNet)} + + + + + ) + })} + +
+
+
+ +
+
+

Slip notes

+
    +
  • Cashier posting follows the standard sales-slip workflow.
  • +
  • Free issue lines are captured from the slip itself, not from a separate register here.
  • +
  • Use the slip detail page after save to post or cancel.
  • +
+
+ +
+ {[ + ["Gross", lkr.format(grossTotal)], + ["Discount", `-${lkr.format(discountTotal)}`], + ["Net", lkr.format(netTotal)], + ["Tax", lkr.format(taxTotal)], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
Payable
+
{lkr.format(payableTotal)}
+
+
+
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/slips/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx new file mode 100644 index 0000000..98806e3 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/slips/page.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react" + +import { salesApi } from "@/lib/api/sales" +import { errorMessage } from "@/lib/error-map" +import { Button, buttonVariants } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales" + +type StatusFilter = SalesSlipStatus | "All" + +const PAGE_SIZE = 10 +const tabs = ["All", "Draft", "Posted", "Cancelled"] as const + +function statusClass(status: SalesSlipStatus) { + switch (status) { + case "Draft": + return "border-amber-200 bg-amber-50 text-amber-800" + case "Posted": + return "border-emerald-200 bg-emerald-50 text-emerald-800" + case "Cancelled": + return "border-rose-200 bg-rose-50 text-rose-800" + } +} + +export default function SalesSlipsPage() { + const [rows, setRows] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + useEffect(() => { + setError(null) + salesApi + .listSlips({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRows(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + }, [page, status]) + + const visibleRows = useMemo( + () => + rows?.filter((row) => + `${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) + ) ?? [], + [rows, query] + ) + const hasFilters = status !== "All" || query.length > 0 + const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) + const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) + const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) + + return ( +
+
+
+

Sales Slips

+

Counter sales register with posting and cancellation flow.

+
+
+ + + + New Slip + +
+
+ +
+
+
+ {tabs.map((t) => ( + + ))} +
+ +
+ setSearchInput(e.target.value)} + placeholder="Filter by customer or slip number" + className="h-12 w-full lg:max-w-sm" + /> + +
+
+ + {error &&
{error}
} + + {!error && rows === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rows !== null && visibleRows.length === 0 && ( +
+ +

{hasFilters ? "No slips match your filters." : "No slips yet."}

+
+ )} + + {!error && rows !== null && visibleRows.length > 0 && ( + <> +
+ + + + Slip + Customer + Date + Status + Lines + Gross + Discount + Net + Actions + + + + {visibleRows.map((row) => ( + + + + {row.slipNo} + + + {row.customerSnapshotName} + {new Date(row.createdAt).toLocaleDateString()} + + + {row.status} + + + {row.totals.freeQtyTotal.toFixed(2)} + {row.totals.subtotal.toFixed(2)} + -{row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)} + +
+ + + +
+
+
+ ))} +
+
+
+ +
+
+
+
Gross total
+
{grossTotal.toFixed(2)}
+
+
+
Discount total
+
-{discountTotal.toFixed(2)}
+
+
+
Net total
+
{netTotal.toFixed(2)}
+
+
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx new file mode 100644 index 0000000..a149a86 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Building2, Save } from "lucide-react" + +import { companyApi } from "@/lib/api/company" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { CompanyProfile } from "@/types/company" + +import { buttonVariants, Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function CompanyProfilePage() { + const [profile, setProfile] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + companyApi + .getProfile() + .then((res) => { + setProfile(res.data) + setEtag(res.etag) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function patch(key: K, value: CompanyProfile[K]) { + setProfile((prev) => (prev ? { ...prev, [key]: value } : prev)) + } + + async function save() { + if (!profile || !etag) return + setSaving(true) + setError(null) + try { + const updated = await companyApi.updateProfile(profile, etag) + setProfile(updated.data) + setEtag(updated.etag) + toast.success("Company profile saved", updated.data.legalName) + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( +
+
+ + + +
+

Company Profile

+

Invoice header, tax details, logo, and bank information.

+
+
+ + {error &&
{error}
} + {!error && !profile && } + + {!error && profile && ( +
+
+ +

Invoice Header

+
+ +
+ patch("legalName", v)} /> + patch("tradeName", v)} /> + patch("logoUrl", v)} /> + patch("taxRegistrationNo", v)} /> + patch("vatRegistrationNo", v)} /> + patch("phone", v)} /> + patch("email", v)} /> + patch("city", v)} /> + patch("country", v)} /> + patch("addressLine1", v)} /> + patch("addressLine2", v)} /> +
+ +
+ +
+ +

Bank Details

+
+ +
+ patch("bankName", v)} /> + patch("bankBranch", v)} /> + patch("accountName", v)} /> + patch("accountNumber", v)} /> + patch("swiftCode", v)} /> + patch("footerNote", v)} /> +
+ +
+ + Cancel + + +
+
+ )} +
+ ) +} + +function Field({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) { + return ( +
+ + onChange(e.target.value)} /> +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/page.tsx b/Frontend/erp-system/app/dashboard/settings/page.tsx index dbe71d9..70fe245 100644 --- a/Frontend/erp-system/app/dashboard/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { ShieldCheck, SlidersHorizontal, Users } from "lucide-react" +import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react" const cards = [ { @@ -20,6 +20,12 @@ const cards = [ icon: SlidersHorizontal, description: "Configure product and master-data options", }, + { + title: "Company Profile", + href: "/dashboard/settings/company-profile", + icon: Building2, + description: "Invoice header, tax, and bank details", + }, ] export default function SettingsPage() { diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index a0ee55b..b251b5b 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -219,3 +219,34 @@ } } +@media print { + body { + background: white !important; + color: black !important; + } + + .print\:hidden { + display: none !important; + } + + .invoice-sheet { + border: 0 !important; + box-shadow: none !important; + padding: 0 !important; + } + + .invoice-header { + break-inside: avoid; + } + + .invoice-sheet table { + width: 100% !important; + } + + .invoice-sheet tr, + .invoice-sheet td, + .invoice-sheet th { + break-inside: avoid; + } +} + diff --git a/Frontend/erp-system/app/layout.tsx b/Frontend/erp-system/app/layout.tsx index b1c1f57..73ba6c1 100644 --- a/Frontend/erp-system/app/layout.tsx +++ b/Frontend/erp-system/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { ThemeProvider } from "next-themes"; -import { TooltipProvider } from "@/components/ui/tooltip"; +import { Providers } from "@/components/providers"; import "./globals.css"; const geistSans = Geist({ @@ -31,14 +30,7 @@ export default function RootLayout({ suppressHydrationWarning > - - {children} - + {children} ); diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 612976d..345bfa1 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -31,6 +31,8 @@ function LoginForm() { const [showPassword, setShowPassword] = useState(false) const [remember, setRemember] = useState(false) const [submitError, setSubmitError] = useState(null) + const returnTo = searchParams.get("next") + const sessionNotice = returnTo ? "Your session is missing or expired. Sign in again to continue." : null const form = useForm({ resolver: zodResolver(loginSchema), @@ -85,6 +87,11 @@ function LoginForm() {

Sign in to your account

Access your ERP dashboard and manage your business

+ {sessionNotice ? ( +
+ {sessionNotice} +
+ ) : null}
diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 8b52e99..a286926 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -33,6 +33,7 @@ import { PlayCircle, PieChart, Receipt, + ReceiptText, Ruler, Scale, Settings, @@ -99,6 +100,20 @@ const navItems: { { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, ], }, + { + title: "Sales", + code: "sales", + href: "/dashboard/sales", + landingHref: "/dashboard/sales/invoices", + icon: ReceiptText, + chevron: true, + children: [ + { title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, + { title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart }, + { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, + { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, + ], + }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, @@ -386,14 +401,14 @@ export function AppSidebar() { // flashing the full menu to a restricted role. Once resolved, a nav item // is visible if its own code is granted, or (for parents) if any child is. // - // "procurement", "hrm" and "production" are exempted from that check (frontend-only): no + // "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no // role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children // server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted // the permission properly (Settings → Roles → Sidebar permissions) or a backend seed // grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // anything server-side. - const bypassCodes = new Set(["procurement", "hrm", "production"]) + const bypassCodes = new Set(["procurement", "sales", "hrm", "production"]) const visibleItems = loading ? [] : navItems diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index 7e0942d..c8b4046 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -111,6 +111,7 @@ function titleFromPath(pathname: string) { if (pathname === "/dashboard/products/brands") return "Brands" if (pathname === "/dashboard/products/item-types") return "Item Types" if (pathname === "/dashboard/products/settings") return "Product Configuration" + if (pathname === "/dashboard/settings/company-profile") return "Company Profile" if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item" const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard" diff --git a/Frontend/erp-system/components/dashboard/recent-orders-table.tsx b/Frontend/erp-system/components/dashboard/recent-orders-table.tsx index 3bf1829..45c5fe6 100644 --- a/Frontend/erp-system/components/dashboard/recent-orders-table.tsx +++ b/Frontend/erp-system/components/dashboard/recent-orders-table.tsx @@ -29,9 +29,9 @@ const statusStyles: Record = { } // Use a fixed locale to avoid hydration mismatches between server and client -const currency = new Intl.NumberFormat("en-US", { +const currency = new Intl.NumberFormat("en-LK", { style: "currency", - currency: "USD", + currency: "LKR", }) const columns: DataTableColumn[] = [ diff --git a/Frontend/erp-system/components/providers.tsx b/Frontend/erp-system/components/providers.tsx new file mode 100644 index 0000000..e3dd194 --- /dev/null +++ b/Frontend/erp-system/components/providers.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ThemeProvider } from "next-themes" + +import { TooltipProvider } from "@/components/ui/tooltip" + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx b/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx new file mode 100644 index 0000000..03d0387 --- /dev/null +++ b/Frontend/erp-system/components/sales/FreeIssuePromotionSuggestions.tsx @@ -0,0 +1,65 @@ +"use client" + +import Link from "next/link" +import { Lightbulb, PackageCheck } from "lucide-react" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { SalesFreeIssueSuggestion } from "@/types/sales" + +export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: SalesFreeIssueSuggestion | null }) { + if (!suggestion || suggestion.lines.length === 0) { + return ( +
+ No free-issue promotion suggestions were generated for this slip yet. +
+ ) + } + + return ( +
+
+
+
+ + Backend suggestion +
+

Free-issue promotions

+

The server suggests reward quantities and alternate products for this slip.

+
+ + Create free issue + +
+ +
+ {suggestion.lines.map((line) => ( +
+
+
+
{line.itemName}
+
{line.itemSku} • Qty {line.qty}
+
+
+ Buy {line.triggerQty} get {line.suggestedFreeQty} free +
+
+ +
+ {line.rewardOptions.map((option, index) => ( + + {index === 0 ? : null} + {option.name} + + ))} +
+ +
+ Suggested free qty: {line.suggestedFreeQty.toFixed(2)} +
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx b/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx new file mode 100644 index 0000000..2813912 --- /dev/null +++ b/Frontend/erp-system/components/sales/FreeIssueSuggestions.tsx @@ -0,0 +1,42 @@ +"use client" + +import Link from "next/link" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { SalesSlipSummary } from "@/types/sales" + +export function FreeIssueSuggestions({ rows }: { rows: SalesSlipSummary[] }) { + if (rows.length === 0) return null + + return ( +
+
+
+

Free issue suggestions

+

Recent free-issue slips created from the free-issues page.

+
+ + Open free issues + +
+ +
+ {rows.map((row) => ( + +
+
{row.slipNo}
+
{row.status}
+
+
{row.customerSnapshotName}
+
Free qty total: {row.totals.freeQtyTotal.toFixed(2)}
+ + ))} +
+
+ ) +} \ No newline at end of file diff --git a/Frontend/erp-system/lib/api/customers.ts b/Frontend/erp-system/lib/api/customers.ts new file mode 100644 index 0000000..eaeabb9 --- /dev/null +++ b/Frontend/erp-system/lib/api/customers.ts @@ -0,0 +1,15 @@ +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { Customer } from "@/types/customers" + +export interface ListCustomersParams { + page?: number + pageSize?: number + q?: string +} + +export const customersApi = { + list(params: ListCustomersParams = {}): Promise> { + return apiRequest>(`/customers${buildQuery(params)}`) + }, +} diff --git a/Frontend/erp-system/lib/api/sales.ts b/Frontend/erp-system/lib/api/sales.ts new file mode 100644 index 0000000..fa8a9c3 --- /dev/null +++ b/Frontend/erp-system/lib/api/sales.ts @@ -0,0 +1,139 @@ +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, PagedResponse } from "@/types/common" +import { + CreateSalesInvoiceRequest, + CreateSalesSlipRequest, + SalesInvoice, + SalesInvoiceStatus, + SalesInvoicePostingCheck, + SalesInvoiceSummary, + SalesListFilterParams, + SalesReportDefinition, + SalesReportQueryRequest, + SalesReportQueryResponse, + SalesFreeIssueSuggestion, + FreeIssueDetail, + FreeIssueSummary, + SalesSlip, + SalesSlipStatus, + SalesSlipPostingCheck, + SalesSlipSummary, + UpdateSalesInvoiceRequest, + UpdateSalesSlipRequest, +} from "@/types/sales" + +type SalesReportId = string + +export interface ListSalesInvoicesParams extends SalesListFilterParams { + status?: SalesInvoiceStatus +} + +export interface ListSalesSlipsParams extends SalesListFilterParams { + status?: SalesSlipStatus +} + +export interface ListFreeIssuesParams extends SalesListFilterParams { + status?: SalesSlipStatus +} + +export const salesApi = { + listInvoices(params: ListSalesInvoicesParams = {}): Promise> { + return apiRequest>(`/sales-invoices${buildQuery(params)}`) + }, + + getInvoice(salesInvoiceId: number): Promise> { + return apiRequestWithETag(`/sales-invoices/${salesInvoiceId}`) + }, + + createInvoice(request: CreateSalesInvoiceRequest): Promise> { + return apiRequestWithETag("/sales-invoices", { method: "POST", body: request }) + }, + + updateInvoice(salesInvoiceId: number, request: UpdateSalesInvoiceRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/sales-invoices/${salesInvoiceId}`, { method: "PUT", body: request, ifMatch }) + }, + + postInvoice(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/post`, { method: "POST" }) + }, + + checkInvoicePosting(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/posting-check`) + }, + + cancelInvoice(salesInvoiceId: number): Promise { + return apiRequest(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" }) + }, + + listSlips(params: ListSalesSlipsParams = {}): Promise> { + return apiRequest>(`/sales-slips${buildQuery(params)}`) + }, + + getSlip(salesSlipId: number): Promise> { + return apiRequestWithETag(`/sales-slips/${salesSlipId}`) + }, + + createSlip(request: CreateSalesSlipRequest): Promise> { + return apiRequestWithETag("/sales-slips", { method: "POST", body: request }) + }, + + updateSlip(salesSlipId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/sales-slips/${salesSlipId}`, { method: "PUT", body: request, ifMatch }) + }, + + postSlip(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/post`, { method: "POST" }) + }, + + checkSlipPosting(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/posting-check`) + }, + + cancelSlip(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/cancel`, { method: "POST" }) + }, + + listFreeIssues(params: ListFreeIssuesParams = {}): Promise> { + return apiRequest>(`/free-issues${buildQuery(params)}`) + }, + + getFreeIssue(freeIssueId: number): Promise> { + return apiRequestWithETag(`/free-issues/${freeIssueId}`) + }, + + createFreeIssue(request: CreateSalesSlipRequest): Promise> { + return apiRequestWithETag("/free-issues", { method: "POST", body: request }) + }, + + updateFreeIssue(freeIssueId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/free-issues/${freeIssueId}`, { method: "PUT", body: request, ifMatch }) + }, + + postFreeIssue(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/post`, { method: "POST" }) + }, + + checkFreeIssuePosting(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/posting-check`) + }, + + cancelFreeIssue(freeIssueId: number): Promise { + return apiRequest(`/free-issues/${freeIssueId}/cancel`, { method: "POST" }) + }, + + getFreeIssueSuggestions(salesSlipId: number): Promise { + return apiRequest(`/sales-slips/${salesSlipId}/free-issue-suggestions`) + }, + + listReports(): Promise { + return apiRequest("/reports/sales") + }, + + getReport(reportId: SalesReportId): Promise { + return apiRequest(`/reports/sales/${reportId}`) + }, + + queryReport(request: SalesReportQueryRequest): Promise { + return apiRequest("/reports/sales/query", { method: "POST", body: request }) + }, +} diff --git a/Frontend/erp-system/lib/sales-line-utils.ts b/Frontend/erp-system/lib/sales-line-utils.ts new file mode 100644 index 0000000..5037f43 --- /dev/null +++ b/Frontend/erp-system/lib/sales-line-utils.ts @@ -0,0 +1,6 @@ +import { ItemListItem } from "@/types/master-data" + +export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null { + if (!itemId) return null + return items.find((item) => item.itemId === itemId)?.salePrice ?? null +} \ No newline at end of file diff --git a/Frontend/erp-system/types/customers.ts b/Frontend/erp-system/types/customers.ts new file mode 100644 index 0000000..a11afdc --- /dev/null +++ b/Frontend/erp-system/types/customers.ts @@ -0,0 +1,24 @@ +import { EntityStatus } from "@/types/common" + +export type CustomerType = "B2B" | "B2C" + +export interface Customer { + customerId: number + customerCode: string + customerType: CustomerType + name: string + displayName: string | null + phone: string | null + email: string | null + addressLine1: string | null + addressLine2: string | null + city: string | null + country: string | null + taxRegistrationNo: string | null + creditLimit: number + creditDays: number + defaultWarehouseId: number | null + status: EntityStatus + createdAt: string + updatedAt: string | null +} diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts new file mode 100644 index 0000000..b973e39 --- /dev/null +++ b/Frontend/erp-system/types/sales.ts @@ -0,0 +1,314 @@ +import { EntityStatus } from "@/types/common" + +export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit" +export type SalesInvoiceStatus = "Draft" | "Posted" | "Cancelled" +export type SalesSlipStatus = "Draft" | "Posted" | "Cancelled" +export type SalesDiscountMode = "Percentage" | "Amount" + +export interface SalesInvoiceLine { + salesInvoiceLineId: number + itemId: number + description: string + qty: number + freeQty: number + uomId: number + warehouseId: number + unitPrice: number + baseCost: number + priceSource: string + discountPct: number + discountAmount: number + discountMode: SalesDiscountMode + netUnitPrice: number + lineTotal: number + taxPct: number + taxAmount: number + isFreeIssue: boolean + parentLineId: number | null +} + +export interface SalesInvoiceTotals { + subtotal: number + discountTotal: number + freeQtyTotal: number + taxTotal: number + grandTotal: number + roundOff: number + netPayable: number + paidAmount: number + balanceAmount: number +} + +export interface SalesInvoiceSummary { + salesInvoiceId: number + invoiceNo: string + invoiceDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + invoiceType: SalesInvoiceType + status: SalesInvoiceStatus + totals: SalesInvoiceTotals + createdAt: string +} + +export interface SalesInvoicePostingIssue { + salesInvoiceLineId: number + itemId: number + itemSku: string + itemName: string + warehouseId: number + requestedQty: number + availableQty: number + shortQty: number + isFreeIssue: boolean +} + +export interface SalesInvoicePostingCheck { + salesInvoiceId: number + invoiceNo: string + status: SalesInvoiceStatus + canPost: boolean + issues: SalesInvoicePostingIssue[] +} + +export interface SalesSlipPostingIssue { + salesSlipLineId: number + itemId: number + itemSku: string + itemName: string + warehouseId: number + requestedQty: number + availableQty: number + shortQty: number + isFreeIssue: boolean +} + +export interface SalesSlipPostingCheck { + salesSlipId: number + slipNo: string + status: SalesSlipStatus + canPost: boolean + issues: SalesSlipPostingIssue[] +} + +export interface SalesInvoice extends SalesInvoiceSummary { + customerSnapshotTaxNo: string | null + createdBy: number + updatedAt: string | null + lines: SalesInvoiceLine[] +} + +export interface CreateSalesInvoiceLineRequest { + itemId: number + uomId: number + warehouseId: number + qty: number + freeQty: number + unitPrice?: number | null + allowManualPriceOverride: boolean + discountMode: SalesDiscountMode + discountPct: number + discountAmount: number + discountValue: number + taxPct: number + isFreeIssue: boolean + parentLineId?: number | null +} + +export interface CreateSalesInvoiceRequest { + customerId: number + warehouseId: number + invoiceType: SalesInvoiceType + lines: CreateSalesInvoiceLineRequest[] +} + +export type UpdateSalesInvoiceRequest = CreateSalesInvoiceRequest + +export interface SalesSlipLine { + salesSlipLineId: number + itemId: number + description: string + qty: number + freeQty: number + uomId: number + warehouseId: number + unitPrice: number + baseCost: number + priceSource: string + discountPct: number + discountAmount: number + discountMode: SalesDiscountMode + netUnitPrice: number + lineTotal: number + taxPct: number + taxAmount: number + isFreeIssue: boolean + parentLineId: number | null +} + +export interface SalesSlipTotals { + subtotal: number + discountTotal: number + freeQtyTotal: number + taxTotal: number + grandTotal: number + paidAmount: number + balanceAmount: number +} + +export interface SalesSlipSummary { + salesSlipId: number + slipNo: string + slipDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + status: SalesSlipStatus + totals: SalesSlipTotals + createdAt: string +} + +export interface SalesSlip { + salesSlipId: number + slipNo: string + slipDate: string + customerId: number + customerSnapshotName: string + warehouseId: number + cashierUserId: number + status: SalesSlipStatus + createdAt: string + updatedAt: string | null + totals: SalesSlipTotals + lines: SalesSlipLine[] +} + +export interface CreateSalesSlipLineRequest { + itemId: number + uomId: number + warehouseId: number + qty: number + freeQty: number + unitPrice?: number | null + allowManualPriceOverride: boolean + discountMode: SalesDiscountMode + discountPct: number + discountAmount: number + discountValue: number + taxPct: number + isFreeIssue: boolean + parentLineId?: number | null +} + +export interface CreateSalesSlipRequest { + customerId: number + warehouseId: number + cashierUserId: number + lines: CreateSalesSlipLineRequest[] +} + +export type UpdateSalesSlipRequest = CreateSalesSlipRequest + +export interface SalesReportDefinition { + id: string + name: string + description: string + supportedFilters: string[] +} + +export interface SalesReportQueryRequest { + reportType: string + from: string + to: string + itemId?: number | null + customerId?: number | null + warehouseId?: number | null +} + +export interface SalesReportQueryResponse { + reportType: string + from: string + to: string + rows: unknown[] +} + +export interface SalesListFilterParams { + page?: number + pageSize?: number + status?: SalesInvoiceStatus | SalesSlipStatus + customerId?: number + warehouseId?: number +} + +export interface FreeIssueSummaryItem extends SalesSlipSummary { + // Alias for the free issue route. Kept as a slip-shaped payload because the backend + // stores free issues as sales slips with free-issue line flags. + isFreeIssue?: boolean +} + +export interface FreeIssueSummary { + salesSlipId: number + slipNo: string + status: SalesSlipStatus + createdAt: string + warehouseId: number + warehouseName: string + itemId: number + itemSku: string + itemName: string + uomId: number + uomName: string + qty: number + freeQty: number + schemeLabel: string +} + +export interface FreeIssueDetail { + salesSlipId: number + slipNo: string + slipDate: string + status: SalesSlipStatus + customerId: number + customerSnapshotName: string + warehouseId: number + warehouseName: string + cashierUserId: number + createdAt: string + updatedAt: string | null + summary: FreeIssueSummary + lines: SalesSlipLine[] +} + +export interface SalesFreeIssueRewardOption { + itemId: number + sku: string + name: string + salePrice: number | null +} + +export interface SalesFreeIssueSuggestionLine { + salesSlipLineId: number + itemId: number + itemSku: string + itemName: string + qty: number + suggestedFreeQty: number + triggerQty: number + rewardOptions: SalesFreeIssueRewardOption[] +} + +export interface SalesFreeIssueSuggestion { + salesSlipId: number + slipNo: string + slipDate: string + lines: SalesFreeIssueSuggestionLine[] +} + +export interface SalesCustomer { + customerId: number + code: string + name: string + taxNo: string | null + status: EntityStatus +} diff --git a/docs/00-CORE.md b/docs/00-CORE.md index 428709a..0218b00 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -21,6 +21,7 @@ A modular ERP built in phases. **Phase 1** delivers the **Inventory & Supply Cha Full requirements live in the backend spec (see §7). This file does **not** duplicate them. **Phase 2 (HRM)** is now underway alongside Phase 1 — see §7 routing to `12-BACKEND-HRM.md` / `13-BACKEND-HRM-API.md` / `21-FRONTEND-HRM.md`. +Sales has its own API reference at `14-BACKEND-SALES-API.md`. --- @@ -45,6 +46,7 @@ erp-monorepo/ ├── 02-SECURITY.md # accepted-risks register + per-feature security checklist ├── 10-BACKEND-PHASE1.md # backend spec: SRS + ER/entities + tech + architecture ├── 11-BACKEND-PHASE1.md # backend API reference (complete req/res) + ├── 14-BACKEND-SALES-API.md # sales API reference (invoices, slips, free issues, reports) ├── 12-GENERAL-LEDGER-INTEGRATION.md # ERPCore ↔ external General Ledger service (transport only) ├── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture └── 21-GENERAL-LEDGER-FRONTEND.md # Ledgers section: reports UI + cash/bank accounts @@ -344,6 +346,7 @@ All frontend work is governed by `20-FRONTEND.md`. | HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** | | Connecting to the external General Ledger service (proxy, config, API key) | **`12-GENERAL-LEDGER-INTEGRATION.md`** | | The Ledgers frontend section (reports, cash/bank accounts) | **`21-GENERAL-LEDGER-FRONTEND.md`** | +| Sales API endpoints, request/response shapes, error catalog, enums | **`14-BACKEND-SALES-API.md`** | | Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** | | HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** | | Manufacturing requirements, business rules, entities, stock/costing integration, API (Phase 2) | **`30-BACKEND-PHASE2.md`** | @@ -356,6 +359,7 @@ All frontend work is governed by `20-FRONTEND.md`. Quick resolver: - *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (Phase 1) / `12-BACKEND-HRM.md` (HRM) — schema is authoritative there. - *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md` (Phase 1) / `13-BACKEND-HRM-API.md` (HRM). +- *"What does the sales API accept/return?"* → `14-BACKEND-SALES-API.md`. - *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md` (Phase 1) / `21-FRONTEND-HRM.md` (HRM). - *"What security risks / checks apply to this feature?"* → `02-SECURITY.md`. diff --git a/docs/01-DOC-GUIDE.md b/docs/01-DOC-GUIDE.md index ac7e22f..3545ee6 100644 --- a/docs/01-DOC-GUIDE.md +++ b/docs/01-DOC-GUIDE.md @@ -39,6 +39,7 @@ | `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans | | `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans | | `12-GENERAL-LEDGER-INTEGRATION.md` | High | ERPCore ↔ external **General Ledger service**: connection/proxy contract, config, progress. GL's own endpoint contract lives in the GL service's own repo, not here. | Any work touching the GL proxy or a future internal GL caller. | Claude + humans | +| `14-BACKEND-SALES-API.md` | High | Sales **API reference**: invoices, slips, free issues, reports, enums. | Any sales API contract / controller work. | Claude + humans | | `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans | | `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans | | `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans | @@ -197,7 +198,7 @@ Spec: docs/20-FRONTEND.md (flows + rules) · docs/11-BACKEND-PHASE1.md (API cont ## 7. Adding future docs When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting), follow the same scheme HRM (Phase 2) established: -- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`, mirroring the `10`/`11` SRS+ER / API-reference split), linked from the hub. +- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`; sales now uses `14-BACKEND-SALES-API.md` alongside the existing phase docs), linked from the hub. - Frontend additions → extend `20-FRONTEND.md` or add `2x-` files (e.g. HRM used `21-FRONTEND-HRM.md`). - Always register the new doc in `00-CORE.md` routing (§7) and in this index (§2). diff --git a/docs/14-BACKEND-SALES-API.md b/docs/14-BACKEND-SALES-API.md new file mode 100644 index 0000000..a683434 --- /dev/null +++ b/docs/14-BACKEND-SALES-API.md @@ -0,0 +1,224 @@ +# 14 · BACKEND — Sales API Reference + +> **Authoritative for:** sales API contracts for invoices, slips, free issues, and sales reports. +> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`. +> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table. + +--- + +## 1. Conventions + +- Base URL: `https://{host}/api/v1` +- Authentication: same as the rest of the v1 API +- Concurrency: `ETag` / `If-Match` on mutable resources +- Validation: `ProblemDetails` / `ValidationProblemDetails` + +--- + +## 2. Sales Invoices + +### `GET /api/v1/sales-invoices` +Query: +- `page` +- `pageSize` +- `q` +- `status` +- `customerId` +- `warehouseId` + +Returns a paged list of `SalesInvoiceSummaryDto`. +The current frontend register uses this endpoint directly for the invoice hub view. + +### `GET /api/v1/sales-invoices/{salesInvoiceId}` +Returns `SalesInvoiceDto`. +The detail page shows the document template and only exposes edit actions when the invoice is `Draft`. + +### `POST /api/v1/sales-invoices` +Creates a draft sales invoice. + +Request: +```json +{ + "customerId": 2, + "warehouseId": 1, + "invoiceType": "B2C", + "lines": [ + { + "itemId": 1, + "uomId": 1, + "warehouseId": 1, + "qty": 1, + "freeQty": 0, + "unitPrice": 100, + "allowManualPriceOverride": false, + "discountMode": "Percentage", + "discountPct": 0, + "discountAmount": 0, + "discountValue": 0, + "taxPct": 0, + "isFreeIssue": false, + "parentLineId": null + } + ] +} +``` + +### `PUT /api/v1/sales-invoices/{salesInvoiceId}` +Updates a draft invoice. Requires `If-Match`. + +### `POST /api/v1/sales-invoices/{salesInvoiceId}/post` +Posts stock out and marks the invoice as `Posted`. +Before posting, the UI calls `GET /api/v1/sales-invoices/{salesInvoiceId}/posting-check` to show shortages. + +### `POST /api/v1/sales-invoices/{salesInvoiceId}/cancel` +Cancels a draft invoice. + +--- + +## 3. Sales Slips + +### `GET /api/v1/sales-slips` +Query: +- `page` +- `pageSize` +- `q` +- `status` +- `customerId` +- `warehouseId` + +Returns a paged list of `SalesSlipSummaryDto`. + +### `GET /api/v1/sales-slips/{salesSlipId}` +Returns `SalesSlipDto`. +The detail page follows the same document-style layout and draft-only edit behavior as invoices. + +### `POST /api/v1/sales-slips` +Creates a draft sales slip. + +Request: +```json +{ + "customerId": 2, + "warehouseId": 1, + "cashierUserId": 1, + "lines": [ + { + "itemId": 1, + "uomId": 1, + "warehouseId": 1, + "qty": 1, + "freeQty": 0, + "unitPrice": 50, + "allowManualPriceOverride": false, + "discountMode": "Percentage", + "discountPct": 0, + "discountAmount": 0, + "discountValue": 0, + "taxPct": 0, + "isFreeIssue": false, + "parentLineId": null + } + ] +} +``` + +### `PUT /api/v1/sales-slips/{salesSlipId}` +Updates a draft slip. Requires `If-Match`. + +### `POST /api/v1/sales-slips/{salesSlipId}/post` +Posts stock out and marks the slip as `Posted`. +Before posting, the UI calls `GET /api/v1/sales-slips/{salesSlipId}/posting-check`. + +### `POST /api/v1/sales-slips/{salesSlipId}/cancel` +Cancels a draft slip. + +--- + +## 4. Free Issues + +Free issue is a business alias over sales slips. +There is no separate free-issue table in the current schema. + +### `GET /api/v1/free-issues` +Same contract as `GET /api/v1/sales-slips`. + +### `GET /api/v1/free-issues/{freeIssueId}` +Same contract as `GET /api/v1/sales-slips/{salesSlipId}`. + +### `POST /api/v1/free-issues` +Same contract as `POST /api/v1/sales-slips`. + +### `PUT /api/v1/free-issues/{freeIssueId}` +Same contract as `PUT /api/v1/sales-slips/{salesSlipId}`. + +### `POST /api/v1/free-issues/{freeIssueId}/post` +Same contract as `POST /api/v1/sales-slips/{salesSlipId}/post`. + +### `POST /api/v1/free-issues/{freeIssueId}/cancel` +Same contract as `POST /api/v1/sales-slips/{salesSlipId}/cancel`. + +Business rule: +- free issue is represented by `IsFreeIssue=true` and/or `FreeQty>0` +- it still posts stock out through the normal slip posting flow +- the frontend free-issue screen is the main working page for create/list/edit/cancel +- cancelled free-issue records are hidden from the working list view + +--- + +## 5. Sales Reports + +### `GET /api/v1/reports/sales` +Returns the report catalog. + +Response: +```json +[ + { + "id": "daily-summary", + "name": "Daily Summary", + "description": "Aggregated sales by day across posted invoices and slips.", + "supportedFilters": ["from", "to"] + } +] +``` + +### `GET /api/v1/reports/sales/{reportId}` +Returns a single report definition by id. + +### `POST /api/v1/reports/sales/query` +Returns the actual report data. + +Request: +```json +{ + "reportType": "item-summary", + "from": "2026-07-01", + "to": "2026-07-28", + "itemId": 1, + "customerId": null, + "warehouseId": 1 +} +``` + +Supported report types: +- `daily-summary` +- `item-summary` +- `customer-summary` +- `warehouse-summary` +- `discount-summary` +- `free-issue-summary` + +Validation rule: +- invalid filters for a given report type are rejected +- unsupported report types are rejected +- the frontend report page uses the same endpoint for all report types +- `daily-summary` auto-loads with a default date range in the UI +- the report results table is rendered from the backend response rows, not hardcoded data + +--- + +## 6. Notes + +- The sales report service reads both invoices and slips where relevant. +- Free issue reporting is derived from the same sales document lines. +- There is no separate free-issue table in the current schema. diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md index efb4bea..fc2038d 100644 --- a/docs/SALES_MODULE_PLAN.md +++ b/docs/SALES_MODULE_PLAN.md @@ -229,13 +229,15 @@ When an invoice or slip is posted: - `POST /api/v1/sales-slips` - `POST /api/v1/sales-slips/{id}/post` - `POST /api/v1/sales-slips/{id}/cancel` -- `GET /api/v1/sales-reports/daily-summary` -- `GET /api/v1/sales-reports/item-wise` -- `GET /api/v1/sales-reports/customer-wise` -- `GET /api/v1/sales-reports/warehouse-wise` -- `GET /api/v1/sales-reports/discount-summary` -- `GET /api/v1/sales-reports/free-issue-summary` -- `GET /api/v1/sales-reports/margin-summary` +- `GET /api/v1/free-issues` +- `GET /api/v1/free-issues/{id}` +- `POST /api/v1/free-issues` +- `PUT /api/v1/free-issues/{id}` +- `POST /api/v1/free-issues/{id}/post` +- `POST /api/v1/free-issues/{id}/cancel` +- `GET /api/v1/reports/sales` +- `GET /api/v1/reports/sales/{reportId}` +- `POST /api/v1/reports/sales/query` ### Phase 1 Folder / Module Plan - `Domain/Entities` @@ -264,7 +266,7 @@ When an invoice or slip is posted: 4. Pricing resolver 5. Discount computation 6. Free issue handling -7. Stock posting and ledger integration +7. Stock posting 8. Basic sales reports 9. Controllers and swagger wiring