Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bebec10eb3 | |||
| 160c687db3 | |||
| d413ad4064 | |||
| 9b8c2493ad | |||
| 99b669395e | |||
| 4cc6a546c7 | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 9a38cc1131 | |||
| 1c5b05e315 | |||
| efa4ad3770 |
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+8
-7
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
@@ -20,4 +20,4 @@ public sealed record SalesFreeIssueSuggestionDto(
|
||||
int SalesSlipId,
|
||||
string SlipNo,
|
||||
DateTime SlipDate,
|
||||
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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,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>();
|
||||
|
||||
+7531
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7580
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7600
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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'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'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'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 font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono 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="font-mono 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="font-mono 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
Reference in New Issue
Block a user