Compare commits

...

10 Commits

Author SHA1 Message Date
Sasanka bebec10eb3 feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices.
- Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout.
- Added invoice layout presets for different styles (Classic, Compact, Modern).
- Developed PurchaseInvoicePaymentDialog for handling payments against invoices.
- Introduced API mocks for company profile and invoice template configuration.
- Added validation functions for company profile and purchase invoices.
- Defined types for company profile, invoice template configuration, and purchase invoices.
2026-09-01 15:25:32 +05:30
ashan_rusiru 160c687db3 Merge pull request 'notification module' (#51) from feat/notification-module into Dev
Reviewed-on: #51
2026-08-21 06:40:40 +00:00
Dhananjaya99 d413ad4064 notification module 2026-08-21 12:05:47 +05:30
ImanThiyanga 9b8c2493ad Merge pull request 'feat: add production navigation permissions and produced items page' (#50) from fix/production-costing into Dev
Reviewed-on: #50
2026-08-21 06:32:26 +00:00
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
Dhananjaya99 4cc6a546c7 add day-end and change item model 2026-08-20 14:10:39 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
155 changed files with 46740 additions and 2545 deletions
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
Assert.Equal(7u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -0,0 +1,17 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
@@ -29,6 +29,14 @@ public sealed class BundleSalesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
+1 -22
View File
@@ -11,12 +11,10 @@ namespace ERPCore.Controllers;
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
private readonly IGrnPaymentService _payments;
public GrnsController(IGrnService grns, IGrnPaymentService payments)
public GrnsController(IGrnService grns)
{
_grns = grns;
_payments = payments;
}
/// <summary>List GRNs, newest first.</summary>
@@ -64,23 +62,4 @@ public sealed class GrnsController : ApiControllerBase
public async Task<ActionResult<ReleaseLineResultDto>> Release(
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{grnId:int}/payments")]
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(grnId, request, ct);
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
}
/// <summary>Payment history for this GRN, newest first.</summary>
[HttpGet("{grnId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
=> Ok(await _payments.ListAsync(grnId, ct));
}
@@ -0,0 +1,34 @@
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
public NotificationConfigController(INotificationConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
/// something, plus a summary total over every filtered row.</summary>
[HttpGet("produced")]
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
[FromQuery] ProductionRunStatus? status,
CancellationToken ct)
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -0,0 +1,102 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
+2
View File
@@ -10,6 +10,7 @@ public static class DocumentTypes
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
@@ -20,6 +21,7 @@ public static class DocumentTypes
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
+5 -5
View File
@@ -34,14 +34,14 @@ public class Grn
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
public decimal PaidAmount { get; set; }
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
public decimal BalanceAmount { get; set; }
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
/// <see cref="PurchaseInvoice"/>.</summary>
public int? PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
}
+5 -4
View File
@@ -44,11 +44,12 @@ public class Item
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// Optional selling price range and flat price used by Sales only.
/// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
/// </summary>
public decimal? SalePrice { get; set; }
public decimal? SellingMinPrice { get; set; }
public decimal? SellingMaxPrice { get; set; }
public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Notification configuration (new settings module) - singleton root row.
/// </summary>
public class NotificationConfig
{
public const int SingletonId = 1;
public int NotificationConfigId { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
public class NotificationConfigModule
{
public int NotificationConfigModuleId { get; set; }
public int NotificationConfigId { get; set; }
public NotificationConfig? NotificationConfig { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public bool EmailEnabled { get; set; }
public bool SmsEnabled { get; set; }
public bool InAppEnabled { get; set; }
}
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
/// on a write to reject; the frontend honours it by hiding the variant builder.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// on item writes and drive the create/edit screens. Reads are never gated, so existing
/// data stays visible after a flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public bool SellingPricesEnabled { get; set; } = true;
public bool SerialNumbersEnabled { get; set; } = true;
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,50 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
/// are then recorded against this aggregate rather than against the individual GRNs.
/// </summary>
public class PurchaseInvoice
{
public int PurchaseInvoiceId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string VendorInvoiceNo { get; set; } = string.Empty;
public DateTime VendorInvoiceDate { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string VendorSnapshotName { get; set; } = string.Empty;
public string? VendorSnapshotTaxNo { get; set; }
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
/// PurchaseInvoiceService.PostAsync.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
/// Invoice consolidates GRNs, it does not re-enter item lines).
/// </summary>
public class PurchaseInvoiceLine
{
public int PurchaseInvoiceLineId { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal LineTotal { get; set; }
}
@@ -1,16 +1,17 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
/// A vendor payment made against a Posted purchase invoice's balance (installments
/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
/// before being recorded here.
/// </summary>
public class GrnPayment
public class PurchaseInvoicePayment
{
public int GrnPaymentId { get; set; }
public int PurchaseInvoicePaymentId { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
@@ -4,7 +4,7 @@ namespace ERPCore.Domain.Entities;
/// A customer payment made against a Posted sales invoice's balance (installments
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
/// being recorded here — the mirror image of <see cref="PurchaseInvoicePayment"/>.
/// </summary>
public class SalesInvoicePayment
{
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum PurchaseInvoiceStatus
{
Draft,
Posted,
Cancelled
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain.Notifications.Modules;
[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
public sealed class SaleCreateNotificationModule;
[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
public sealed class SalePostedNotificationModule;
[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
public sealed class GrnCreatedNotificationModule;
[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
public sealed class GrnConfirmedNotificationModule;
[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
public sealed class PurchaseOrderCreatedNotificationModule;
[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
public sealed class PurchaseOrderApprovedNotificationModule;
[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
public sealed class PurchaseReturnCreatedNotificationModule;
[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule;
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule;
[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
public sealed class ItemUpdatedNotificationModule;
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Notifications;
public enum NotificationChannel
{
Email,
Sms,
InApp
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Notifications;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class NotificationModuleAttribute : Attribute
{
public string Key { get; }
public string Label { get; }
public string Description { get; }
public int SortOrder { get; }
public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
{
Key = key;
Label = label;
Description = description;
SortOrder = sortOrder;
}
}
@@ -0,0 +1,7 @@
namespace ERPCore.Domain.Notifications;
public sealed record NotificationModuleDefinition(
string Key,
string Label,
string Description,
int SortOrder);
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) - see the entity docs.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// when items are written.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// <c>false</c> and silently switch everything off. Nullable makes the requirement actually
/// bind - an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
[Required] public bool? SellingPricesEnabled { get; set; }
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
}
+7 -13
View File
@@ -17,34 +17,28 @@ public sealed record GrnLineDto(
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
decimal PaidAmount, decimal BalanceAmount);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
public sealed record GrnPaymentDto(
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreateGrnPaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
/// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
public sealed record GrnEligibleForInvoiceDto(
int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
// Requests ----------------------------------------------------------------------
+8 -4
View File
@@ -10,7 +10,7 @@ public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
@@ -31,7 +31,7 @@ public sealed record ItemDetailDto(
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
@@ -66,7 +66,9 @@ public sealed class CreateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -96,7 +98,9 @@ public sealed class UpdateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Notifications;
public sealed record NotificationModuleConfigDto(
string ModuleKey,
string Label,
string Description,
bool EmailEnabled,
bool SmsEnabled,
bool InAppEnabled);
public sealed record NotificationConfigDto(
IReadOnlyList<NotificationModuleConfigDto> Modules,
DateTime? UpdatedAt,
int? UpdatedBy);
public sealed class UpdateNotificationModuleRequest
{
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? InAppEnabled { get; set; }
}
public sealed class UpdateNotificationConfigRequest
{
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
}
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
public sealed record PurchaseInvoiceLineDto(
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
decimal Subtotal, decimal VatTotal, decimal LineTotal);
public sealed record PurchaseInvoiceTotalsDto(
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
public sealed record PurchaseInvoiceDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
string? GlJournalNo, DateTime? GlPostedAt,
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
public sealed record PurchaseInvoiceSummaryDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record PurchaseInvoicePaymentDto(
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreatePurchaseInvoicePaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
public sealed class CreatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
public sealed class UpdatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
+40 -2
View File
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
/// terminal output of a run that has produced something, costed the same way
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
/// one, so run and produced line coincide 1:1.
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
/// </summary>
public sealed record ProducedLineDto(
int RunId, string DocNo, ProductionRunStatus Status,
int TemplateId, string TemplateName,
int WarehouseId, string WarehouseName,
int ItemId, string ItemName,
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
decimal UnitCost, decimal TotalCost,
DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
/// matched, not just the current page (there is no pagination here, matching the Sales report
/// convention of flat, filter-bounded rows).</summary>
public sealed record ProducedSummaryDto(
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -51,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
+13 -2
View File
@@ -8,11 +8,21 @@ namespace ERPCore.Dtos.Sales;
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
/// <summary>Read-only visibility into that cashier's Sales Invoices for the date — informational
/// only, appended to <see cref="SalesDayEndDto"/> and <see cref="SalesDayEndPreviewDto"/>.
/// Invoices post their own GL entry on Confirm (SalesPostingService.PostInvoiceAsync) and have no
/// DayEndId, so they're never locked against a close: these fields are computed live from current
/// data on every read, unlike the frozen Slip/Bundle totals alongside them.</summary>
public sealed record SalesDayEndInvoiceSummaryDto(
int InvoiceCount, decimal InvoiceSubtotal, decimal InvoiceDiscountTotal, decimal InvoiceTaxTotal,
decimal InvoiceGrandTotal, IReadOnlyList<string> InvoiceNumbers);
public sealed record SalesDayEndDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown);
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record SalesDayEndSummaryDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
@@ -25,7 +35,8 @@ public sealed record SalesDayEndPreviewDto(
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose);
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
@@ -4,7 +4,7 @@ public sealed record SalesFreeIssueRewardOptionDto(
int ItemId,
string Sku,
string Name,
decimal? SalePrice);
decimal? SellingFlatPrice);
public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId,
@@ -17,41 +17,19 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
}
}
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
{
public void Configure(EntityTypeBuilder<GrnPayment> builder)
{
builder.ToTable("grn_payments");
builder.HasKey(p => p.GrnPaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.GrnId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{
public void Configure(EntityTypeBuilder<GrnLine> builder)
@@ -20,7 +20,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingMaxPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingFlatPrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
);
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<NotificationConfig>
{
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
{
builder.ToTable("notification_config", t =>
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
builder.HasKey(c => c.NotificationConfigId);
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasMany(c => c.Modules)
.WithOne(m => m.NotificationConfig)
.HasForeignKey(m => m.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration<NotificationConfigModule>
{
public void Configure(EntityTypeBuilder<NotificationConfigModule> builder)
{
builder.ToTable("notification_config_modules");
builder.HasKey(m => m.NotificationConfigModuleId);
builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
builder.HasIndex(m => m.ModuleKey).IsUnique();
builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
}
}
@@ -61,7 +61,12 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 27 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }
);
}
}
@@ -25,6 +25,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.SellingPricesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<PurchaseInvoice>
{
public void Configure(EntityTypeBuilder<PurchaseInvoice> builder)
{
builder.ToTable("purchase_invoices");
builder.HasKey(x => x.PurchaseInvoiceId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.VendorInvoiceNo).IsRequired().HasMaxLength(50);
builder.Property(x => x.VendorInvoiceDate).IsRequired();
// A vendor invoice number is only meaningful per-vendor; the service also excludes
// Cancelled invoices from the duplicate check, so this composite index is a coarse
// backstop (it does not distinguish Cancelled rows) rather than the sole enforcement.
builder.HasIndex(x => new { x.VendorId, x.VendorInvoiceNo });
builder.HasOne(x => x.Vendor)
.WithMany()
.HasForeignKey(x => x.VendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.VendorSnapshotName).IsRequired().HasMaxLength(200);
builder.Property(x => x.VendorSnapshotTaxNo).HasMaxLength(50);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(PurchaseInvoiceStatus.Draft);
foreach (var p in new[] { nameof(PurchaseInvoice.Subtotal), nameof(PurchaseInvoice.VatTotal), nameof(PurchaseInvoice.GrandTotal), nameof(PurchaseInvoice.PaidAmount), nameof(PurchaseInvoice.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.VendorId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.PurchaseInvoice)
.HasForeignKey(x => x.PurchaseInvoiceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class PurchaseInvoiceLineConfiguration : IEntityTypeConfiguration<PurchaseInvoiceLine>
{
public void Configure(EntityTypeBuilder<PurchaseInvoiceLine> builder)
{
builder.ToTable("purchase_invoice_lines");
builder.HasKey(x => x.PurchaseInvoiceLineId);
builder.Property(x => x.Subtotal).HasPrecision(18, 4);
builder.Property(x => x.VatTotal).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.HasOne(x => x.Grn)
.WithMany()
.HasForeignKey(x => x.GrnId)
.OnDelete(DeleteBehavior.Restrict);
// A GRN can be consolidated onto at most one active line at a time — cancelling the
// invoice removes its lines and frees the GRN (PurchaseInvoiceService.CancelAsync), so
// this stays enforceable rather than blocking a later re-invoice of the same GRN.
builder.HasIndex(x => x.GrnId).IsUnique();
}
}
public sealed class PurchaseInvoicePaymentConfiguration : IEntityTypeConfiguration<PurchaseInvoicePayment>
{
public void Configure(EntityTypeBuilder<PurchaseInvoicePayment> builder)
{
builder.ToTable("purchase_invoice_payments");
builder.HasKey(p => p.PurchaseInvoicePaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.PurchaseInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.PurchaseInvoiceId);
}
}
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
@@ -41,6 +42,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
@@ -53,7 +55,15 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// doesn't silently lose it just because the section it lives under changed.
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
// notification-config item and silently overwrites this one. See migration
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
);
}
}
+43 -27
View File
@@ -43,8 +43,9 @@ public static class DataSeeder
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
@@ -100,7 +101,22 @@ public static class DataSeeder
ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true,
BrandsEnabled = true,
ItemTypesEnabled = true
ItemTypesEnabled = true,
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
});
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
db.NotificationConfig.Add(new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId
});
return true;
}
@@ -182,7 +198,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m,
QtyRemaining = 100m,
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7)
});
db.StockLayers.Add(new StockLayer
@@ -191,7 +207,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m,
QtyRemaining = 5m,
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6)
});
return true;
@@ -278,7 +294,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 100m,
SellingFlatPrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
@@ -292,7 +308,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 50m,
SellingFlatPrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
@@ -445,7 +461,7 @@ public static class DataSeeder
ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
@@ -454,7 +470,7 @@ public static class DataSeeder
ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
@@ -478,10 +494,10 @@ public static class DataSeeder
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m,
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
@@ -493,8 +509,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -504,8 +520,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -523,12 +539,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
@@ -539,8 +555,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -550,8 +566,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -569,12 +585,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
@@ -585,8 +601,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
@@ -63,7 +66,11 @@ public class ErpDbContext : DbContext
// --- Goods Receipt (docs/10 Part C.3) ---
public DbSet<Grn> Grns => Set<Grn>();
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
// --- Purchase Invoices (vendor invoices consolidating Confirmed GRNs) ---
public DbSet<PurchaseInvoice> PurchaseInvoices => Set<PurchaseInvoice>();
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductionNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 35, "NAV:production", 14, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 36, "NAV:production.templates", null, 24 },
{ 37, "NAV:production.runs", null, 25 },
{ 38, "NAV:production.produced", null, 26 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 38);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 14);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class itemModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingMinPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingFlatPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingFlatPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingMinPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddNotificationConfigModulePreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_config",
columns: table => new
{
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
table.ForeignKey(
name: "FK_notification_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "notification_config_modules",
columns: table => new
{
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
table.ForeignKey(
name: "FK_notification_config_modules_notification_config_Notificatio~",
column: x => x.NotificationConfigId,
principalTable: "notification_config",
principalColumn: "NotificationConfigId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
ON CONFLICT ("SubNavItemId") DO UPDATE
SET "NavItemId" = EXCLUDED."NavItemId",
"Code" = EXCLUDED."Code",
"Href" = EXCLUDED."Href",
"Icon" = EXCLUDED."Icon",
"Label" = EXCLUDED."Label",
"SortOrder" = EXCLUDED."SortOrder";
""");
migrationBuilder.CreateIndex(
name: "IX_notification_config_UpdatedBy",
table: "notification_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_ModuleKey",
table: "notification_config_modules",
column: "ModuleKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_NotificationConfigId",
table: "notification_config_modules",
column: "NotificationConfigId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "notification_config_modules");
migrationBuilder.DropTable(
name: "notification_config");
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
public partial class UpdateItemSellingPrices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingFlatPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingMinPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingMinPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingFlatPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigFeatureFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BatchListsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SellingPricesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SerialNumbersEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "WarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchListsEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SellingPricesEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SerialNumbersEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "WarrantyPeriodsEnabled",
table: "product_config");
}
}
}
@@ -0,0 +1,24 @@
// <auto-generated />
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
[DbContext(typeof(ErpDbContext))]
[Migration("20260825000000_FixProductionTemplatesSubNavItemIdConflict")]
partial class FixProductionTemplatesSubNavItemIdConflict
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
// This migration only repairs seed row data via raw SQL — no entity/column shape
// changes — so it does not redeclare the full model here. The single source of
// truth for scaffolding future migrations is ErpDbContextModelSnapshot.cs, which
// already reflects this fix (production.templates seeded at SubNavItemId 27).
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <summary>
/// Repairs a collision baked into migration history: 20260818090139_AddProductionNavPermissions
/// inserted "production.templates" as sub_nav_items row 24, then
/// 20260820100345_AddNotificationConfigModulePreferences reused the same literal id 24 for
/// "settings.notification-config" via a raw-SQL <c>INSERT ... ON CONFLICT ("SubNavItemId") DO
/// UPDATE</c>, silently overwriting the production.templates row instead of inserting a new
/// one. Row 24 now correctly holds notification-config; this migration re-creates the lost
/// production.templates row under a fresh id (27, matching the corrected seed data in
/// SubNavItemConfiguration/PermissionConfiguration) and repoints the NAV:production.templates
/// permission (id 36) at it.
/// </summary>
public partial class FixProductionTemplatesSubNavItemIdConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (27, 14, 'production.templates', '/dashboard/production/templates', NULL, 'Templates', 1)
ON CONFLICT ("SubNavItemId") DO NOTHING;
""");
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 27 WHERE "PermissionId" = 36;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE permissions SET "SubNavItemId" = 24 WHERE "PermissionId" = 36;
""");
migrationBuilder.Sql("""
DELETE FROM sub_nav_items WHERE "SubNavItemId" = 27;
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_payments");
migrationBuilder.DropColumn(
name: "BalanceAmount",
table: "grns");
migrationBuilder.DropColumn(
name: "PaidAmount",
table: "grns");
migrationBuilder.AddColumn<int>(
name: "PurchaseInvoiceId",
table: "grns",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "purchase_invoices",
columns: table => new
{
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorInvoiceNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
VendorInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
VendorId = table.Column<int>(type: "integer", nullable: false),
VendorSnapshotName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
VendorSnapshotTaxNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaidAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BalanceAmount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoices", x => x.PurchaseInvoiceId);
table.ForeignKey(
name: "FK_purchase_invoices_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoices_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_lines",
columns: table => new
{
PurchaseInvoiceLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
VatTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_lines", x => x.PurchaseInvoiceLineId);
table.ForeignKey(
name: "FK_purchase_invoice_lines_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_lines_purchase_invoices_PurchaseInvoiceId",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "purchase_invoice_payments",
columns: table => new
{
PurchaseInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PurchaseInvoiceId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_invoice_payments", x => x.PurchaseInvoicePaymentId);
table.ForeignKey(
name: "FK_purchase_invoice_payments_purchase_invoices_PurchaseInvoice~",
column: x => x.PurchaseInvoiceId,
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 28, "procurement.purchase-invoices", "/dashboard/procurement/purchase-invoices", null, "Purchase Invoices", 4, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 39, "NAV:procurement.purchase-invoices", null, 28 });
migrationBuilder.CreateIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_GrnId",
table: "purchase_invoice_lines",
column: "GrnId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_lines_PurchaseInvoiceId",
table: "purchase_invoice_lines",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_CreatedBy",
table: "purchase_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoice_payments_PurchaseInvoiceId",
table: "purchase_invoice_payments",
column: "PurchaseInvoiceId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_CreatedBy",
table: "purchase_invoices",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_DocNo",
table: "purchase_invoices",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_Status",
table: "purchase_invoices",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId",
table: "purchase_invoices",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_purchase_invoices_VendorId_VendorInvoiceNo",
table: "purchase_invoices",
columns: new[] { "VendorId", "VendorInvoiceNo" });
migrationBuilder.AddForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns",
column: "PurchaseInvoiceId",
principalTable: "purchase_invoices",
principalColumn: "PurchaseInvoiceId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_purchase_invoices_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DropTable(
name: "purchase_invoice_lines");
migrationBuilder.DropTable(
name: "purchase_invoice_payments");
migrationBuilder.DropTable(
name: "purchase_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_PurchaseInvoiceId",
table: "grns");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 39);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 28);
migrationBuilder.DropColumn(
name: "PurchaseInvoiceId",
table: "grns");
migrationBuilder.AddColumn<decimal>(
name: "BalanceAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PaidAmount",
table: "grns",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "grn_payments",
columns: table => new
{
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
GrnId = table.Column<int>(type: "integer", nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
table.ForeignKey(
name: "FK_grn_payments_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_grn_payments_CreatedBy",
table: "grn_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grn_payments_GrnId",
table: "grn_payments",
column: "GrnId");
}
}
}
@@ -1411,10 +1411,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1433,16 +1429,15 @@ namespace ERPCore.Migrations
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int?>("PoId")
.HasColumnType("integer");
b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -1469,6 +1464,8 @@ namespace ERPCore.Migrations
b.HasIndex("PoId");
b.HasIndex("PurchaseInvoiceId");
b.HasIndex("Status");
b.HasIndex("VendorId");
@@ -1584,55 +1581,6 @@ namespace ERPCore.Migrations
b.ToTable("grn_line_warranty_numbers", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.Property<int>("GrnPaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("GrnPaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("GrnId");
b.ToTable("grn_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
{
b.Property<int>("HrDocumentTypeId")
@@ -1745,7 +1693,15 @@ namespace ERPCore.Migrations
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<decimal?>("SalePrice")
b.Property<decimal?>("SellingFlatPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal?>("SellingMaxPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal?>("SellingMinPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -2334,9 +2290,86 @@ namespace ERPCore.Migrations
Label = "Sales",
SortOrder = 13,
Status = "Active"
},
new
{
NavItemId = 14,
Code = "production",
Href = "/dashboard/production",
Label = "Production",
SortOrder = 14,
Status = "Active"
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.Property<int>("NotificationConfigId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("NotificationConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("notification_config", null, t =>
{
t.HasCheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
{
b.Property<int>("NotificationConfigModuleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NotificationConfigModuleId"));
b.Property<bool>("EmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("InAppEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ModuleKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("NotificationConfigId")
.HasColumnType("integer");
b.Property<bool>("SmsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.HasKey("NotificationConfigModuleId");
b.HasIndex("ModuleKey")
.IsUnique();
b.HasIndex("NotificationConfigId");
b.ToTable("notification_config_modules", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{
b.Property<int>("SequenceId")
@@ -2880,6 +2913,36 @@ namespace ERPCore.Migrations
PermissionId = 34,
Code = "NAV:accounts.received-cheques",
SubNavItemId = 22
},
new
{
PermissionId = 35,
Code = "NAV:production",
NavItemId = 14
},
new
{
PermissionId = 36,
Code = "NAV:production.templates",
SubNavItemId = 27
},
new
{
PermissionId = 37,
Code = "NAV:production.runs",
SubNavItemId = 25
},
new
{
PermissionId = 38,
Code = "NAV:production.produced",
SubNavItemId = 26
},
new
{
PermissionId = 39,
Code = "NAV:procurement.purchase-invoices",
SubNavItemId = 28
});
});
@@ -2932,6 +2995,11 @@ namespace ERPCore.Migrations
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("BatchListsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("BrandsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -2948,6 +3016,16 @@ namespace ERPCore.Migrations
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<bool>("SellingPricesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SerialNumbersEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -2959,6 +3037,11 @@ namespace ERPCore.Migrations
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.Property<bool>("WarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
@@ -3099,6 +3182,189 @@ namespace ERPCore.Migrations
b.ToTable("production_templates", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Property<int>("PurchaseInvoiceId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("VendorId")
.HasColumnType("integer");
b.Property<DateTime>("VendorInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorInvoiceNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("VendorSnapshotName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("VendorSnapshotTaxNo")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("PurchaseInvoiceId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("VendorId");
b.HasIndex("VendorId", "VendorInvoiceNo");
b.ToTable("purchase_invoices", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.Property<int>("PurchaseInvoiceLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoiceLineId"));
b.Property<int>("GrnId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("VatTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("PurchaseInvoiceLineId");
b.HasIndex("GrnId")
.IsUnique();
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.Property<int>("PurchaseInvoicePaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PurchaseInvoicePaymentId"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("BankAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<long>("GlBankAccountId")
.HasColumnType("bigint");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("PurchaseInvoiceId")
.HasColumnType("integer");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("PurchaseInvoicePaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("PurchaseInvoiceId");
b.ToTable("purchase_invoice_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property<int>("PoId")
@@ -5148,6 +5414,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 24,
Code = "settings.notification-config",
Href = "/dashboard/settings/notification-config",
Label = "Notification Config",
NavItemId = 9,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
@@ -5198,6 +5474,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 28,
Code = "procurement.purchase-invoices",
Href = "/dashboard/procurement/purchase-invoices",
Label = "Purchase Invoices",
NavItemId = 4,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 9,
Code = "ledgers.trial-balance",
@@ -5296,6 +5582,36 @@ namespace ERPCore.Migrations
NavItemId = 12,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 27,
Code = "production.templates",
Href = "/dashboard/production/templates",
Label = "Templates",
NavItemId = 14,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 25,
Code = "production.runs",
Href = "/dashboard/production/runs",
Label = "Runs",
NavItemId = 14,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 26,
Code = "production.produced",
Href = "/dashboard/production/produced",
Label = "Produced",
NavItemId = 14,
SortOrder = 3,
Status = "Active"
});
});
@@ -6007,6 +6323,11 @@ namespace ERPCore.Migrations
.HasForeignKey("PoId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany()
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
@@ -6021,6 +6342,8 @@ namespace ERPCore.Migrations
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
b.Navigation("PurchaseOrder");
b.Navigation("Vendor");
@@ -6079,25 +6402,6 @@ namespace ERPCore.Migrations
b.Navigation("GrnLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany("Payments")
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Grn");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
@@ -6213,6 +6517,27 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
{
b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig")
.WithMany("Modules")
.HasForeignKey("NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
@@ -6377,6 +6702,63 @@ namespace ERPCore.Migrations
b.Navigation("Creator");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Vendor");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
.WithMany()
.HasForeignKey("GrnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Lines")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Grn");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice")
.WithMany("Payments")
.HasForeignKey("PurchaseInvoiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -7326,8 +7708,6 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
@@ -7345,6 +7725,11 @@ namespace ERPCore.Migrations
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.Navigation("Modules");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.Navigation("Components");
@@ -7373,6 +7758,13 @@ namespace ERPCore.Migrations
b.Navigation("Stages");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
+6 -1
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
@@ -103,7 +105,10 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
// Purchase invoices (vendor invoices consolidating Confirmed GRNs)
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
// Sales (Phase 1)
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
@@ -85,6 +85,45 @@ public sealed class BundleSaleService : IBundleSaleService
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default)
{
var template = new BundleSaleTemplate
{
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
TemplateName = request.TemplateName,
Description = request.Description,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
foreach (var line in request.Lines)
{
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
throw new NotFoundException($"Item {line.ItemId} was not found.");
if (!await _warehouses.Query().AsNoTracking().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct))
throw new NotFoundException($"Warehouse {line.WarehouseId} was not found.");
template.Lines.Add(new BundleSaleTemplateLine
{
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
Qty = line.Qty,
UnitPrice = line.UnitPrice,
IncludeInBundle = line.IncludeInBundle,
SortOrder = line.SortOrder
});
}
await _templates.AddAsync(template, ct);
await _uow.SaveChangesAsync(ct);
return new BundleSaleTemplateDto(
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description,
template.Status, template.CreatedAt, template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
@@ -1,120 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class GrnPaymentService : IGrnPaymentService
{
private readonly IRepository<Grn> _grns;
private readonly IRepository<GrnPayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glClearingAccountCode;
public GrnPaymentService(
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_grns = grns;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
{
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
if (grn.Status == GrnStatus.Draft)
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
if (request.Amount > grn.BalanceAmount)
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "GRN_PAYMENT",
Reference = grn.DocNo,
Narration = $"Payment against GRN {grn.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
]
}, token);
var entity = new GrnPayment
{
GrnId = grn.GrnId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
grn.PaidAmount += request.Amount;
grn.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
{
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
throw new NotFoundException($"GRN {grnId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.GrnId == grnId)
.OrderByDescending(p => p.GrnPaymentId)
.Select(p => new GrnPaymentDto(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static GrnPaymentDto Map(GrnPayment p) => new(
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
+6 -6
View File
@@ -92,7 +92,8 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -102,6 +103,7 @@ public sealed class GrnService : IGrnService
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
@@ -302,15 +304,13 @@ public sealed class GrnService : IGrnService
grn.PostedAt = now;
grn.GlJournalNo = posted.JournalNo;
grn.GlPostedAt = now;
grn.PaidAmount = 0m;
grn.BalanceAmount = totalPayable;
await UpdatePoStatusAsync(grn.PoId, token);
return 0;
}, ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
@@ -437,7 +437,7 @@ public sealed class GrnService : IGrnService
.Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
@@ -446,7 +446,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
@@ -9,6 +9,7 @@ public interface IBundleSaleService
{
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
Task<BundleSaleTemplateDto> CreateTemplateAsync(CreateBundleSaleTemplateRequest request, CancellationToken ct = default);
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
@@ -1,10 +0,0 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
public interface IGrnPaymentService
{
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
namespace ERPCore.Services.Interfaces;
public interface INotificationConfigService
{
Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using ERPCore.Domain.Notifications;
namespace ERPCore.Services.Interfaces;
public interface INotificationModuleCatalog
{
IReadOnlyList<NotificationModuleDefinition> GetAll();
NotificationModuleDefinition? Find(string moduleKey);
}
@@ -18,6 +18,15 @@ public interface IProductionRunService
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
/// <summary>
/// Produced-items report (FR-MFG-13): one row per run that has produced something,
/// terminal-output quantities and roll-up cost, plus a summary total over every
/// filtered row. No pagination — bounded by the filters like the Sales reports.
/// </summary>
Task<ProducedListResponseDto> ListProducedAsync(
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
CancellationToken ct = default);
/// <summary>
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a Posted purchase invoice's balance (installments allowed).</summary>
public interface IPurchaseInvoicePaymentService
{
Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
public interface IPurchaseInvoiceService
{
Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
}
+59 -9
View File
@@ -1,6 +1,7 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
@@ -85,7 +86,8 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
.ToListAsync(ct);
@@ -107,9 +109,11 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
@@ -130,7 +134,9 @@ public sealed class ItemService : IItemService
Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
SellingMinPrice = request.SellingMinPrice,
SellingMaxPrice = request.SellingMaxPrice,
SellingFlatPrice = request.SellingFlatPrice,
ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
@@ -160,9 +166,11 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
// The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any
@@ -190,7 +198,9 @@ public sealed class ItemService : IItemService
item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.SellingMinPrice = request.SellingMinPrice;
item.SellingMaxPrice = request.SellingMaxPrice;
item.SellingFlatPrice = request.SellingFlatPrice;
item.ContentQty = request.ContentQty;
item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty;
@@ -275,10 +285,9 @@ public sealed class ItemService : IItemService
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
/// </summary>
private async Task ValidateReferencesAsync(
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
ProductConfigDto config, CancellationToken ct)
{
var config = (await _config.GetAsync(ct)).Value;
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
@@ -332,6 +341,46 @@ public sealed class ItemService : IItemService
}
}
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
{
if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
422);
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
422);
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
422);
if (!config.WarrantyPeriodsEnabled && warranty == Warranty.Warranty)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Warranty periods are disabled in the product configuration; warranty cannot be Warranty.",
422);
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
@@ -364,7 +413,8 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
@@ -0,0 +1,143 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class NotificationConfigService : INotificationConfigService
{
private readonly IRepository<NotificationConfig> _config;
private readonly IRepository<NotificationConfigModule> _modules;
private readonly INotificationModuleCatalog _catalog;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public NotificationConfigService(
IRepository<NotificationConfig> config,
IRepository<NotificationConfigModule> modules,
INotificationModuleCatalog catalog,
ICurrentUser currentUser,
IUnitOfWork uow)
{
_config = config;
_modules = modules;
_catalog = catalog;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.Query()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
var catalog = _catalog.GetAll();
var inputs = request.Modules ?? new List<UpdateNotificationModuleRequest>();
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.ModuleKey)))
throw new DomainException(ErrorCodes.Validation, "Notification module keys cannot be empty.", 400);
if (inputs.Select(x => x.ModuleKey.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() != inputs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate notification module keys are not allowed.", 400);
var byKey = inputs.ToDictionary(x => x.ModuleKey.Trim(), StringComparer.OrdinalIgnoreCase);
var unknown = byKey.Keys.Where(k => catalog.All(m => !string.Equals(m.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
if (unknown.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Unknown notification module key(s): {string.Join(", ", unknown)}.", 422);
var missing = catalog.Where(m => !byKey.ContainsKey(m.Key)).Select(m => m.Key).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Missing notification module settings for: {string.Join(", ", missing)}.", 422);
config.Modules.Clear();
foreach (var module in catalog)
{
var input = byKey[module.Key];
config.Modules.Add(new NotificationConfigModule
{
NotificationConfigId = config.NotificationConfigId,
ModuleKey = module.Key,
EmailEnabled = input.EmailEnabled!.Value,
SmsEnabled = input.SmsEnabled!.Value,
InAppEnabled = input.InAppEnabled!.Value
});
}
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
await SaveGuardingConcurrencyAsync(ct);
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default)
{
var key = moduleKey?.Trim();
if (string.IsNullOrWhiteSpace(key)) return Array.Empty<NotificationChannel>();
var row = await _modules.Query().AsNoTracking()
.FirstOrDefaultAsync(m => m.ModuleKey == key, ct);
if (row is null) return Array.Empty<NotificationChannel>();
var channels = new List<NotificationChannel>(3);
if (row.EmailEnabled) channels.Add(NotificationChannel.Email);
if (row.SmsEnabled) channels.Add(NotificationChannel.Sms);
if (row.InAppEnabled) channels.Add(NotificationChannel.InApp);
return channels;
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
}
}
private NotificationConfigDto Map(NotificationConfig config)
{
var rows = config.Modules
.ToDictionary(m => m.ModuleKey, m => m, StringComparer.OrdinalIgnoreCase);
var modules = _catalog.GetAll()
.Select(m =>
{
rows.TryGetValue(m.Key, out var row);
return new NotificationModuleConfigDto(
m.Key,
m.Label,
m.Description,
row?.EmailEnabled ?? false,
row?.SmsEnabled ?? false,
row?.InAppEnabled ?? false);
})
.ToList();
return new NotificationConfigDto(modules, config.UpdatedAt, config.UpdatedBy);
}
}
@@ -0,0 +1,35 @@
using System.Reflection;
using ERPCore.Domain.Notifications;
using ERPCore.Domain.Notifications.Modules;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
public sealed class NotificationModuleCatalog : INotificationModuleCatalog
{
private readonly IReadOnlyList<NotificationModuleDefinition> _modules;
public NotificationModuleCatalog()
{
var moduleType = typeof(SaleCreateNotificationModule).Assembly;
_modules = moduleType.GetTypes()
.Select(t => new { Type = t, Attr = t.GetCustomAttribute<NotificationModuleAttribute>() })
.Where(x => x.Attr is not null)
.Select(x => new NotificationModuleDefinition(x.Attr!.Key, x.Attr.Label, x.Attr.Description, x.Attr.SortOrder))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Key, StringComparer.Ordinal)
.ToList();
var duplicates = _modules.GroupBy(m => m.Key, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicates.Count > 0)
throw new InvalidOperationException($"Duplicate notification module key(s) found: {string.Join(", ", duplicates)}.");
}
public IReadOnlyList<NotificationModuleDefinition> GetAll() => _modules;
public NotificationModuleDefinition? Find(string moduleKey)
=> _modules.FirstOrDefault(m => string.Equals(m.Key, moduleKey, StringComparison.OrdinalIgnoreCase));
}
@@ -12,7 +12,7 @@ namespace ERPCore.Services;
/// <summary>
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
/// Reads are never gated — only writes consult the flags — so switching a feature off
/// Reads are never gated - only writes consult the flags - so switching a feature off
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
/// </summary>
public sealed class ProductConfigService : IProductConfigService
@@ -46,11 +46,15 @@ public sealed class ProductConfigService : IProductConfigService
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
// model binder has already rejected anything that would reach here with a null.
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
config.BrandsEnabled = request.BrandsEnabled!.Value;
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -67,5 +71,7 @@ public sealed class ProductConfigService : IProductConfigService
}
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -124,6 +124,75 @@ public sealed class ProductionRunService : IProductionRunService
return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<ProducedListResponseDto> ListProducedAsync(
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
CancellationToken ct = default)
{
var q = _runs.Query().AsNoTracking()
// FR-MFG-05 (ProductionGraphValidator) guarantees exactly one item-bearing
// output per run, always the terminal one — a row exists once that output has
// actually produced something.
.Where(r => r.Stages.Any(s => s.Outputs.Any(o => o.ItemId != null && o.ProducedQty > 0)));
if (from is not null) q = q.Where(r => r.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(r => r.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
if (status is not null) q = q.Where(r => r.Status == status);
// Projected to an anonymous type first, then mapped client-side — EF Core 10 cannot
// translate a record constructor call inside a projection alongside aggregates (same
// failure mode noted in ListAsync above).
var rows = await q
.OrderByDescending(r => r.RunId)
.Select(r => new
{
r.RunId,
r.DocNo,
r.Status,
r.TemplateId,
TemplateName = r.Template!.Name,
r.WarehouseId,
WarehouseName = r.Warehouse!.Name,
Output = r.Stages.SelectMany(s => s.Outputs)
.Where(o => o.ItemId != null)
.Select(o => new { o.ItemId, ItemName = o.Item!.Name, o.ProducedQty, o.ScrappedQty })
.First(),
ConsumedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue),
ReturnedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue),
r.CreatedAt,
r.CompletedAt
})
.ToListAsync(ct);
var items = rows.Select(r =>
{
var good = r.Output.ProducedQty - r.Output.ScrappedQty;
// Same formula PostReceiptAsync uses to cost the finished layer at approve
// (FR-MFG-13): pool / good qty, 6dp. TotalCost carries the pool exactly rather
// than good * unitCost, which drifts from it past ~100 units (see the ledgerValue
// rounding note in PostReceiptAsync).
var pool = r.ConsumedValue - r.ReturnedValue;
var unitCost = good > 0 ? Math.Round(pool / good, 6, MidpointRounding.AwayFromZero) : 0m;
return new ProducedLineDto(
r.RunId, r.DocNo, r.Status, r.TemplateId, r.TemplateName,
r.WarehouseId, r.WarehouseName,
r.Output.ItemId!.Value, r.Output.ItemName,
r.Output.ProducedQty, r.Output.ScrappedQty, good,
unitCost, pool,
r.CreatedAt, r.CompletedAt);
}).ToList();
var summary = new ProducedSummaryDto(
items.Count,
items.Sum(i => i.ProducedQty),
items.Sum(i => i.ScrappedQty),
items.Sum(i => i.GoodQty),
items.Sum(i => i.TotalCost));
return new ProducedListResponseDto(items, summary);
}
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
{
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
@@ -305,17 +374,58 @@ public sealed class ProductionRunService : IProductionRunService
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
// FR-MFG-08: editable until the stage starts. Once started, consumption has
// already happened against the planned figures.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
// input can still move — raising it draws the extra amount via FIFO (mirroring
// StartStageAsync's initial draw), lowering it returns the excess to stock
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
throw new DomainException(ErrorCodes.StageNotEditable,
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
var now = DateTime.UtcNow;
var balances = new Dictionary<int, decimal>();
var consumed = new List<ConsumedInputDto>();
var returned = new List<object>();
foreach (var line in request.Inputs)
{
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
?? throw new DomainException(ErrorCodes.Validation,
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
{
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
var netConsumed = input.ConsumedQty - input.ReturnedQty;
var delta = plannedBase - netConsumed;
if (delta > 0)
{
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
consumed.Add(dto);
}
else if (delta < 0)
{
var qty = -delta;
if (line.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired,
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
returned.Add(new
{
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
reasonCodeId = line.ReasonCodeId
});
}
}
input.PlannedQty = line.PlannedQty;
}
@@ -330,12 +440,15 @@ public sealed class ProductionRunService : IProductionRunService
// Raising an upstream input's planned quantity means the deliveries that made
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
// does not state this; it is the only behaviour consistent with FR-MFG-09.
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
RecomputeReadiness(stage);
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
{
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
returned
});
}, ct);
@@ -344,6 +457,87 @@ public sealed class ProductionRunService : IProductionRunService
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
}
/// <summary>
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
/// (raising planned qty on a running stage) so the two never drift apart.
/// </summary>
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
ProductionRun run, RunStageInput input, Item item, decimal delta,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
input.ConsumedQty += delta;
input.ConsumedValue += value;
var dto = new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
return (dto, entry);
}
/// <summary>
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
/// </summary>
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
ProductionRun run, RunStageInput input, decimal qty,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
// so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
// multiplying lets successive partial returns accumulate drift until the last one
// can exceed ConsumedValue.
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the pool
// lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
qty, unitCost, now, ct);
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
balance += qty;
balances[input.ItemId.Value] = balance;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
var entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
input.ReturnedQty += qty;
input.ReturnedValue += returnValue;
return (unitCost, returnValue, layer, entry);
}
// --- stage actions -------------------------------------------------------
public async Task<StartStageResultDto> StartStageAsync(
@@ -361,7 +555,6 @@ public sealed class ProductionRunService : IProductionRunService
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
var now = DateTime.UtcNow;
var actor = _currentUser.AuditUserId;
// Running balances accumulated per item across the loop: layers written in this
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
@@ -386,27 +579,9 @@ public sealed class ProductionRunService : IProductionRunService
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
ledgerRefs.Add(entry);
input.ConsumedQty += delta;
input.ConsumedValue += value;
consumed.Add(new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
consumed.Add(dto);
}
stage.Status = ProductionStageStatus.InProgress;
@@ -649,36 +824,9 @@ public sealed class ProductionRunService : IProductionRunService
var now = DateTime.UtcNow;
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
// Weighted cost actually consumed for THIS input — cost-preserving by
// construction, so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
returnedQty = request.Qty;
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
// then multiplying lets successive partial returns accumulate drift until the
// last one can exceed ConsumedValue.
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the
// pool lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
layer = await _fifo.CreateInboundLayerAsync(
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
request.Qty, unitCost, now, token);
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
input.ReturnedQty += request.Qty;
input.ReturnedValue += returnValue;
(unitCost, returnValue, layer, entry) =
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -0,0 +1,119 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Vendor payments against a Posted purchase invoice. Multiple installments are allowed
/// until <see cref="PurchaseInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit Accounts Payable / Credit the selected bank-or-cash
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
/// rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
{
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotPayable, $"Purchase invoice {purchaseInvoiceId} must be posted before it can be paid.", 409);
if (request.Amount > invoice.BalanceAmount)
throw new DomainException(ErrorCodes.PurchaseInvoicePaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.PurchaseInvoiceBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE_PAYMENT",
Reference = invoice.DocNo,
Narration = $"Payment against invoice {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
]
}, token);
var entity = new PurchaseInvoicePayment
{
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
Amount = request.Amount,
PaymentDate = now,
GlBankAccountId = account.AccountId,
BankAccountName = account.AccountName,
Reference = request.Reference,
GlJournalNo = posted.JournalNo,
CreatedBy = actor,
CreatedAt = now
};
await _payments.AddAsync(entity, token);
invoice.PaidAmount += request.Amount;
invoice.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.Select(p => new PurchaseInvoicePaymentDto(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,303 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase invoice service. A purchase invoice consolidates one or more Confirmed
/// GRNs for the same vendor into a single vendor-facing document — each linked GRN
/// becomes one <see cref="PurchaseInvoiceLine"/>, a straight sum of that GRN's line
/// totals (no re-itemization). Draft invoices are fully editable (GRNs can be
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
/// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _grns;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode;
private readonly string _glAccountsPayableCode;
public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_invoices = invoices;
_vendors = vendors;
_grns = grns;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
_gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PurchaseInvoice
{
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
VendorInvoiceNo = vendorInvoiceNo,
VendorInvoiceDate = request.VendorInvoiceDate,
VendorId = request.VendorId,
VendorSnapshotName = vendor.Name,
VendorSnapshotTaxNo = vendor.TaxReg,
Status = PurchaseInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = grns.Select(BuildLine).ToList()
};
Recalculate(entity);
await _invoices.AddAsync(entity, token);
foreach (var grn in grns) grn.PurchaseInvoice = entity;
return entity;
}, ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
// Free GRNs that were linked to this invoice but are no longer selected.
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
foreach (var line in invoice.Lines)
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
invoice.VendorId = request.VendorId;
invoice.VendorInvoiceNo = vendorInvoiceNo;
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
invoice.VendorSnapshotName = vendor.Name;
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
invoice.Lines.Clear();
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be posted.");
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE",
Reference = invoice.DocNo,
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
]
}, token);
invoice.Status = PurchaseInvoiceStatus.Posted;
invoice.GlJournalNo = posted.JournalNo;
invoice.GlPostedAt = now;
invoice.UpdatedAt = now;
return 0;
}, ct);
return MapInvoice(invoice);
}
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be cancelled.");
var dto = MapInvoice(invoice);
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
// the link rows are removed (the unique index on GrnId only tracks the active link),
// while the Cancelled header row itself remains for audit history.
foreach (var line in invoice.Lines)
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
invoice.Lines.Clear();
invoice.Status = PurchaseInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
}
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{
return await _grns.Query().AsNoTracking()
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
.Select(g => new GrnEligibleForInvoiceDto(
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
.ToListAsync(ct);
}
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
x.VendorId == vendorId
&& x.Status != PurchaseInvoiceStatus.Cancelled
&& x.VendorInvoiceNo == vendorInvoiceNo
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
}
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines)
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
if (grns.Count != distinctIds.Count)
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
foreach (var grn in grns)
{
if (grn.Status != GrnStatus.Confirmed)
throw new DomainException(ErrorCodes.GrnNotConfirmed, $"GRN {grn.DocNo} must be confirmed before it can be invoiced.", 409);
if (grn.VendorId != vendorId)
throw new DomainException(ErrorCodes.GrnVendorMismatch, $"GRN {grn.DocNo} belongs to a different vendor.", 409);
if (grn.PurchaseInvoiceId is not null && grn.PurchaseInvoiceId != excludingPurchaseInvoiceId)
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
}
return grns;
}
private static PurchaseInvoiceLine BuildLine(Grn grn) => new()
{
Grn = grn,
GrnId = grn.GrnId,
Subtotal = grn.Lines.Sum(l => l.ReceivedValue),
VatTotal = grn.Lines.Sum(l => l.VatAmount),
LineTotal = grn.Lines.Sum(l => l.LineTotal)
};
private static void Recalculate(PurchaseInvoice invoice)
{
invoice.Subtotal = invoice.Lines.Sum(x => x.Subtotal);
invoice.VatTotal = invoice.Lines.Sum(x => x.VatTotal);
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal);
invoice.PaidAmount = 0m;
invoice.BalanceAmount = invoice.GrandTotal;
}
private static PurchaseInvoiceTotalsDto MapTotals(PurchaseInvoice x) => new(
x.Subtotal, x.VatTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount);
private static PurchaseInvoiceSummaryDto MapSummary(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.Status, x.Lines.Count, MapTotals(x), x.CreatedAt, x.GlJournalNo);
private static PurchaseInvoiceDto MapInvoice(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.VendorSnapshotTaxNo, x.Status, x.Lines.Count, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.GlJournalNo, x.GlPostedAt, MapTotals(x),
x.Lines.OrderBy(l => l.PurchaseInvoiceLineId).Select(l => new PurchaseInvoiceLineDto(
l.PurchaseInvoiceLineId, l.GrnId, l.Grn?.DocNo ?? string.Empty, l.Grn?.PostedAt ?? default,
l.Subtotal, l.VatTotal, l.LineTotal)).ToList());
}
+30 -4
View File
@@ -34,6 +34,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers;
@@ -49,12 +50,13 @@ public sealed class SalesDayEndService : ISalesDayEndService
public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_dayEnds = dayEnds;
_slips = slips;
_bundles = bundles;
_invoices = invoices;
_ledger = ledger;
_items = items;
_numbers = numbers;
@@ -71,6 +73,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
{
var invoiceSummary = await LoadInvoiceSummaryAsync(cashierUserId, businessDate, ct);
var existing = await _dayEnds.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
if (existing is not null)
@@ -89,7 +93,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
cashierUserId, businessDate, true,
existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal,
closedSlipNumbers, closedBundleNumbers,
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>());
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>(), invoiceSummary);
}
var openSlips = await LoadOpenSlipsAsync(cashierUserId, businessDate, ct);
@@ -111,7 +115,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(),
postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(),
draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(),
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList());
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList(),
invoiceSummary);
}
public async Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default)
@@ -265,6 +270,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
.ToListAsync(ct);
}
// Sales Invoices have no CashierUserId (they aren't a cashier document — SalesInvoiceService
// doesn't take one) so CreatedBy is the closest equivalent: whoever was logged in when they
// made the sale. Matched against Posted invoices only, same as the Slip/Bundle totals above.
private async Task<SalesDayEndInvoiceSummaryDto> LoadInvoiceSummaryAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct)
{
var (dayStart, dayEnd) = DayRangeUtc(businessDate);
var invoices = await _invoices.Query().AsNoTracking()
.Where(x => x.CreatedBy == cashierUserId && x.Status == SalesInvoiceStatus.Posted
&& x.InvoiceDate >= dayStart && x.InvoiceDate < dayEnd)
.ToListAsync(ct);
return new SalesDayEndInvoiceSummaryDto(
invoices.Count,
invoices.Sum(x => x.Subtotal),
invoices.Sum(x => x.DiscountTotal),
invoices.Sum(x => x.TaxTotal),
invoices.Sum(x => x.GrandTotal),
invoices.OrderBy(x => x.InvoiceNo).Select(x => x.InvoiceNo).ToList());
}
// SlipDate/BundleDate are stored as DateTime.UtcNow (Kind=Utc) throughout — Npgsql requires
// a matching Kind=Utc here too, or comparisons against the `timestamptz` column throw.
private static (DateTime Start, DateTime End) DayRangeUtc(DateOnly businessDate)
@@ -310,10 +334,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
.OrderByDescending(x => x.Revenue)
.ToList();
var invoiceSummary = await LoadInvoiceSummaryAsync(entity.CashierUserId, entity.BusinessDate, ct);
return new SalesDayEndDto(
entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate,
entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal,
entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt,
slipNumbers, bundleNumbers, breakdown);
slipNumbers, bundleNumbers, breakdown, invoiceSummary);
}
}
@@ -16,8 +16,8 @@ namespace ERPCore.Services;
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable
/// clearing account when vendor-paid; this one credits the receivable asset when
/// <see cref="PurchaseInvoicePaymentService"/> — the mirror image of it (that one debits a
/// payable account when the vendor is paid; this one credits the receivable asset when
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
@@ -31,8 +31,8 @@ public sealed class SalesPricingService : ISalesPricingService
{
if (!allowManualOverride)
{
if (item.SalePrice.HasValue)
return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value);
if (item.SellingFlatPrice.HasValue)
return new SalesPriceResolution(item.SellingFlatPrice.Value, "SALE_PRICE", item.SellingFlatPrice.Value);
var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct);
if (grnPrice is not null)
@@ -44,8 +44,8 @@ public sealed class SalesPricingService : ISalesPricingService
return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value);
}
if (item.SalePrice.HasValue)
return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value);
if (item.SellingFlatPrice.HasValue)
return new SalesPriceResolution(item.SellingFlatPrice.Value, "SALE_PRICE", item.SellingFlatPrice.Value);
var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct);
if (weighted is not null)
@@ -46,14 +46,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
{
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
new(item.ItemId, item.Sku, item.Name, item.SellingFlatPrice)
};
var alternates = await _items.Query().AsNoTracking()
.Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId)
.OrderBy(x => x.Name)
.Take(3)
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice))
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SellingFlatPrice))
.ToListAsync(ct);
rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId)));
+10 -5
View File
@@ -73,13 +73,18 @@ public static class ErrorCodes
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED";
// GRN payments
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
// Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
// Purchase invoices (consolidate Confirmed GRNs; vendor payments now tracked here, not on the GRN)
public const string GrnAlreadyInvoiced = "GRN_ALREADY_INVOICED";
public const string GrnVendorMismatch = "GRN_VENDOR_MISMATCH";
public const string GrnNotConfirmed = "GRN_NOT_CONFIRMED";
public const string PurchaseInvoiceNotEditable = "PURCHASE_INVOICE_NOT_EDITABLE";
public const string DuplicateVendorInvoiceNo = "DUPLICATE_VENDOR_INVOICE_NO";
public const string PurchaseInvoiceNotPayable = "PURCHASE_INVOICE_NOT_PAYABLE";
public const string PurchaseInvoicePaymentExceedsBalance = "PURCHASE_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string PurchaseInvoiceBankAccountNotFound = "PURCHASE_INVOICE_BANK_ACCOUNT_NOT_FOUND";
}
+4
View File
@@ -47,5 +47,9 @@
"SalesInvoice": {
"GlAccountsReceivableCode": "1300"
},
"PurchaseInvoice": {
"GlClearingAccountCode": "2000",
"GlAccountsPayableCode": "2200"
},
"AllowedHosts": "*"
}
@@ -0,0 +1,96 @@
import { Badge } from "@/components/ui/badge"
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
// Fixed width (not w-fit) so every status pill is the same size regardless of
// label length, same convention as components/receiving/status-badges.tsx.
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
function requisitionClass(status: RequisitionStatus) {
return status === "Draft" ? "bg-warning/10 text-warning border-transparent" : "bg-success/10 text-success border-transparent"
}
export function RequisitionStatusBadge({ status }: { status: RequisitionStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${requisitionClass(status)}`}>
{status}
</Badge>
)
}
function rfqClass(status: RfqStatus) {
return status === "Open" ? "bg-success/10 text-success border-transparent" : "bg-muted text-muted-foreground border-transparent"
}
export function RfqStatusBadge({ status }: { status: RfqStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${rfqClass(status)}`}>
{status}
</Badge>
)
}
function poClass(status: PurchaseOrderStatus) {
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
}
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
{status}
</Badge>
)
}
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
}
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
{purchaseInvoiceStatusLabel[status]}
</Badge>
)
}
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Unpaid
}
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
{purchaseInvoicePaymentLabel[status]}
</Badge>
)
}
@@ -1,6 +1,6 @@
"use client"
import { Fragment, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BookOpen } from "lucide-react"
@@ -62,7 +62,7 @@ export default function GeneralLedgerReportPage() {
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
<p className="text-base text-muted-foreground">Every posted movement on every account, with a running balance per account.</p>
<p className="text-base text-muted-foreground">Every posted movement, grouped by transaction so every leg of a GRN, Sale, or other journal entry sits together.</p>
</div>
</div>
@@ -108,37 +108,32 @@ export default function GeneralLedgerReportPage() {
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Journal No.</TableHead>
<TableHead className="h-11 px-3 text-sm">Narration</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Running balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => {
const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode
return (
<Fragment key={i}>
{isNewAccount && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={6} className="px-3 py-2 font-semibold">
{row.accountCode} — {row.accountName}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 font-medium">{row.journalNo}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-medium tabular-nums">{formatAmount(row.runningBalance)}</TableCell>
</TableRow>
</Fragment>
)
})}
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
@@ -0,0 +1,298 @@
"use client"
import { use, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookOpen, Printer } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { vendorsApi } from "@/lib/api/vendors"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Vendor } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
/** "Posted" reads as "approved" everywhere in the UI — matches PurchaseInvoiceStatusBadge's label. */
function statusWord(status: PurchaseInvoiceStatus): string {
return status === "Posted" ? "approved" : status.toLowerCase()
}
export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter()
const resolvedParams = use(params)
const purchaseInvoiceId = Number(resolvedParams.id)
const [invoice, setInvoice] = useState<PurchaseInvoice | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [payments, setPayments] = useState<PurchaseInvoicePayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
if (!Number.isFinite(purchaseInvoiceId)) {
setError(`Invalid purchase invoice id '${resolvedParams.id}'.`)
return
}
Promise.all([
vendorsApi.list({ pageSize: 200 }),
purchaseInvoicesApi.get(purchaseInvoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
.then(([vendorRes, doc, companyRes, templateRes]) => {
setVendors(vendorRes.items)
setInvoice(doc.data)
setCompany(companyRes.data)
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [purchaseInvoiceId, resolvedParams.id])
// No manual Save/Post/Cancel step: a Draft landed on here is posted immediately and
// automatically — this page is only ever meant to be seen in its Posted (read-only) state.
const autoPostedRef = useRef(false)
useEffect(() => {
if (!invoice || invoice.status !== "Draft" || autoPostedRef.current) return
autoPostedRef.current = true
setPosting(true)
setError(null)
purchaseInvoicesApi
.post(purchaseInvoiceId)
.then((posted) => {
setInvoice(posted)
toast.success("Purchase invoice posted", posted.docNo)
router.refresh()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setPosting(false))
}, [invoice, purchaseInvoiceId, router])
useEffect(() => {
if (!invoice || invoice.status !== "Posted") {
setPayments([])
return
}
purchaseInvoicesApi.listPayments(purchaseInvoiceId).then(setPayments).catch(() => setPayments([]))
}, [invoice?.status, purchaseInvoiceId])
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
setInvoice((prev) =>
prev
? {
...prev,
status: newStatus,
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
}
: prev
)
setPayments((prev) => [payment, ...prev])
}
const vendor = useMemo(() => vendors.find((v) => v.vendorId === invoice?.vendorId), [vendors, invoice?.vendorId])
if (error && !invoice) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>
}
if (!invoice) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
const layout = getInvoiceLayoutClasses(template?.templateStyle)
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Invoice Details</h1>
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div>
</div>
<div className="flex items-center gap-2">
{invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" />
View in Ledger
</Link>
)}
<Link
href={`/print/procurement/purchase-invoices/${invoice.purchaseInvoiceId}`}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print
</Link>
</div>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="border-b pb-4">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Purchase Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.docNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<PurchaseInvoiceStatusBadge status={invoice.status} />
{invoice.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={invoice.totals.paidAmount} balanceAmount={invoice.totals.balanceAmount} />
)}
</div>
<div className="mt-1 text-sm text-muted-foreground">Vendor invoice: {invoice.vendorInvoiceNo}</div>
<div className="text-sm text-muted-foreground">Date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
</div>
<InvoicePrintHeader company={company} template={template} />
</div>
</div>
{layout.arrangement === "highlight" && (
<div className={`mt-5 ${layout.totalsBanner}`}>
<span className={layout.totalsBannerLabel}>Grand total</span>
<span className={layout.totalsBannerValue}>{formatAmount(invoice.totals.grandTotal)}</span>
</div>
)}
<div className="grid gap-6 py-5 md:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Vendor</div>
<div className="mt-2 font-semibold text-foreground">{invoice.vendorSnapshotName}</div>
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
</div>
{layout.arrangement !== "highlight" && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{formatAmount(invoice.totals.subtotal)}</div>
<div className="text-muted-foreground">VAT</div>
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
<div className="text-muted-foreground">Grand total</div>
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
</div>
</div>
)}
</div>
<div className="overflow-x-auto border-y">
<Table className="text-sm">
<TableHeader>
<TableRow className={layout.tableHeaderRow}>
<TableHead className="h-11 px-4 text-sm">GRN</TableHead>
<TableHead className="h-11 px-4 text-sm">Posted</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">VAT</TableHead>
<TableHead className="h-11 px-4 text-sm text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.purchaseInvoiceLineId}>
<TableCell className="px-4 py-3">
<Link href={`/dashboard/receiving/grn/${line.grnId}`} className="font-medium hover:underline">
{line.grnDocNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<InvoicePrintFooter company={company} template={template} />
<div className="mt-6 border-t pt-5">
{invoice.status === "Draft" ? (
<div className="flex items-center gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
{posting ? "Posting invoice…" : "Waiting to post…"}
</div>
) : (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>This invoice is {statusWord(invoice.status)}.</span>
{invoice.status === "Posted" ? (
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
</Button>
) : null}
</div>
)}
{invoice.status === "Posted" && (
<div className="mt-5 flex flex-col gap-2 rounded-2xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-semibold text-foreground">Payment status</span>
<span className="text-muted-foreground">
Paid {formatAmount(invoice.totals.paidAmount)} of {formatAmount(invoice.totals.grandTotal)} — balance {formatAmount(invoice.totals.balanceAmount)}
{invoice.glJournalNo ? ` · GL journal ${invoice.glJournalNo}` : ""}
</span>
</div>
{payments.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-3 text-xs">Date</TableHead>
<TableHead className="h-9 px-3 text-xs">Account</TableHead>
<TableHead className="h-9 px-3 text-xs">Reference</TableHead>
<TableHead className="h-9 px-3 text-xs">GL journal</TableHead>
<TableHead className="h-9 px-3 text-xs text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.purchaseInvoicePaymentId}>
<TableCell className="px-3 py-2">{new Date(p.paymentDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-2">{p.bankAccountName}</TableCell>
<TableCell className="px-3 py-2">{p.reference ?? "—"}</TableCell>
<TableCell className="px-3 py-2">{p.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
</section>
<PurchaseInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -0,0 +1,337 @@
"use client"
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Combobox } from "@/components/ui/combobox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { vendorsApi } from "@/lib/api/vendors"
import { validatePurchaseInvoiceHeader } from "@/lib/validations/purchase-invoices"
import { Vendor } from "@/types/master-data"
import { GrnEligibleForInvoice } from "@/types/purchase-invoices"
const lkr = new Intl.NumberFormat("en-LK", {
style: "currency",
currency: "LKR",
minimumFractionDigits: 2,
})
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function NewPurchaseInvoiceContent() {
const router = useRouter()
const searchParams = useSearchParams()
const vendorIdFromQuery = searchParams.get("vendorId")
const grnIdFromQuery = searchParams.get("grnId")
const appliedDeepLinkRef = useRef(false)
const [vendors, setVendors] = useState<Vendor[]>([])
const [vendorId, setVendorId] = useState<number | null>(null)
const [vendorInvoiceNo, setVendorInvoiceNo] = useState("")
const [vendorInvoiceDate, setVendorInvoiceDate] = useState(todayIso())
const [eligibleGrns, setEligibleGrns] = useState<GrnEligibleForInvoice[] | null>(null)
const [selectedGrnIds, setSelectedGrnIds] = useState<Set<number>>(new Set())
const [grnsError, setGrnsError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
useEffect(() => {
vendorsApi
.list({ pageSize: 200 })
.then((res) => {
setVendors(res.items)
if (vendorIdFromQuery) {
setVendorId(Number(vendorIdFromQuery))
} else {
setVendorId(res.items[0]?.vendorId ?? null)
}
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Bumped to force a refetch when the browser restores this page from its back-forward
// cache (e.g. Back button after create() redirected away) — bfcache restores the frozen
// JS/React state verbatim without re-running effects, so without this the picker would
// keep showing the GRN we just invoiced as still eligible.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
function onPageShow(e: PageTransitionEvent) {
if (e.persisted) setRefetchTick((t) => t + 1)
}
window.addEventListener("pageshow", onPageShow)
return () => window.removeEventListener("pageshow", onPageShow)
}, [])
useEffect(() => {
if (!vendorId) {
setEligibleGrns(null)
setSelectedGrnIds(new Set())
return
}
setGrnsError(null)
setEligibleGrns(null)
purchaseInvoicesApi
.listEligibleGrns(vendorId)
.then((grns) => {
setEligibleGrns(grns)
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — pre-check
// that GRN once its vendor's eligible list has loaded, but only once per page load
// (a bfcache-triggered refetch should re-apply it the same way, since the GRN may
// still be eligible if the user came Back without ever submitting).
if (grnIdFromQuery && (!appliedDeepLinkRef.current || refetchTick > 0)) {
appliedDeepLinkRef.current = true
const id = Number(grnIdFromQuery)
setSelectedGrnIds(grns.some((g) => g.grnId === id) ? new Set([id]) : new Set())
} else if (!grnIdFromQuery) {
setSelectedGrnIds(new Set())
}
})
.catch((err) => setGrnsError(errorMessage(err)))
}, [vendorId, grnIdFromQuery, refetchTick])
const vendorOptions = useMemo(
() => vendors.map((v) => ({ value: v.vendorId, label: `${v.code} — ${v.name}` })),
[vendors]
)
function toggleGrn(grnId: number, checked: boolean) {
setSelectedGrnIds((prev) => {
const next = new Set(prev)
if (checked) next.add(grnId)
else next.delete(grnId)
return next
})
}
function toggleAll(checked: boolean) {
setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set())
}
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the
// whole reason this page was opened, so show only it rather than the vendor's full eligible
// list (which could pull in unrelated GRNs the user never meant to consolidate here).
const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null
const visibleGrns = useMemo(
() => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)),
[eligibleGrns, deepLinkedGrnId]
)
const selectedGrns = useMemo(
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
[eligibleGrns, selectedGrnIds]
)
const subtotal = selectedGrns.reduce((sum, g) => sum + g.subtotal, 0)
const vatTotal = selectedGrns.reduce((sum, g) => sum + g.vatTotal, 0)
const grandTotal = selectedGrns.reduce((sum, g) => sum + g.grandTotal, 0)
const allSelected = visibleGrns.length > 0 && visibleGrns.every((g) => selectedGrnIds.has(g.grnId))
async function submit() {
const grnIds = Array.from(selectedGrnIds)
const nextErrors = validatePurchaseInvoiceHeader({ vendorId, vendorInvoiceNo, vendorInvoiceDate, grnIds })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
setSubmitError(null)
try {
const created = await purchaseInvoicesApi.create({
vendorId: vendorId!,
vendorInvoiceNo: vendorInvoiceNo.trim(),
vendorInvoiceDate,
grnIds,
})
// Deep-linked from a specific GRN — the GRN is already known and confirmed, so there's
// nothing left to review before posting; skip the separate Draft step and post right away.
if (deepLinkedGrnId !== null) {
try {
const posted = await purchaseInvoicesApi.post(created.data.purchaseInvoiceId)
toast.success("Purchase invoice created and posted", posted.docNo)
} catch (postErr) {
toast.error("Invoice created, but could not be posted", errorMessage(postErr))
}
} else {
toast.success("Purchase invoice created", created.data.docNo)
}
router.push(`/dashboard/procurement/purchase-invoices/${created.data.purchaseInvoiceId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Invoice</h1>
<p className="text-base text-muted-foreground">Pick a vendor, then consolidate one or more of its confirmed, un-invoiced GRNs.</p>
</div>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.vendorId}>
<FieldLabel>Vendor</FieldLabel>
<Combobox
items={vendorOptions}
value={vendorId}
onValueChange={setVendorId}
placeholder="Search vendor…"
aria-invalid={!!errors.vendorId}
/>
<FieldError errors={[errors.vendorId ? { message: errors.vendorId } : undefined]} />
</Field>
<Field data-invalid={!!errors.vendorInvoiceNo}>
<FieldLabel htmlFor="pinv-vendor-no">Vendor invoice no.</FieldLabel>
<Input
id="pinv-vendor-no"
value={vendorInvoiceNo}
onChange={(e) => setVendorInvoiceNo(e.target.value)}
placeholder="e.g. INV-84213"
aria-invalid={!!errors.vendorInvoiceNo}
/>
<FieldError errors={[errors.vendorInvoiceNo ? { message: errors.vendorInvoiceNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.vendorInvoiceDate}>
<FieldLabel htmlFor="pinv-vendor-date">Vendor invoice date</FieldLabel>
<Input
id="pinv-vendor-date"
type="date"
value={vendorInvoiceDate}
max={todayIso()}
onChange={(e) => setVendorInvoiceDate(e.target.value)}
aria-invalid={!!errors.vendorInvoiceDate}
/>
<FieldError errors={[errors.vendorInvoiceDate ? { message: errors.vendorInvoiceDate } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Eligible GRNs</h2>
{errors.grnIds && <span className="text-sm text-destructive">{errors.grnIds}</span>}
</div>
{!vendorId && (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">Select a vendor to see its confirmed, un-invoiced GRNs.</p>
)}
{vendorId && grnsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{grnsError}</div>
)}
{vendorId && !grnsError && eligibleGrns === null && <Skeleton className="h-32 w-full" />}
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length === 0 && (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
{deepLinkedGrnId !== null
? "That GRN is no longer eligible to invoice (already invoiced, or not confirmed)."
: "No confirmed, un-invoiced GRNs for this vendor."}
</p>
)}
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length > 0 && (
<div className="overflow-x-auto rounded-2xl border">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-11 w-10 px-3">
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} aria-label="Select all" />
</TableHead>
<TableHead className="h-11 px-3 text-sm">GRN</TableHead>
<TableHead className="h-11 px-3 text-sm">Posted</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Lines</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">VAT</TableHead>
<TableHead className="h-11 px-3 text-sm text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleGrns.map((g) => (
<TableRow key={g.grnId}>
<TableCell className="px-3 py-3">
<Checkbox
checked={selectedGrnIds.has(g.grnId)}
onCheckedChange={(v) => toggleGrn(g.grnId, !!v)}
aria-label={`Select ${g.docNo}`}
/>
</TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
<TableCell className="px-3 py-3 text-right font-semibold tabular-nums">{lkr.format(g.grandTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base">
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">Subtotal</span>
<span className="tabular-nums">{lkr.format(subtotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">VAT</span>
<span className="tabular-nums">{lkr.format(vatTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Grand total</span>
<span className="font-semibold tabular-nums">{lkr.format(grandTotal)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}>
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewPurchaseInvoicePage() {
return (
<Suspense fallback={<Skeleton className="h-12 w-full" />}>
<NewPurchaseInvoiceContent />
</Suspense>
)
}
@@ -0,0 +1,299 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 10
const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
{ value: "Posted", label: "Approved" },
{ value: "Cancelled", label: "Cancelled" },
]
export default function PurchaseInvoicesPage() {
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
useEffect(() => {
setError(null)
purchaseInvoicesApi
.list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status])
async function handleConfirm(row: PurchaseInvoiceSummary) {
setConfirmingId(row.purchaseInvoiceId)
try {
const posted = await purchaseInvoicesApi.post(row.purchaseInvoiceId)
setRows((prev) => prev?.map((r) => (r.purchaseInvoiceId === row.purchaseInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.docNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
setConfirmingId(null)
}
}
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
setRows(
(prev) =>
prev?.map((r) =>
r.purchaseInvoiceId === payment.purchaseInvoiceId
? {
...r,
status: newStatus,
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
}
: r
) ?? prev
)
}
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || query.length > 0
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const vatTotal = visibleRows.reduce((sum, row) => sum + row.totals.vatTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/procurement/purchase-invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Invoices</h1>
<p className="text-base text-muted-foreground">Vendor bills consolidating confirmed GRNs, with posting and settlement tracking.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/procurement/purchase-invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Purchase Invoice
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setStatus(t.value)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by vendor, invoice no. or doc no."
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<ReceiptText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase invoices match your filters." : "No purchase invoices yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-4 text-sm">Vendor invoice no.</TableHead>
<TableHead className="h-12 px-4 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">GRNs</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">VAT</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Total</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.purchaseInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">VAT</div>
<div className="text-base font-semibold tabular-nums">{vatTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
<PurchaseInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -0,0 +1,242 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { PackageCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button } 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, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = ProductionRunStatus | "All"
function runStatusBadgeClass(status: ProductionRunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
}
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
// cost of a fractional-value item is still legible without dragging the whole table wide.
function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
export default function ProducedItemsPage() {
const router = useRouter()
// null = still loading (the codebase convention for "no data yet" vs "empty result").
const [data, setData] = useState<ProducedListResponse | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const load = useCallback(() => {
setLoadError(null)
productionRunsApi
.listProduced({
from: from || undefined,
to: to || undefined,
templateId: templateId ?? undefined,
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
.then(setData)
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
})
}, [from, to, templateId, warehouseId, status])
useEffect(load, [load])
useEffect(() => {
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([templateRes, warehouseRes]) => {
setTemplates(templateRes.items)
setWarehouses(warehouseRes.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
<p className="text-base text-muted-foreground">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setFrom("")
setTo("")
setTemplateId(null)
setWarehouseId(null)
setStatus("All")
}}
>
Clear filters
</Button>
)}
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{data === null ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : data.items.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageCheck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
@@ -58,6 +58,9 @@ export default function ItemDetailPage() {
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("")
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -86,6 +89,9 @@ export default function ItemDetailPage() {
setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "")
setSellingMinPrice(data.sellingMinPrice === null ? "" : String(data.sellingMinPrice))
setSellingMaxPrice(data.sellingMaxPrice === null ? "" : String(data.sellingMaxPrice))
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
}
@@ -130,6 +136,9 @@ export default function ItemDetailPage() {
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
},
@@ -246,6 +255,48 @@ export default function ItemDetailPage() {
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -105,6 +105,7 @@ export default function NewItemPage() {
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
@@ -128,11 +129,11 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only.
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// each row keeps its own flat price in `pricesByKey`.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock")
const [fixValue, setFixValue] = useState<string>("")
const [defaultFlatPrice, setDefaultFlatPrice] = useState<string>("")
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
@@ -144,8 +145,9 @@ export default function NewItemPage() {
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// A row shows its own override if set, otherwise it follows the shared fix value.
const priceFor = (key: string) => pricesByKey[key] ?? fixValue
// Each row owns its own flat price. The default field is only a bulk-fill helper.
const priceFor = (key: string) => pricesByKey[key] ?? ""
const variantPriceFor = (variantKey: string, kind: "min" | "max" | "flat") => pricesByKey[`${variantKey}::${kind}`] ?? ""
useEffect(() => {
Promise.all([
@@ -181,6 +183,35 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
useEffect(() => {
if (!config) return
if (!config.serialNumbersEnabled && trackingMode === "Serial") {
setTrackingMode("None")
}
if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None")
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}
if (!config.sellingPricesEnabled) {
setPriceMode("stock")
setDefaultFlatPrice("")
setPricesByKey({})
setPriceErrors({})
}
}, [
config,
trackingMode,
warranty,
config?.serialNumbersEnabled,
config?.batchListsEnabled,
config?.warrantyPeriodsEnabled,
config?.sellingPricesEnabled,
])
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
@@ -355,7 +386,7 @@ export default function NewItemPage() {
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
@@ -374,7 +405,7 @@ export default function NewItemPage() {
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
if (baseUomId === null) {
@@ -385,6 +416,17 @@ export default function NewItemPage() {
setSubmitting(true)
let created = 0
try {
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
? "None"
: !config?.batchListsEnabled && trackingMode === "Batch"
? "None"
: trackingMode
const effectiveWarranty = config?.warrantyPeriodsEnabled ? warranty : "NonWarranty"
const effectiveWarrantyPeriodMonths =
config?.warrantyPeriodsEnabled && effectiveWarranty === "Warranty" ? warrantyPeriodMonths : null
const allowSellingPrices = !!config?.sellingPricesEnabled
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
@@ -398,10 +440,21 @@ export default function NewItemPage() {
brandId,
baseUomId,
stockNature,
trackingMode: "None",
warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
sellingMinPrice:
allowSellingPrices && variantPriceFor(variant.key, "min").trim()
? Number(variantPriceFor(variant.key, "min"))
: null,
sellingMaxPrice:
allowSellingPrices && variantPriceFor(variant.key, "max").trim()
? Number(variantPriceFor(variant.key, "max"))
: null,
sellingFlatPrice:
allowSellingPrices && sendFlatPrices && priceFor(variant.key).trim()
? Number(priceFor(variant.key))
: null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
@@ -637,118 +690,170 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
</div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
{config?.batchListsEnabled && <SelectItem value="Batch" className="text-base">Batch</SelectItem>}
{config?.serialNumbersEnabled && <SelectItem value="Serial" className="text-base">Serial</SelectItem>}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose a fixed selling price, or leave it to the item&apos;s stock value.
{config?.serialNumbersEnabled ? (
<>
Choose <span className="font-medium">Serial</span> if each unit needs its own serial number on receipt.
</>
) : config?.batchListsEnabled ? (
<>
Batch tracking is available; serial tracking is currently disabled in product settings.
</>
) : (
<>
Tracking is currently disabled in product settings.
</>
)}
</p>
</div>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && (
<div className="flex max-w-xl flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
</div>
)}
{priceMode === "fixed" && (
<div className="flex max-w-xs flex-col gap-2">
<Label className="text-base">Fix value (applies to all variants)</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={fixValue}
onChange={(e) => setFixValue(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Edit any row below to give that variant a different price.
Mark whether every generated variant is sold under warranty.
</p>
</div>
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
)}
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
@@ -918,7 +1023,11 @@ export default function NewItemPage() {
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
<>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
@@ -942,29 +1051,55 @@ export default function NewItemPage() {
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
<>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::min`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::max`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
@@ -12,12 +12,11 @@ import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
*
* `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
* `itemTypesEnabled` has no control on this screen but is still part of the record, so
* every save round-trips its current value unchanged (the server rejects a partial body).
* Item types are the product-variant builder, while the other flags control whether item
* pricing, tracking and warranty-period fields are available when creating or editing an item.
* The server enforces the fields that are truly written to the item record.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
@@ -38,17 +37,28 @@ export default function ProductSettingsPage() {
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
async function toggle(flag: keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
>, next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
sellingPricesEnabled: config.sellingPricesEnabled,
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next,
},
etag ?? "",
@@ -58,7 +68,7 @@ export default function ProductSettingsPage() {
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
load()
} finally {
setSaving(null)
}
@@ -79,7 +89,7 @@ export default function ProductSettingsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && !config && <Skeleton className="h-80 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
@@ -90,9 +100,49 @@ export default function ProductSettingsPage() {
<div className="border-t" />
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<ToggleRow
label="Product variants"
description="Controls the variant builder on item create. Off hides the item-type section."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
/>
<ToggleRow
label="Selling prices"
description="Controls the min, max and flat sale price fields on item create and edit."
checked={config.sellingPricesEnabled}
busy={saving === "sellingPricesEnabled"}
onChange={(v) => toggle("sellingPricesEnabled", v)}
/>
<ToggleRow
label="Serial numbers"
description="Allows items to be created with Serial tracking."
checked={config.serialNumbersEnabled}
busy={saving === "serialNumbersEnabled"}
onChange={(v) => toggle("serialNumbersEnabled", v)}
/>
<ToggleRow
label="Batch lists"
description="Allows items to be created with Batch tracking."
checked={config.batchListsEnabled}
busy={saving === "batchListsEnabled"}
onChange={(v) => toggle("batchListsEnabled", v)}
/>
<ToggleRow
label="Warranty periods"
description="Allows warranty-tracked items to pick a coverage period."
checked={config.warrantyPeriodsEnabled}
busy={saving === "warrantyPeriodsEnabled"}
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
/>
<ToggleRow
label="Subcategories"
description="Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a category."
description="Category hierarchy includes a subcategory level. Off means products attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
@@ -119,9 +169,16 @@ export default function ProductSettingsPage() {
)
}
const LABELS: Record<string, string> = {
const LABELS: Record<keyof ProductConfig, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Product variants",
sellingPricesEnabled: "Selling prices",
serialNumbersEnabled: "Serial numbers",
batchListsEnabled: "Batch lists",
warrantyPeriodsEnabled: "Warranty periods",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
function ToggleRow({
@@ -3,26 +3,27 @@
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn, GrnPayment } from "@/types/grn"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { PurchaseInvoice } from "@/types/purchase-invoices"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
@@ -38,12 +39,24 @@ export default function GrnDetailPage() {
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [payments, setPayments] = useState<GrnPayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID())
// Bumped to force a refetch when the browser restores this page from its back-forward
// cache (e.g. Back button after "Create Purchase Invoice" redirected away) — bfcache
// restores the frozen JS/React state verbatim without re-running effects, so without this
// the page would keep showing the pre-invoice GRN state (no linked invoice) after Back.
const [refetchTick, setRefetchTick] = useState(0)
useEffect(() => {
function onPageShow(e: PageTransitionEvent) {
if (e.persisted) setRefetchTick((t) => t + 1)
}
window.addEventListener("pageshow", onPageShow)
return () => window.removeEventListener("pageshow", onPageShow)
}, [])
useEffect(() => {
if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
@@ -53,7 +66,7 @@ export default function GrnDetailPage() {
setUoms(uo.items)
})
.catch(() => {})
}, [grnId])
}, [grnId, refetchTick])
useEffect(() => {
if (!grn) return
@@ -61,16 +74,12 @@ export default function GrnDetailPage() {
}, [grn?.warehouseId])
useEffect(() => {
if (!grn || grn.status === "Draft") return
grnsApi.listPayments(grn.grnId).then(setPayments).catch(() => setPayments([]))
}, [grn?.grnId, grn?.status])
function handlePaid(_updated: Grn, payment: GrnPayment) {
setGrn((prev) =>
prev ? { ...prev, paidAmount: prev.paidAmount + payment.amount, balanceAmount: prev.balanceAmount - payment.amount } : prev
)
setPayments((prev) => [payment, ...prev])
}
if (!grn?.purchaseInvoiceId) return
purchaseInvoicesApi
.get(grn.purchaseInvoiceId)
.then((res) => setLinkedInvoice(res.data))
.catch(() => setLinkedInvoice(null))
}, [grn?.purchaseInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -87,9 +96,7 @@ export default function GrnDetailPage() {
try {
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
setConfirmResult(result)
setGrn((prev) =>
prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo, balanceAmount: result.balanceAmount } : prev
)
setGrn((prev) => (prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo } : prev))
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock. Journal ${result.glJournalNo}.`)
} catch (err) {
setError(errorMessage(err))
@@ -140,7 +147,6 @@ export default function GrnDetailPage() {
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
<GrnStatusBadge status={grn.status} />
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
</div>
<p className="text-base text-muted-foreground">
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
@@ -150,22 +156,14 @@ export default function GrnDetailPage() {
<div className="flex items-center gap-2">
{grn.status !== "Draft" && (
<>
<Link
href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
title="View this GRN's postings in the General Ledger report"
>
<BookOpen className="size-5" />
View in Ledger
</Link>
{grn.balanceAmount > 0 && (
<Button size="lg" onClick={() => setPayDialogOpen(true)}>
<CircleDollarSign className="size-5" />
Pay
</Button>
)}
</>
<Link
href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
title="View this GRN's postings in the General Ledger report"
>
<BookOpen className="size-5" />
View in Ledger
</Link>
)}
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
@@ -176,8 +174,6 @@ export default function GrnDetailPage() {
</div>
</div>
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -314,47 +310,42 @@ export default function GrnDetailPage() {
<span className="text-muted-foreground">Document total</span>
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
</div>
{grn.status !== "Draft" && (
<>
<div className="flex gap-3">
<span className="text-muted-foreground">Amount paid</span>
<span className="tabular-nums">{formatAmount(grn.paidAmount)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">Balance due</span>
<span className="font-semibold tabular-nums">{formatAmount(grn.balanceAmount)}</span>
</div>
</>
)}
</div>
{grn.status !== "Draft" && payments.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-foreground">Payment History</h2>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
<TableHead className="h-12 px-3 text-sm">Reference</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.grnPaymentId}>
<TableCell className="px-3 py-3.5">{new Date(p.paymentDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
<TableCell className="px-3 py-3.5">{p.bankAccountName}</TableCell>
<TableCell className="px-3 py-3.5">{p.reference ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{p.glJournalNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{grn.status === "Confirmed" && (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-5">
<h2 className="text-lg font-semibold text-foreground">Purchase Invoice</h2>
{grn.purchaseInvoiceId ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}</span>
{linkedInvoice && <PurchaseInvoiceStatusBadge status={linkedInvoice.status} />}
{linkedInvoice && linkedInvoice.status !== "Draft" && (
<span className="text-sm text-muted-foreground">
Balance due: {linkedInvoice.totals.balanceAmount.toFixed(2)}
</span>
)}
</div>
<Link
href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<FileText className="size-4" />
View invoice
</Link>
</div>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span>
<Link
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
className={cn(buttonVariants({ size: "sm" }))}
>
<FileText className="size-4" />
Create Purchase Invoice
</Link>
</div>
)}
</div>
)}
</div>
@@ -98,6 +98,64 @@ function newKey() {
return `gline-${keySeq}`
}
let warrantySeq = 0
/** Auto-generated warranty number, pre-filled into the input so the user can accept or edit it. */
function generateWarrantyNumber(): string {
warrantySeq += 1
const stamp = Date.now().toString(36).toUpperCase()
const seq = warrantySeq.toString(36).toUpperCase().padStart(3, "0")
return `WTY-${stamp}-${seq}`
}
/** Splits a warranty number into its non-numeric prefix and trailing digit run, e.g. "WTY-000101" → { prefix: "WTY-", num: 101, width: 6 }. */
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
const m = s.trim().match(/^(.*?)(\d+)$/)
if (!m) return null
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
}
/**
* Splices an "adding number" onto the end of the current number: it keeps everything except
* the trailing characters, replacing as many of them as `adding` is long, e.g. current
* "WTY-000101" + adding "105" (3 chars) → keep "WTY-000", replace the last 3 with "105" →
* "WTY-000105".
*/
function applyAddingNumber(current: string, adding: string): string {
const tail = adding.trim()
if (!tail) return current
if (tail.length >= current.length) return tail
return current.slice(0, current.length - tail.length) + tail
}
/**
* Builds the sequential run of warranty numbers spanning `from` to `to` (inclusive) — both
* must share the same prefix and `to` must not come before `from`. Returns null otherwise
* so the caller can surface a validation message.
*/
function buildWarrantyRange(from: string, to: string): string[] | null {
const f = parseTrailingNumber(from)
const t = parseTrailingNumber(to)
if (!f || !t || f.prefix !== t.prefix) return null
const span = t.num - f.num + 1
if (span <= 0) return null
return Array.from({ length: span }, (_, i) => `${f.prefix}${String(f.num + i).padStart(f.width, "0")}`)
}
/**
* Resizes a line's warrantyNumbers to match its unit count and auto-fills any blank slot
* with a generated number — existing (manually edited) values are left untouched.
*/
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
if (item?.warranty !== "Warranty") return l.warrantyNumbers
const units = unitCount(l.qty)
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
for (let i = 0; i < units; i++) {
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
}
return next
}
function emptyLine(): DraftLine {
return {
key: newKey(),
@@ -218,8 +276,8 @@ export default function NewGrnPage() {
}
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
setLines(
openLines.map(
(l): DraftLine => ({
openLines.map((l): DraftLine => {
const draft: DraftLine = {
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
@@ -231,8 +289,9 @@ export default function NewGrnPage() {
vatPct: headerVatPct.trim() || "0",
holdStatus: "Available",
warrantyNumbers: [],
})
)
}
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
})
)
} catch (err) {
setHeaderError(errorMessage(err))
@@ -257,7 +316,13 @@ export default function NewGrnPage() {
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
setLines((prev) =>
prev.map((l) => {
if (l.key !== key) return l
const merged = { ...l, ...patch }
return { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
})
)
}
function removeLine(key: string) {
@@ -280,6 +345,62 @@ export default function NewGrnPage() {
)
}
// Typed "From" number plus an "Adding number" per line — "Replace numbers" keeps From as-is
// and splices Adding number onto its tail (see applyAddingNumber) to get To, then fills as
// many units as that range spans (from unit 1), leaving any remaining units' numbers untouched.
const [warrantyRangeInputs, setWarrantyRangeInputs] = useState<Record<string, { from: string; adding: string }>>({})
const [warrantyAddErrors, setWarrantyAddErrors] = useState<Record<string, string>>({})
function updateWarrantyRangeInput(lineKey: string, field: "from" | "adding", value: string) {
setWarrantyRangeInputs((prev) => {
const current = prev[lineKey] ?? { from: "", adding: "" }
return { ...prev, [lineKey]: { ...current, [field]: value } }
})
}
// Fills units 1..(To-From+1) with the typed sequence — only as many units as the range
// actually spans. Anything beyond that (the other units' numbers) is left exactly as it
// was: nothing gets wiped just because the range doesn't cover the whole line.
function replaceWarrantyNumbers(lineKey: string, units: number) {
const range = warrantyRangeInputs[lineKey]
if (!range?.from.trim() || !range?.adding.trim()) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
return
}
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
// number the same way "adding" splices onto "From", so you never have to retype the prefix.
const currentFirst = lines.find((l) => l.key === lineKey)?.warrantyNumbers[0]?.trim()
const from = currentFirst ? applyAddingNumber(currentFirst, range.from) : range.from
const to = applyAddingNumber(from, range.adding)
const seq = buildWarrantyRange(from, to)
if (!seq) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
return
}
if (seq.length > units) {
setWarrantyAddErrors((prev) => ({
...prev,
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
}))
return
}
setWarrantyAddErrors((prev) => {
const next = { ...prev }
delete next[lineKey]
return next
})
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = [...l.warrantyNumbers]
while (next.length < units) next.push("")
for (let i = 0; i < seq.length; i++) next[i] = seq[i]
return { ...l, warrantyNumbers: next }
})
)
// From/To are left exactly as typed — not cleared — so a typo is easy to fix in place.
}
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
// the "Warranty numbers" tab needs to capture, one input per unit.
const warrantyLines = lines
@@ -806,17 +927,56 @@ export default function NewGrnPage() {
<div className="flex flex-col gap-6">
{warrantyLines.map(({ line, item, units }) => {
const errors = lineErrors[line.key] ?? {}
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
const addError = warrantyAddErrors[line.key]
return (
<div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">From</Label>
<Input
value={range.from}
onChange={(e) => updateWarrantyRangeInput(line.key, "from", e.target.value)}
placeholder="e.g. 101 or WTY-000101"
className="h-9 w-40 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Adding number</Label>
<Input
value={range.adding}
onChange={(e) => updateWarrantyRangeInput(line.key, "adding", e.target.value)}
placeholder="e.g. 120"
className="h-9 w-40 text-sm"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => replaceWarrantyNumbers(line.key, units)}
>
Replace numbers
</Button>
<span className="text-xs text-muted-foreground">
Type just the trailing digits — From and Adding number each replace that many
characters off the end of unit 1&apos;s current number. Fills starting from unit 1
— units beyond the range keep their number.
</span>
{addError && <FieldError errors={[{ message: addError }]} />}
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
<TableHead className="h-10 px-3 text-sm">
Warranty number
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -6,7 +6,6 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
@@ -23,7 +22,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -175,8 +174,7 @@ export default function GrnListPage() {
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Balance</TableHead>
<TableHead className="h-12 px-3 text-sm">Payment</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
@@ -199,14 +197,15 @@ export default function GrnListPage() {
<TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{grn.status === "Draft" ? "—" : formatAmount(grn.balanceAmount)}
</TableCell>
<TableCell className="px-3 py-3.5">
{grn.status === "Draft" ? (
{grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span>
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
<GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />
<span className="text-sm text-muted-foreground">Not invoiced</span>
)}
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
@@ -1,438 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
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 { Label } from "@/components/ui/label"
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 { BundleSale, BundleSaleTemplate, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
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"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSaleDetailPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(null)
const [bundleName, setBundleName] = useState("")
const [bundlePrice, setBundlePrice] = useState(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
Promise.all([
bundleApi.getBundle(bundleSaleId),
bundleApi.listTemplates({ pageSize: 200 }),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => {
const data = bundleRes
setBundle(data)
setTemplates(templateRes.items)
setCustomers(custRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(data.customerId)
setWarehouseId(data.warehouseId)
setCashierUserId(data.cashierUserId)
setTemplateId(data.bundleSaleTemplateId)
setBundleName(data.bundleName)
setBundlePrice(data.bundlePrice)
setLines(
data.lines.map((line) => ({
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
includeInBundle: line.includeInBundle,
sortOrder: line.bundleSaleLineId,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id, items])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then(setTemplate).catch((err) => setError(errorMessage(err)))
}, [templateId])
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
const isDraft = bundle?.status === "Draft"
const canEdit = isDraft
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = template?.lines[0]
if (!source) return
setLines((prev) => [...prev, createBlankLine(source)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
setBusy(true)
setError(null)
try {
const request: UpdateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
setBundle(res)
setEditing(false)
router.refresh()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function postBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
if (!check.canPost) {
setError("Resolve stock shortages before posting this bundle.")
return
}
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
const shortageIssues = bundle && bundle.status === "Draft"
? bundle.lines
.filter((line) => line.includeInBundle)
.filter((line) => Number(line.qty || 0) > 0)
.map((line) => ({
bundleSaleLineId: line.bundleSaleLineId,
itemId: line.itemId,
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
warehouseId: line.warehouseId,
requestedQty: line.qty,
}))
: []
async function cancelBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-4" />
Print
</Link>
{canEdit ? (
editing ? (
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
<Save className="size-4" />
Save
</Button>
) : (
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
<Edit className="size-4" />
Edit
</Button>
)
) : null}
{isDraft ? (
<>
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
<CheckCircle2 className="size-4" />
Post
</Button>
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
<XCircle className="size-4" />
Cancel
</Button>
</>
) : null}
</div>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Component breakdown</h2>
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
</div>
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
{editing && isDraft ? <TableHead /> : null}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">
{editing && isDraft ? (
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
) : (
line.includeInBundle ? "Included" : "Excluded"
)}
</TableCell>
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
</TableRow>
))}
{lines.length === 0 ? (
<TableRow>
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add line to start.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
</div>
)
}
@@ -1,337 +0,0 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { 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 { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
})
})
: [createBlankLine()]
)
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [items, templateId])
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
return
}
setSaving(true)
setSubmitError(null)
try {
const request: CreateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.createBundle(request)
toast.success("Bundle saved", res.bundleNo)
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Need stock before saving this bundle?</div>
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
</div>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Override allowed</Label>
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
{allowPriceOverride ? "Yes" : "No"}
</button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Editable component rows</h2>
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
</TableCell>
</TableRow>
))}
{lines.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add component to start.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving..." : "Save draft"}
</Button>
</div>
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -1,308 +0,0 @@
"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 { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
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 { 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 { PaginationMeta } from "@/types/common"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
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 BundleSalesPage() {
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cust, whRes]) => {
setCustomers(cust.items)
setWarehouses(whRes.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setError(null)
bundleApi
.listBundles({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
q: query || undefined,
customerId: customerId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(
() =>
rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
.toLowerCase()
.includes(query.toLowerCase())
return matchesStatus && matchesQuery
}) ?? [],
[rows, query, status]
)
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Sales</h1>
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Bundle
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by bundle, code, or customer"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Bundle</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
{row.bundleNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="inline-flex gap-2">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View bundle ${row.bundleNo}`}>
<Eye className="size-4" />
</Link>
<Link href={`/print/sales/bundles/${row.bundleSaleId}`} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Print bundle ${row.bundleNo}`}>
<Printer className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Rows loaded</div>
<div className="text-base font-semibold tabular-nums">{visibleRows.length}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More