Compare commits

...

8 Commits

Author SHA1 Message Date
Dhananjaya99 d413ad4064 notification module 2026-08-21 12:05:47 +05:30
ImanThiyanga 9b8c2493ad Merge pull request 'feat: add production navigation permissions and produced items page' (#50) from fix/production-costing into Dev
Reviewed-on: #50
2026-08-21 06:32:26 +00:00
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
Dhananjaya99 4cc6a546c7 add day-end and change item model 2026-08-20 14:10:39 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
95 changed files with 34302 additions and 1964 deletions
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
Assert.Equal(7u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -0,0 +1,17 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
@@ -29,6 +29,14 @@ public sealed class BundleSalesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
@@ -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)]
+1
View File
@@ -20,6 +20,7 @@ public static class DocumentTypes
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
+5 -4
View File
@@ -44,11 +44,12 @@ public class Item
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// Optional selling price range and flat price used by Sales only.
/// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
/// </summary>
public decimal? SalePrice { get; set; }
public decimal? SellingMinPrice { get; set; }
public decimal? SellingMaxPrice { get; set; }
public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Notification configuration (new settings module) - singleton root row.
/// </summary>
public class NotificationConfig
{
public const int SingletonId = 1;
public int NotificationConfigId { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
public class NotificationConfigModule
{
public int NotificationConfigModuleId { get; set; }
public int NotificationConfigId { get; set; }
public NotificationConfig? NotificationConfig { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public bool EmailEnabled { get; set; }
public bool SmsEnabled { get; set; }
public bool InAppEnabled { get; set; }
}
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
/// on a write to reject; the frontend honours it by hiding the variant builder.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// on item writes and drive the create/edit screens. Reads are never gated, so existing
/// data stays visible after a flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public bool SellingPricesEnabled { get; set; } = true;
public bool SerialNumbersEnabled { get; set; } = true;
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,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; }
}
+8 -4
View File
@@ -10,7 +10,7 @@ public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
@@ -31,7 +31,7 @@ public sealed record ItemDetailDto(
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
@@ -66,7 +66,9 @@ public sealed class CreateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -96,7 +98,9 @@ public sealed class UpdateItemRequest
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Notifications;
public sealed record NotificationModuleConfigDto(
string ModuleKey,
string Label,
string Description,
bool EmailEnabled,
bool SmsEnabled,
bool InAppEnabled);
public sealed record NotificationConfigDto(
IReadOnlyList<NotificationModuleConfigDto> Modules,
DateTime? UpdatedAt,
int? UpdatedBy);
public sealed class UpdateNotificationModuleRequest
{
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? InAppEnabled { get; set; }
}
public sealed class UpdateNotificationConfigRequest
{
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
}
+40 -2
View File
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
/// terminal output of a run that has produced something, costed the same way
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
/// one, so run and produced line coincide 1:1.
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
/// </summary>
public sealed record ProducedLineDto(
int RunId, string DocNo, ProductionRunStatus Status,
int TemplateId, string TemplateName,
int WarehouseId, string WarehouseName,
int ItemId, string ItemName,
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
decimal UnitCost, decimal TotalCost,
DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
/// matched, not just the current page (there is no pagination here, matching the Sales report
/// convention of flat, filter-bounded rows).</summary>
public sealed record ProducedSummaryDto(
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -51,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
+13 -2
View File
@@ -8,11 +8,21 @@ namespace ERPCore.Dtos.Sales;
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
/// <summary>Read-only visibility into that cashier's Sales Invoices for the date — informational
/// only, appended to <see cref="SalesDayEndDto"/> and <see cref="SalesDayEndPreviewDto"/>.
/// Invoices post their own GL entry on Confirm (SalesPostingService.PostInvoiceAsync) and have no
/// DayEndId, so they're never locked against a close: these fields are computed live from current
/// data on every read, unlike the frozen Slip/Bundle totals alongside them.</summary>
public sealed record SalesDayEndInvoiceSummaryDto(
int InvoiceCount, decimal InvoiceSubtotal, decimal InvoiceDiscountTotal, decimal InvoiceTaxTotal,
decimal InvoiceGrandTotal, IReadOnlyList<string> InvoiceNumbers);
public sealed record SalesDayEndDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown);
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record SalesDayEndSummaryDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
@@ -25,7 +35,8 @@ public sealed record SalesDayEndPreviewDto(
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose);
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose,
SalesDayEndInvoiceSummaryDto Invoices);
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
@@ -4,7 +4,7 @@ public sealed record SalesFreeIssueRewardOptionDto(
int ItemId,
string Sku,
string Name,
decimal? SalePrice);
decimal? SellingFlatPrice);
public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId,
@@ -20,4 +20,4 @@ public sealed record SalesFreeIssueSuggestionDto(
int SalesSlipId,
string SlipNo,
DateTime SlipDate,
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
@@ -20,7 +20,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingMaxPrice).HasPrecision(18, 4);
builder.Property(i => i.SellingFlatPrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
);
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<NotificationConfig>
{
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
{
builder.ToTable("notification_config", t =>
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
builder.HasKey(c => c.NotificationConfigId);
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasMany(c => c.Modules)
.WithOne(m => m.NotificationConfig)
.HasForeignKey(m => m.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration<NotificationConfigModule>
{
public void Configure(EntityTypeBuilder<NotificationConfigModule> builder)
{
builder.ToTable("notification_config_modules");
builder.HasKey(m => m.NotificationConfigModuleId);
builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
builder.HasIndex(m => m.ModuleKey).IsUnique();
builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
}
}
@@ -61,7 +61,11 @@ 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 = 24 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
);
}
}
@@ -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();
@@ -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;
@@ -53,7 +54,11 @@ 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).
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
);
}
}
+43 -27
View File
@@ -43,8 +43,9 @@ public static class DataSeeder
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
@@ -100,7 +101,22 @@ public static class DataSeeder
ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true,
BrandsEnabled = true,
ItemTypesEnabled = true
ItemTypesEnabled = true,
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
});
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
db.NotificationConfig.Add(new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId
});
return true;
}
@@ -182,7 +198,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m,
QtyRemaining = 100m,
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7)
});
db.StockLayers.Add(new StockLayer
@@ -191,7 +207,7 @@ public static class DataSeeder
WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m,
QtyRemaining = 5m,
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6)
});
return true;
@@ -278,7 +294,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 100m,
SellingFlatPrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
@@ -292,7 +308,7 @@ public static class DataSeeder
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 50m,
SellingFlatPrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
@@ -445,7 +461,7 @@ public static class DataSeeder
ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
@@ -454,7 +470,7 @@ public static class DataSeeder
ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
@@ -478,10 +494,10 @@ public static class DataSeeder
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m,
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
@@ -493,8 +509,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -504,8 +520,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -523,12 +539,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
@@ -539,8 +555,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
@@ -550,8 +566,8 @@ public static class DataSeeder
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -569,12 +585,12 @@ public static class DataSeeder
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
@@ -585,8 +601,8 @@ public static class DataSeeder
Description = items[0].Name,
Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductionNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 35, "NAV:production", 14, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 36, "NAV:production.templates", null, 24 },
{ 37, "NAV:production.runs", null, 25 },
{ 38, "NAV:production.produced", null, 26 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 38);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 14);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class itemModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingMinPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingFlatPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingFlatPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingMinPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddNotificationConfigModulePreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_config",
columns: table => new
{
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
table.ForeignKey(
name: "FK_notification_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "notification_config_modules",
columns: table => new
{
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
table.ForeignKey(
name: "FK_notification_config_modules_notification_config_Notificatio~",
column: x => x.NotificationConfigId,
principalTable: "notification_config",
principalColumn: "NotificationConfigId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
ON CONFLICT ("SubNavItemId") DO UPDATE
SET "NavItemId" = EXCLUDED."NavItemId",
"Code" = EXCLUDED."Code",
"Href" = EXCLUDED."Href",
"Icon" = EXCLUDED."Icon",
"Label" = EXCLUDED."Label",
"SortOrder" = EXCLUDED."SortOrder";
""");
migrationBuilder.CreateIndex(
name: "IX_notification_config_UpdatedBy",
table: "notification_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_ModuleKey",
table: "notification_config_modules",
column: "ModuleKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_NotificationConfigId",
table: "notification_config_modules",
column: "NotificationConfigId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "notification_config_modules");
migrationBuilder.DropTable(
name: "notification_config");
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
public partial class UpdateItemSellingPrices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "SalePrice",
table: "items",
newName: "SellingFlatPrice");
migrationBuilder.AddColumn<decimal>(
name: "SellingMinPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "SellingMaxPrice",
table: "items",
type: "numeric(18,4)",
precision: 18,
scale: 4,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SellingMinPrice",
table: "items");
migrationBuilder.DropColumn(
name: "SellingMaxPrice",
table: "items");
migrationBuilder.RenameColumn(
name: "SellingFlatPrice",
table: "items",
newName: "SalePrice");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigFeatureFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BatchListsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SellingPricesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SerialNumbersEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "WarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchListsEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SellingPricesEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SerialNumbersEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "WarrantyPeriodsEnabled",
table: "product_config");
}
}
}
@@ -1745,7 +1745,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 +2342,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 +2965,30 @@ 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 = 24
},
new
{
PermissionId = 37,
Code = "NAV:production.runs",
SubNavItemId = 25
},
new
{
PermissionId = 38,
Code = "NAV:production.produced",
SubNavItemId = 26
});
});
@@ -2932,6 +3041,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 +3062,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 +3083,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");
@@ -5148,6 +5277,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",
@@ -5296,6 +5435,36 @@ namespace ERPCore.Migrations
NavItemId = 12,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 24,
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"
});
});
@@ -6213,6 +6382,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")
@@ -7345,6 +7535,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");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
@@ -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);
@@ -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);
@@ -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
+59 -9
View File
@@ -1,6 +1,7 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
@@ -85,7 +86,8 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
.ToListAsync(ct);
@@ -107,9 +109,11 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
@@ -130,7 +134,9 @@ public sealed class ItemService : IItemService
Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
SellingMinPrice = request.SellingMinPrice,
SellingMaxPrice = request.SellingMaxPrice,
SellingFlatPrice = request.SellingFlatPrice,
ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
@@ -160,9 +166,11 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
// The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any
@@ -190,7 +198,9 @@ public sealed class ItemService : IItemService
item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.SellingMinPrice = request.SellingMinPrice;
item.SellingMaxPrice = request.SellingMaxPrice;
item.SellingFlatPrice = request.SellingFlatPrice;
item.ContentQty = request.ContentQty;
item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty;
@@ -275,10 +285,9 @@ public sealed class ItemService : IItemService
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
/// </summary>
private async Task ValidateReferencesAsync(
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
ProductConfigDto config, CancellationToken ct)
{
var config = (await _config.GetAsync(ct)).Value;
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
@@ -332,6 +341,46 @@ public sealed class ItemService : IItemService
}
}
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
{
if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
422);
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
422);
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
422);
if (!config.WarrantyPeriodsEnabled && warranty == Warranty.Warranty)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Warranty periods are disabled in the product configuration; warranty cannot be Warranty.",
422);
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
@@ -364,7 +413,8 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass,
i.SellingMinPrice, i.SellingMaxPrice, i.SellingFlatPrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
@@ -0,0 +1,143 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class NotificationConfigService : INotificationConfigService
{
private readonly IRepository<NotificationConfig> _config;
private readonly IRepository<NotificationConfigModule> _modules;
private readonly INotificationModuleCatalog _catalog;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public NotificationConfigService(
IRepository<NotificationConfig> config,
IRepository<NotificationConfigModule> modules,
INotificationModuleCatalog catalog,
ICurrentUser currentUser,
IUnitOfWork uow)
{
_config = config;
_modules = modules;
_catalog = catalog;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.Query()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
var catalog = _catalog.GetAll();
var inputs = request.Modules ?? new List<UpdateNotificationModuleRequest>();
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.ModuleKey)))
throw new DomainException(ErrorCodes.Validation, "Notification module keys cannot be empty.", 400);
if (inputs.Select(x => x.ModuleKey.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() != inputs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate notification module keys are not allowed.", 400);
var byKey = inputs.ToDictionary(x => x.ModuleKey.Trim(), StringComparer.OrdinalIgnoreCase);
var unknown = byKey.Keys.Where(k => catalog.All(m => !string.Equals(m.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
if (unknown.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Unknown notification module key(s): {string.Join(", ", unknown)}.", 422);
var missing = catalog.Where(m => !byKey.ContainsKey(m.Key)).Select(m => m.Key).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Missing notification module settings for: {string.Join(", ", missing)}.", 422);
config.Modules.Clear();
foreach (var module in catalog)
{
var input = byKey[module.Key];
config.Modules.Add(new NotificationConfigModule
{
NotificationConfigId = config.NotificationConfigId,
ModuleKey = module.Key,
EmailEnabled = input.EmailEnabled!.Value,
SmsEnabled = input.SmsEnabled!.Value,
InAppEnabled = input.InAppEnabled!.Value
});
}
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
await SaveGuardingConcurrencyAsync(ct);
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default)
{
var key = moduleKey?.Trim();
if (string.IsNullOrWhiteSpace(key)) return Array.Empty<NotificationChannel>();
var row = await _modules.Query().AsNoTracking()
.FirstOrDefaultAsync(m => m.ModuleKey == key, ct);
if (row is null) return Array.Empty<NotificationChannel>();
var channels = new List<NotificationChannel>(3);
if (row.EmailEnabled) channels.Add(NotificationChannel.Email);
if (row.SmsEnabled) channels.Add(NotificationChannel.Sms);
if (row.InAppEnabled) channels.Add(NotificationChannel.InApp);
return channels;
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
}
}
private NotificationConfigDto Map(NotificationConfig config)
{
var rows = config.Modules
.ToDictionary(m => m.ModuleKey, m => m, StringComparer.OrdinalIgnoreCase);
var modules = _catalog.GetAll()
.Select(m =>
{
rows.TryGetValue(m.Key, out var row);
return new NotificationModuleConfigDto(
m.Key,
m.Label,
m.Description,
row?.EmailEnabled ?? false,
row?.SmsEnabled ?? false,
row?.InAppEnabled ?? false);
})
.ToList();
return new NotificationConfigDto(modules, config.UpdatedAt, config.UpdatedBy);
}
}
@@ -0,0 +1,35 @@
using System.Reflection;
using ERPCore.Domain.Notifications;
using ERPCore.Domain.Notifications.Modules;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
public sealed class NotificationModuleCatalog : INotificationModuleCatalog
{
private readonly IReadOnlyList<NotificationModuleDefinition> _modules;
public NotificationModuleCatalog()
{
var moduleType = typeof(SaleCreateNotificationModule).Assembly;
_modules = moduleType.GetTypes()
.Select(t => new { Type = t, Attr = t.GetCustomAttribute<NotificationModuleAttribute>() })
.Where(x => x.Attr is not null)
.Select(x => new NotificationModuleDefinition(x.Attr!.Key, x.Attr.Label, x.Attr.Description, x.Attr.SortOrder))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Key, StringComparer.Ordinal)
.ToList();
var duplicates = _modules.GroupBy(m => m.Key, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicates.Count > 0)
throw new InvalidOperationException($"Duplicate notification module key(s) found: {string.Join(", ", duplicates)}.");
}
public IReadOnlyList<NotificationModuleDefinition> GetAll() => _modules;
public NotificationModuleDefinition? Find(string moduleKey)
=> _modules.FirstOrDefault(m => string.Equals(m.Key, moduleKey, StringComparison.OrdinalIgnoreCase));
}
@@ -12,7 +12,7 @@ namespace ERPCore.Services;
/// <summary>
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
/// Reads are never gated — only writes consult the flags — so switching a feature off
/// Reads are never gated - only writes consult the flags - so switching a feature off
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
/// </summary>
public sealed class ProductConfigService : IProductConfigService
@@ -46,11 +46,15 @@ public sealed class ProductConfigService : IProductConfigService
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
// model binder has already rejected anything that would reach here with a null.
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
config.BrandsEnabled = request.BrandsEnabled!.Value;
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -67,5 +71,7 @@ public sealed class ProductConfigService : IProductConfigService
}
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}
@@ -124,6 +124,75 @@ public sealed class ProductionRunService : IProductionRunService
return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<ProducedListResponseDto> ListProducedAsync(
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
CancellationToken ct = default)
{
var q = _runs.Query().AsNoTracking()
// FR-MFG-05 (ProductionGraphValidator) guarantees exactly one item-bearing
// output per run, always the terminal one — a row exists once that output has
// actually produced something.
.Where(r => r.Stages.Any(s => s.Outputs.Any(o => o.ItemId != null && o.ProducedQty > 0)));
if (from is not null) q = q.Where(r => r.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(r => r.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
if (status is not null) q = q.Where(r => r.Status == status);
// Projected to an anonymous type first, then mapped client-side — EF Core 10 cannot
// translate a record constructor call inside a projection alongside aggregates (same
// failure mode noted in ListAsync above).
var rows = await q
.OrderByDescending(r => r.RunId)
.Select(r => new
{
r.RunId,
r.DocNo,
r.Status,
r.TemplateId,
TemplateName = r.Template!.Name,
r.WarehouseId,
WarehouseName = r.Warehouse!.Name,
Output = r.Stages.SelectMany(s => s.Outputs)
.Where(o => o.ItemId != null)
.Select(o => new { o.ItemId, ItemName = o.Item!.Name, o.ProducedQty, o.ScrappedQty })
.First(),
ConsumedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue),
ReturnedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue),
r.CreatedAt,
r.CompletedAt
})
.ToListAsync(ct);
var items = rows.Select(r =>
{
var good = r.Output.ProducedQty - r.Output.ScrappedQty;
// Same formula PostReceiptAsync uses to cost the finished layer at approve
// (FR-MFG-13): pool / good qty, 6dp. TotalCost carries the pool exactly rather
// than good * unitCost, which drifts from it past ~100 units (see the ledgerValue
// rounding note in PostReceiptAsync).
var pool = r.ConsumedValue - r.ReturnedValue;
var unitCost = good > 0 ? Math.Round(pool / good, 6, MidpointRounding.AwayFromZero) : 0m;
return new ProducedLineDto(
r.RunId, r.DocNo, r.Status, r.TemplateId, r.TemplateName,
r.WarehouseId, r.WarehouseName,
r.Output.ItemId!.Value, r.Output.ItemName,
r.Output.ProducedQty, r.Output.ScrappedQty, good,
unitCost, pool,
r.CreatedAt, r.CompletedAt);
}).ToList();
var summary = new ProducedSummaryDto(
items.Count,
items.Sum(i => i.ProducedQty),
items.Sum(i => i.ScrappedQty),
items.Sum(i => i.GoodQty),
items.Sum(i => i.TotalCost));
return new ProducedListResponseDto(items, summary);
}
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
{
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
@@ -305,17 +374,58 @@ public sealed class ProductionRunService : IProductionRunService
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
// FR-MFG-08: editable until the stage starts. Once started, consumption has
// already happened against the planned figures.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
// input can still move — raising it draws the extra amount via FIFO (mirroring
// StartStageAsync's initial draw), lowering it returns the excess to stock
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
throw new DomainException(ErrorCodes.StageNotEditable,
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
var now = DateTime.UtcNow;
var balances = new Dictionary<int, decimal>();
var consumed = new List<ConsumedInputDto>();
var returned = new List<object>();
foreach (var line in request.Inputs)
{
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
?? throw new DomainException(ErrorCodes.Validation,
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
{
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
var netConsumed = input.ConsumedQty - input.ReturnedQty;
var delta = plannedBase - netConsumed;
if (delta > 0)
{
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
consumed.Add(dto);
}
else if (delta < 0)
{
var qty = -delta;
if (line.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired,
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
returned.Add(new
{
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
reasonCodeId = line.ReasonCodeId
});
}
}
input.PlannedQty = line.PlannedQty;
}
@@ -330,12 +440,15 @@ public sealed class ProductionRunService : IProductionRunService
// Raising an upstream input's planned quantity means the deliveries that made
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
// does not state this; it is the only behaviour consistent with FR-MFG-09.
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
RecomputeReadiness(stage);
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
{
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
returned
});
}, ct);
@@ -344,6 +457,87 @@ public sealed class ProductionRunService : IProductionRunService
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
}
/// <summary>
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
/// (raising planned qty on a running stage) so the two never drift apart.
/// </summary>
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
ProductionRun run, RunStageInput input, Item item, decimal delta,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
input.ConsumedQty += delta;
input.ConsumedValue += value;
var dto = new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
return (dto, entry);
}
/// <summary>
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
/// </summary>
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
ProductionRun run, RunStageInput input, decimal qty,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
// so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
// multiplying lets successive partial returns accumulate drift until the last one
// can exceed ConsumedValue.
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the pool
// lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
qty, unitCost, now, ct);
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
balance += qty;
balances[input.ItemId.Value] = balance;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
var entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
input.ReturnedQty += qty;
input.ReturnedValue += returnValue;
return (unitCost, returnValue, layer, entry);
}
// --- stage actions -------------------------------------------------------
public async Task<StartStageResultDto> StartStageAsync(
@@ -361,7 +555,6 @@ public sealed class ProductionRunService : IProductionRunService
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
var now = DateTime.UtcNow;
var actor = _currentUser.AuditUserId;
// Running balances accumulated per item across the loop: layers written in this
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
@@ -386,27 +579,9 @@ public sealed class ProductionRunService : IProductionRunService
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
ledgerRefs.Add(entry);
input.ConsumedQty += delta;
input.ConsumedValue += value;
consumed.Add(new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
consumed.Add(dto);
}
stage.Status = ProductionStageStatus.InProgress;
@@ -649,36 +824,9 @@ public sealed class ProductionRunService : IProductionRunService
var now = DateTime.UtcNow;
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
// Weighted cost actually consumed for THIS input — cost-preserving by
// construction, so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
returnedQty = request.Qty;
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
// then multiplying lets successive partial returns accumulate drift until the
// last one can exceed ConsumedValue.
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the
// pool lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
layer = await _fifo.CreateInboundLayerAsync(
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
request.Qty, unitCost, now, token);
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
input.ReturnedQty += request.Qty;
input.ReturnedValue += returnValue;
(unitCost, returnValue, layer, entry) =
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
+30 -4
View File
@@ -34,6 +34,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers;
@@ -49,12 +50,13 @@ public sealed class SalesDayEndService : ISalesDayEndService
public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_dayEnds = dayEnds;
_slips = slips;
_bundles = bundles;
_invoices = invoices;
_ledger = ledger;
_items = items;
_numbers = numbers;
@@ -71,6 +73,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
{
var invoiceSummary = await LoadInvoiceSummaryAsync(cashierUserId, businessDate, ct);
var existing = await _dayEnds.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
if (existing is not null)
@@ -89,7 +93,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
cashierUserId, businessDate, true,
existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal,
closedSlipNumbers, closedBundleNumbers,
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>());
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>(), invoiceSummary);
}
var openSlips = await LoadOpenSlipsAsync(cashierUserId, businessDate, ct);
@@ -111,7 +115,8 @@ public sealed class SalesDayEndService : ISalesDayEndService
postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(),
postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(),
draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(),
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList());
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList(),
invoiceSummary);
}
public async Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default)
@@ -265,6 +270,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
.ToListAsync(ct);
}
// Sales Invoices have no CashierUserId (they aren't a cashier document — SalesInvoiceService
// doesn't take one) so CreatedBy is the closest equivalent: whoever was logged in when they
// made the sale. Matched against Posted invoices only, same as the Slip/Bundle totals above.
private async Task<SalesDayEndInvoiceSummaryDto> LoadInvoiceSummaryAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct)
{
var (dayStart, dayEnd) = DayRangeUtc(businessDate);
var invoices = await _invoices.Query().AsNoTracking()
.Where(x => x.CreatedBy == cashierUserId && x.Status == SalesInvoiceStatus.Posted
&& x.InvoiceDate >= dayStart && x.InvoiceDate < dayEnd)
.ToListAsync(ct);
return new SalesDayEndInvoiceSummaryDto(
invoices.Count,
invoices.Sum(x => x.Subtotal),
invoices.Sum(x => x.DiscountTotal),
invoices.Sum(x => x.TaxTotal),
invoices.Sum(x => x.GrandTotal),
invoices.OrderBy(x => x.InvoiceNo).Select(x => x.InvoiceNo).ToList());
}
// SlipDate/BundleDate are stored as DateTime.UtcNow (Kind=Utc) throughout — Npgsql requires
// a matching Kind=Utc here too, or comparisons against the `timestamptz` column throw.
private static (DateTime Start, DateTime End) DayRangeUtc(DateOnly businessDate)
@@ -310,10 +334,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
.OrderByDescending(x => x.Revenue)
.ToList();
var invoiceSummary = await LoadInvoiceSummaryAsync(entity.CashierUserId, entity.BusinessDate, ct);
return new SalesDayEndDto(
entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate,
entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal,
entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt,
slipNumbers, bundleNumbers, breakdown);
slipNumbers, bundleNumbers, breakdown, invoiceSummary);
}
}
@@ -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)));
@@ -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,242 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { PackageCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = ProductionRunStatus | "All"
function runStatusBadgeClass(status: ProductionRunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
}
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
// cost of a fractional-value item is still legible without dragging the whole table wide.
function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
export default function ProducedItemsPage() {
const router = useRouter()
// null = still loading (the codebase convention for "no data yet" vs "empty result").
const [data, setData] = useState<ProducedListResponse | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const load = useCallback(() => {
setLoadError(null)
productionRunsApi
.listProduced({
from: from || undefined,
to: to || undefined,
templateId: templateId ?? undefined,
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
.then(setData)
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
})
}, [from, to, templateId, warehouseId, status])
useEffect(load, [load])
useEffect(() => {
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([templateRes, warehouseRes]) => {
setTemplates(templateRes.items)
setWarehouses(warehouseRes.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
<p className="text-base text-muted-foreground">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setFrom("")
setTo("")
setTemplateId(null)
setWarehouseId(null)
setStatus("All")
}}
>
Clear filters
</Button>
)}
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{data === null ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : data.items.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageCheck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
@@ -58,6 +58,9 @@ export default function ItemDetailPage() {
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("")
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -86,6 +89,9 @@ export default function ItemDetailPage() {
setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "")
setSellingMinPrice(data.sellingMinPrice === null ? "" : String(data.sellingMinPrice))
setSellingMaxPrice(data.sellingMaxPrice === null ? "" : String(data.sellingMaxPrice))
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
}
@@ -130,6 +136,9 @@ export default function ItemDetailPage() {
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
},
@@ -246,6 +255,48 @@ export default function ItemDetailPage() {
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -105,6 +105,7 @@ export default function NewItemPage() {
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
@@ -128,11 +129,11 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only.
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// each row keeps its own flat price in `pricesByKey`.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock")
const [fixValue, setFixValue] = useState<string>("")
const [defaultFlatPrice, setDefaultFlatPrice] = useState<string>("")
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
@@ -144,8 +145,9 @@ export default function NewItemPage() {
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// A row shows its own override if set, otherwise it follows the shared fix value.
const priceFor = (key: string) => pricesByKey[key] ?? fixValue
// Each row owns its own flat price. The default field is only a bulk-fill helper.
const priceFor = (key: string) => pricesByKey[key] ?? ""
const variantPriceFor = (variantKey: string, kind: "min" | "max" | "flat") => pricesByKey[`${variantKey}::${kind}`] ?? ""
useEffect(() => {
Promise.all([
@@ -181,6 +183,35 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
useEffect(() => {
if (!config) return
if (!config.serialNumbersEnabled && trackingMode === "Serial") {
setTrackingMode("None")
}
if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None")
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}
if (!config.sellingPricesEnabled) {
setPriceMode("stock")
setDefaultFlatPrice("")
setPricesByKey({})
setPriceErrors({})
}
}, [
config,
trackingMode,
warranty,
config?.serialNumbersEnabled,
config?.batchListsEnabled,
config?.warrantyPeriodsEnabled,
config?.sellingPricesEnabled,
])
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
@@ -355,7 +386,7 @@ export default function NewItemPage() {
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
@@ -374,7 +405,7 @@ export default function NewItemPage() {
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
if (baseUomId === null) {
@@ -385,6 +416,17 @@ export default function NewItemPage() {
setSubmitting(true)
let created = 0
try {
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
? "None"
: !config?.batchListsEnabled && trackingMode === "Batch"
? "None"
: trackingMode
const effectiveWarranty = config?.warrantyPeriodsEnabled ? warranty : "NonWarranty"
const effectiveWarrantyPeriodMonths =
config?.warrantyPeriodsEnabled && effectiveWarranty === "Warranty" ? warrantyPeriodMonths : null
const allowSellingPrices = !!config?.sellingPricesEnabled
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
@@ -398,10 +440,21 @@ export default function NewItemPage() {
brandId,
baseUomId,
stockNature,
trackingMode: "None",
warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
sellingMinPrice:
allowSellingPrices && variantPriceFor(variant.key, "min").trim()
? Number(variantPriceFor(variant.key, "min"))
: null,
sellingMaxPrice:
allowSellingPrices && variantPriceFor(variant.key, "max").trim()
? Number(variantPriceFor(variant.key, "max"))
: null,
sellingFlatPrice:
allowSellingPrices && sendFlatPrices && priceFor(variant.key).trim()
? Number(priceFor(variant.key))
: null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
@@ -637,118 +690,170 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
</div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
{config?.batchListsEnabled && <SelectItem value="Batch" className="text-base">Batch</SelectItem>}
{config?.serialNumbersEnabled && <SelectItem value="Serial" className="text-base">Serial</SelectItem>}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose a fixed selling price, or leave it to the item&apos;s stock value.
{config?.serialNumbersEnabled ? (
<>
Choose <span className="font-medium">Serial</span> if each unit needs its own serial number on receipt.
</>
) : config?.batchListsEnabled ? (
<>
Batch tracking is available; serial tracking is currently disabled in product settings.
</>
) : (
<>
Tracking is currently disabled in product settings.
</>
)}
</p>
</div>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && (
<div className="flex max-w-xl flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
</div>
)}
{priceMode === "fixed" && (
<div className="flex max-w-xs flex-col gap-2">
<Label className="text-base">Fix value (applies to all variants)</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={fixValue}
onChange={(e) => setFixValue(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Edit any row below to give that variant a different price.
Mark whether every generated variant is sold under warranty.
</p>
</div>
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
)}
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
@@ -918,7 +1023,11 @@ export default function NewItemPage() {
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
<>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
@@ -942,29 +1051,55 @@ export default function NewItemPage() {
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
<>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::min`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::max`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
@@ -12,12 +12,11 @@ import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
*
* `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
* `itemTypesEnabled` has no control on this screen but is still part of the record, so
* every save round-trips its current value unchanged (the server rejects a partial body).
* Item types are the product-variant builder, while the other flags control whether item
* pricing, tracking and warranty-period fields are available when creating or editing an item.
* The server enforces the fields that are truly written to the item record.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
@@ -38,17 +37,28 @@ export default function ProductSettingsPage() {
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
async function toggle(flag: keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
>, next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
sellingPricesEnabled: config.sellingPricesEnabled,
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next,
},
etag ?? "",
@@ -58,7 +68,7 @@ export default function ProductSettingsPage() {
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
load()
} finally {
setSaving(null)
}
@@ -79,7 +89,7 @@ export default function ProductSettingsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && !config && <Skeleton className="h-80 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
@@ -90,9 +100,49 @@ export default function ProductSettingsPage() {
<div className="border-t" />
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<ToggleRow
label="Product variants"
description="Controls the variant builder on item create. Off hides the item-type section."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
/>
<ToggleRow
label="Selling prices"
description="Controls the min, max and flat sale price fields on item create and edit."
checked={config.sellingPricesEnabled}
busy={saving === "sellingPricesEnabled"}
onChange={(v) => toggle("sellingPricesEnabled", v)}
/>
<ToggleRow
label="Serial numbers"
description="Allows items to be created with Serial tracking."
checked={config.serialNumbersEnabled}
busy={saving === "serialNumbersEnabled"}
onChange={(v) => toggle("serialNumbersEnabled", v)}
/>
<ToggleRow
label="Batch lists"
description="Allows items to be created with Batch tracking."
checked={config.batchListsEnabled}
busy={saving === "batchListsEnabled"}
onChange={(v) => toggle("batchListsEnabled", v)}
/>
<ToggleRow
label="Warranty periods"
description="Allows warranty-tracked items to pick a coverage period."
checked={config.warrantyPeriodsEnabled}
busy={saving === "warrantyPeriodsEnabled"}
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
/>
<ToggleRow
label="Subcategories"
description="Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a category."
description="Category hierarchy includes a subcategory level. Off means products attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
@@ -119,9 +169,16 @@ export default function ProductSettingsPage() {
)
}
const LABELS: Record<string, string> = {
const LABELS: Record<keyof ProductConfig, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Product variants",
sellingPricesEnabled: "Selling prices",
serialNumbersEnabled: "Serial numbers",
batchListsEnabled: "Batch lists",
warrantyPeriodsEnabled: "Warranty periods",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
function ToggleRow({
@@ -1,438 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { BundleSale, BundleSaleTemplate, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function statusClass(status: BundleSale["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSaleDetailPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [editing, setEditing] = useState(false)
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(null)
const [bundleName, setBundleName] = useState("")
const [bundlePrice, setBundlePrice] = useState(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
Promise.all([
bundleApi.getBundle(bundleSaleId),
bundleApi.listTemplates({ pageSize: 200 }),
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([bundleRes, templateRes, custRes, itemRes, uomRes, whRes, userRes]) => {
const data = bundleRes
setBundle(data)
setTemplates(templateRes.items)
setCustomers(custRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(data.customerId)
setWarehouseId(data.warehouseId)
setCashierUserId(data.cashierUserId)
setTemplateId(data.bundleSaleTemplateId)
setBundleName(data.bundleName)
setBundlePrice(data.bundlePrice)
setLines(
data.lines.map((line) => ({
key: `${line.bundleSaleLineId}`,
bundleSaleTemplateLineId: line.bundleSaleLineId,
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
includeInBundle: line.includeInBundle,
sortOrder: line.bundleSaleLineId,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id, items])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then(setTemplate).catch((err) => setError(errorMessage(err)))
}, [templateId])
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
const isDraft = bundle?.status === "Draft"
const canEdit = isDraft
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
const source = template?.lines[0]
if (!source) return
setLines((prev) => [...prev, createBlankLine(source)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function saveBundle() {
if (!bundle || !customerId || !warehouseId || !cashierUserId || !templateId) return
setBusy(true)
setError(null)
try {
const request: UpdateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.updateBundle(bundle.bundleSaleId, request)
setBundle(res)
setEditing(false)
router.refresh()
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
async function postBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const check = await bundleApi.checkBundlePosting(bundle.bundleSaleId)
if (!check.canPost) {
setError("Resolve stock shortages before posting this bundle.")
return
}
const updated = await bundleApi.postBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
const shortageIssues = bundle && bundle.status === "Draft"
? bundle.lines
.filter((line) => line.includeInBundle)
.filter((line) => Number(line.qty || 0) > 0)
.map((line) => ({
bundleSaleLineId: line.bundleSaleLineId,
itemId: line.itemId,
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
warehouseId: line.warehouseId,
requestedQty: line.qty,
}))
: []
async function cancelBundle() {
if (!bundle) return
setBusy(true)
setError(null)
try {
const updated = await bundleApi.cancelBundle(bundle.bundleSaleId)
setBundle({ ...bundle, ...updated })
} catch (err) {
setError(errorMessage(err))
} finally {
setBusy(false)
}
}
if (error && !bundle) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!bundle) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle sale...</div>
const printHref = `/print/sales/bundles/${bundle.bundleSaleId}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{bundle.bundleNo}</h1>
<p className="text-base text-muted-foreground">{bundle.bundleName}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-4" />
Print
</Link>
{canEdit ? (
editing ? (
<Button variant="outline" size="lg" onClick={saveBundle} disabled={busy}>
<Save className="size-4" />
Save
</Button>
) : (
<Button variant="outline" size="lg" onClick={() => setEditing(true)} disabled={!isDraft}>
<Edit className="size-4" />
Edit
</Button>
)
) : null}
{isDraft ? (
<>
<Button variant="outline" size="lg" onClick={postBundle} disabled={busy}>
<CheckCircle2 className="size-4" />
Post
</Button>
<Button variant="outline" size="lg" onClick={cancelBundle} disabled={busy}>
<XCircle className="size-4" />
Cancel
</Button>
</>
) : null}
</div>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
</tr>
</thead>
<tbody>
{shortageIssues.map((issue) => (
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder="Cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))} disabled={!editing || !isDraft}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} disabled={!editing || !isDraft} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} disabled={!editing || !isDraft} />
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Component breakdown</h2>
<p className="text-xs text-muted-foreground">{editing ? "Edit the component lines and save." : "Read-only until you enter edit mode."}</p>
</div>
{editing && isDraft ? <Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add line</Button> : <Badge variant="outline" className={statusClass(bundle.status)}>{bundle.status}</Badge>}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
{editing && isDraft ? <TableHead /> : null}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right">
{editing && isDraft ? (
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
) : (
line.includeInBundle ? "Included" : "Excluded"
)}
</TableCell>
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
</TableRow>
))}
{lines.length === 0 ? (
<TableRow>
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add line to start.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
</div>
)
}
@@ -1,337 +0,0 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, CreateBundleSaleRequest } from "@/types/bundles"
type EditableLine = BundleSaleTemplateLine & { key: string }
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
key: crypto.randomUUID(),
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
itemId: templateLine?.itemId ?? 0,
warehouseId: templateLine?.warehouseId ?? 0,
qty: templateLine?.qty ?? 1,
unitPrice: templateLine?.unitPrice ?? 0,
includeInBundle: templateLine?.includeInBundle ?? true,
sortOrder: templateLine?.sortOrder ?? 0,
})
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [templateId, setTemplateId] = useState<number | null>(templateFromQuery ? Number(templateFromQuery) : null)
const [bundleName, setBundleName] = useState("Demo Bundle")
const [bundlePrice, setBundlePrice] = useState<number>(0)
const [allowPriceOverride, setAllowPriceOverride] = useState(false)
const [lines, setLines] = useState<EditableLine[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setTemplates(templateRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setTemplateId((current) => current ?? templateRes.items[0]?.bundleSaleTemplateId ?? null)
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!templateId) return
bundleApi.getTemplate(templateId).then((res) => {
setTemplate(res)
setLines(
res.lines.length > 0
? res.lines.map((line) => {
const item = items.find((candidate) => candidate.itemId === line.itemId)
return createBlankLine({
...line,
})
})
: [createBlankLine()]
)
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
}).catch((err) => setSubmitError(errorMessage(err)))
}, [items, templateId])
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
function updateLine(key: string, patch: Partial<EditableLine>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function addLine() {
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId || !templateId || !template) {
setSubmitError("Select customer, warehouse, cashier, and bundle template.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one bundle component line.")
return
}
setSaving(true)
setSubmitError(null)
try {
const request: CreateBundleSaleRequest = {
customerId,
warehouseId,
cashierUserId,
bundleSaleTemplateId: templateId,
bundleName,
bundlePrice,
allowPriceOverride,
lines: lines.map(({ key, ...line }) => line),
}
const res = await bundleApi.createBundle(request)
toast.success("Bundle saved", res.bundleNo)
router.push(`/dashboard/sales/bundles/${res.bundleSaleId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading masters...</div>
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Create bundle sale</h1>
<p className="text-base text-muted-foreground">Create a fixed bundle from a stored template.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Need stock before saving this bundle?</div>
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
</div>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : "all"} onValueChange={(v) => setCashierUserId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Template</Label>
<Select value={templateId ? String(templateId) : "all"} onValueChange={(v) => setTemplateId(v === "all" ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={templateLabel} /></SelectTrigger>
<SelectContent>{templates.map((t) => <SelectItem key={t.bundleSaleTemplateId} value={String(t.bundleSaleTemplateId)}>{t.templateCode} - {t.templateName}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bundle name</Label>
<Input value={bundleName} onChange={(e) => setBundleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Bundle price</Label>
<Input type="number" min="0" step="0.01" value={bundlePrice} onChange={(e) => setBundlePrice(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>Override allowed</Label>
<button type="button" onClick={() => setAllowPriceOverride((v) => !v)} className={cn("flex h-10 w-full items-center justify-center rounded-md border px-3 text-sm font-medium", allowPriceOverride ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-border text-muted-foreground")}>
{allowPriceOverride ? "Yes" : "No"}
</button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card shadow-sm">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Editable component rows</h2>
<p className="text-xs text-muted-foreground">These rows are sent to the backend and stored with the bundle.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}><Plus className="size-4" /> Add component</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Include</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell className="min-w-72">
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, {
itemId,
unitPrice: item?.salePrice ?? line.unitPrice,
})
}}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.itemId} value={String(item.itemId)}>
{item.sku} - {item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right">
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</TableCell>
<TableCell className="text-right">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
</TableCell>
</TableRow>
))}
{lines.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
No component rows yet. Click Add component to start.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm">
<div className="grid gap-3 md:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Bundle price</div><div className="text-lg font-semibold">{bundlePrice.toFixed(2)}</div></div>
<div><div className="text-xs text-muted-foreground">Margin</div><div className="text-lg font-semibold">{(bundlePrice - componentSubtotal).toFixed(2)}</div></div>
</div>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/bundles" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving..." : "Save draft"}
</Button>
</div>
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -1,308 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PaginationMeta } from "@/types/common"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { BundleSaleStatus, BundleSaleSummary } from "@/types/bundles"
import { cn } from "@/lib/utils"
type StatusFilter = BundleSaleStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: BundleSaleStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function BundleSalesPage() {
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [showFilters, setShowFilters] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerId, warehouseId])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([cust, whRes]) => {
setCustomers(cust.items)
setWarehouses(whRes.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setError(null)
bundleApi
.listBundles({
page,
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
q: query || undefined,
customerId: customerId ?? undefined,
warehouseId: warehouseId ?? undefined,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, query, customerId, warehouseId])
const visibleRows = useMemo(
() =>
rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
.toLowerCase()
.includes(query.toLowerCase())
return matchesStatus && matchesQuery
}) ?? [],
[rows, query, status]
)
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Sales</h1>
<p className="text-base text-muted-foreground">Fixed bundle register with draft, posted, and cancelled states.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={printHref} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/bundles/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Bundle
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by bundle, code, or customer"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{showFilters && (
<div className="grid gap-4 border-b px-4 py-4 md:grid-cols-3">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Customer</div>
<Select value={customerId ? String(customerId) : "all"} onValueChange={(v) => setCustomerId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Warehouse</div>
<Select value={warehouseId ? String(warehouseId) : "all"} onValueChange={(v) => setWarehouseId(v === "all" ? null : Number(v))}>
<SelectTrigger className="h-10">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setCustomerId(null)
setWarehouseId(null)
setStatus("All")
setSearchInput("")
setQuery("")
}}
>
Reset
</Button>
</div>
</div>
)}
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No bundle sales match your filters." : "No bundle sales yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Bundle</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Grand</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.bundleSaleId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className="hover:underline">
{row.bundleNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<div className="inline-flex gap-2">
<Link href={`/dashboard/sales/bundles/${row.bundleSaleId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View bundle ${row.bundleNo}`}>
<Eye className="size-4" />
</Link>
<Link href={`/print/sales/bundles/${row.bundleSaleId}`} target="_blank" rel="noopener noreferrer" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Print bundle ${row.bundleNo}`}>
<Printer className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Rows loaded</div>
<div className="text-base font-semibold tabular-nums">{visibleRows.length}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation"
export default function BundleRegisterPage() {
redirect("/dashboard/sales/bundles")
}
@@ -1,27 +0,0 @@
"use client"
import Link from "next/link"
import { ArrowLeft, FileText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export default function BundleReportsPage() {
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">Bundle Reports</h1>
<p className="text-base text-muted-foreground">Bundle-level reporting will be added after the backend module is wired.</p>
</div>
</div>
<div className="rounded-2xl border border-dashed p-8 text-muted-foreground">
<FileText className="mb-3 size-6" />
This screen is a placeholder for bundle sales reporting.
</div>
</div>
)
}
@@ -0,0 +1,318 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Combobox } from "@/components/ui/combobox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { bundleApi } from "@/lib/api/bundles"
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 { toast } from "@/components/ui/toast"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({
key,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
export default function BundleTemplatesPage() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function refreshTemplates() {
const res = await bundleApi.listTemplates({ pageSize: 50 })
setTemplates(res.items)
}
useEffect(() => {
Promise.all([
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
])
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine("line-1", defaultWarehouseId)])
await refreshTemplates()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items]
)
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function resetForm() {
setTemplateName("")
setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)])
}
const componentSubtotal = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm()
await refreshTemplates()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
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" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Bundle Templates</h1>
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div>
</div>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"}
</Button>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">Bundle details</h2>
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Template name</Label>
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger className="h-9 text-sm">
<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 className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Component items</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add component
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Include</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</TableCell>
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" />
</TableCell>
<TableCell className="px-4 py-2 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="px-4 py-2 text-right">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end border-t border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div>
</section>
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Bundle templates</h3>
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p>
</div>
<Button variant="outline" size="sm" onClick={refreshTemplates}>
Refresh
</Button>
</div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Template</TableHead>
<TableHead className="h-12 px-4 text-sm">Description</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Lines</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>
{templates.length > 0 ? (
templates.map((t) => (
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5">
<div className="font-medium">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">
No bundle templates yet — save one above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
}
@@ -9,7 +9,7 @@ import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndPreview } from "@/types/sales"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -150,7 +150,7 @@ export default function SalesDayEndPage() {
))}
{preview.draftBundleSalesBlockingClose.map((b) => (
<li key={`bundle-${b.bundleSaleId}`}>
<Link href={`/dashboard/sales/bundles/${b.bundleSaleId}`} className="underline">{b.bundleNo}</Link> — {b.grandTotal.toFixed(2)}
{b.bundleNo} — {b.grandTotal.toFixed(2)}
</li>
))}
</ul>
@@ -159,7 +159,9 @@ export default function SalesDayEndPage() {
) : (
<p className="text-base text-muted-foreground">
{preview.slipCount === 0 && preview.bundleCount === 0
? "No sales slips or bundle sales for this cashier on this date."
? preview.invoices.invoiceCount > 0
? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).`
: "No sales slips or bundle sales for this cashier on this date."
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
</p>
)}
@@ -171,6 +173,8 @@ export default function SalesDayEndPage() {
<Stat label="Grand total" value={preview.grandTotal} emphasize />
</div>
<InvoiceSummaryPanel invoices={preview.invoices} />
{!preview.alreadyClosed && (
<div className="flex justify-end">
<Button
@@ -263,12 +267,39 @@ export default function SalesDayEndPage() {
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
</div>
)}
<InvoiceSummaryPanel invoices={report.invoices} />
</div>
)}
</div>
)
}
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
if (invoices.invoiceCount === 0) return null
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/20 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">Sales invoices (not part of this close)</h3>
<p className="text-xs text-muted-foreground">
Invoices post their own ledger entry when confirmed — shown here for visibility only.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Invoices" value={invoices.invoiceCount} isCount />
<Stat label="Subtotal" value={invoices.invoiceSubtotal} />
<Stat label="Discount" value={invoices.invoiceDiscountTotal} />
<Stat label="Tax" value={invoices.invoiceTaxTotal} />
<Stat label="Grand total" value={invoices.invoiceGrandTotal} emphasize />
</div>
<p className="text-sm text-muted-foreground">{invoices.invoiceNumbers.join(", ")}</p>
</div>
)
}
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
@@ -21,6 +21,7 @@ import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
type Line = CreateSalesInvoiceLineRequest & { key: string }
@@ -47,15 +48,9 @@ const blankLine = (key: string): Line => ({
parentLineId: null,
})
function statusClass(status: SalesInvoiceStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
/** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
function statusWord(status: SalesInvoiceStatus): string {
return status === "Posted" ? "approved" : status.toLowerCase()
}
export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -144,11 +139,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
salesApi.listInvoicePayments(invoiceId).then(setPayments).catch(() => setPayments([]))
}, [invoice, invoiceId])
function handlePaid(_updated: SalesInvoice, payment: SalesInvoicePayment) {
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setInvoice((prev) =>
prev
? {
...prev,
status: newStatus,
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
}
: prev
@@ -310,8 +306,10 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Status:</span>
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", statusClass(invoice.status))}>{invoice.status}</span>
<SalesInvoiceStatusBadge status={invoice.status} />
{invoice.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={invoice.totals.paidAmount} balanceAmount={invoice.totals.balanceAmount} />
)}
<span>Type: {invoice.invoiceType}</span>
<span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span>
</div>
@@ -630,7 +628,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</>
) : (
<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 {invoice.status.toLowerCase()} and cannot be edited.</span>
<span>This invoice is {statusWord(invoice.status)} and cannot be edited.</span>
{invoice.status === "Posted" ? (
<div className="flex flex-wrap items-center gap-2">
<Link
@@ -1,21 +1,23 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
@@ -23,15 +25,40 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles"
import { toast } from "@/components/ui/toast"
type Line = CreateSalesInvoiceLineRequest & { key: string }
type ActiveFocScheme = {
id: number
slipNo: string
// bundleGroupId/bundleName/bundleQty/bundleBaseQty are frontend-only display metadata (never sent
// to the API — submit() maps each line to CreateSalesInvoiceLineRequest field-by-field) that let
// component lines added from the same bundle render collapsed into one row, with a single "how
// many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle
// qty from the template so re-scaling on a qty change is exact, not compounding).
type Line = CreateSalesInvoiceLineRequest & {
key: string
bundleGroupId?: string
bundleName?: string
bundleQty?: number
bundleBaseQty?: number
}
type DisplayRow =
| { type: "line"; line: Line }
| { type: "bundle"; groupId: string; bundleName: string; lines: Line[] }
function lineTotals(line: Line) {
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const discount =
line.discountMode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
return { gross, discount, net: Math.max(0, gross - discount) }
}
/** One active Free Issue record's own qty:freeQty ratio, keyed by item — the "Buy `qty` Get
* `freeQty`" deal a line's Free column auto-fills from once its item/qty match. */
type FreeIssueScheme = {
itemId: number
qty: number
freeQty: number
schemeLabel: string
warehouseName: string
productLabel: string
}
const blankLine = (key: string): Line => ({
@@ -57,8 +84,13 @@ const lkr = new Intl.NumberFormat("en-LK", {
minimumFractionDigits: 2,
})
export default function NewSalesInvoicePage() {
function NewSalesInvoiceContent() {
const router = useRouter()
const searchParams = useSearchParams()
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
@@ -67,7 +99,8 @@ export default function NewSalesInvoicePage() {
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [activeFocSchemes, setActiveFocSchemes] = useState<ActiveFocScheme[]>([])
const [freeIssueSchemes, setFreeIssueSchemes] = useState<FreeIssueScheme[]>([])
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
@@ -79,12 +112,14 @@ export default function NewSalesInvoicePage() {
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
salesApi.listFreeIssues({ pageSize: 20 }),
bundleApi.listTemplates({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, freeIssueRes]) => {
.then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setBundleTemplates(templateRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(defaultWarehouseId)
@@ -96,21 +131,17 @@ export default function NewSalesInvoicePage() {
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
])
setActiveFocSchemes(
freeIssueRes.items.flatMap((issue) => {
if (!issue.itemId) return []
const item = itemRes.items.find((candidate) => candidate.itemId === issue.itemId)
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
return [
{
id: issue.salesSlipId,
slipNo: issue.slipNo,
schemeLabel: issue.schemeLabel,
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
productLabel: `${item?.sku ?? issue.itemSku} - ${item?.name ?? issue.itemName}`,
},
]
}),
// Cancelled Free Issues don't represent a live deal — everything else (Draft/Posted)
// does. First match per item wins (listFreeIssues returns newest first).
setFreeIssueSchemes(
freeIssueRes.items
.filter((issue) => issue.status !== "Cancelled" && issue.itemId && issue.qty > 0 && issue.freeQty > 0)
.map((issue) => ({
itemId: issue.itemId,
qty: issue.qty,
freeQty: issue.freeQty,
schemeLabel: issue.schemeLabel,
})),
)
})
.catch((err) => setSubmitError(errorMessage(err)))
@@ -126,14 +157,49 @@ export default function NewSalesInvoicePage() {
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
}
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
}
// Tiered, not continuous: a "Buy 10 Get 2" scheme only pays out in whole multiples of its
// own qty (10 units -> 2 free, 25 units -> still 2, 30 units -> 4) — matches how the Free
// Issue slip itself frames the deal ("Buy {qty} Get {freeQty}"), not a per-unit ratio.
function suggestedFreeQty(itemId: number, qty: number): number {
const scheme = freeIssueSchemeFor(itemId)
if (!scheme || !qty) return 0
return Math.floor(qty / scheme.qty) * scheme.freeQty
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
const qty = Number(lines.find((l) => l.key === key)?.qty ?? 0)
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
freeQty: suggestedFreeQty(itemId, qty),
})
}
function updateQty(key: string, itemId: number, qty: number) {
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
}
// The per-line item search doubles as bundle search: item values are prefixed "item:" and
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
const itemOptions = useMemo(
() => items.map((i) => ({ value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}` })),
[items],
)
const bundleTemplateOptions = useMemo(
() => bundleTemplates.map((t) => ({ value: `bundle:${t.bundleSaleTemplateId}`, label: `Bundle: ${t.templateCode} — ${t.templateName}` })),
[bundleTemplates],
)
const lineComboOptions = useMemo(
() => [...itemOptions, ...bundleTemplateOptions],
[itemOptions, bundleTemplateOptions],
)
function addLine() {
setLines((prev) => [
...prev,
@@ -144,10 +210,114 @@ export default function NewSalesInvoicePage() {
])
}
// A bundle template isn't a stock item itself — it has no itemId of its own — so "adding" one
// expands it into its real component lines (each with its own itemId/qty/price) rather than a
// single pseudo-item row. That's what keeps stock and GL postings correct per component.
// When picked from a specific line's search box (replaceLineKey set), that line is dropped if
// it was still blank — otherwise it's left in place and the bundle's lines are appended after it.
async function addBundle(bundleSaleTemplateId: number, replaceLineKey?: string) {
try {
const template = await bundleApi.getTemplate(bundleSaleTemplateId)
const included = template.lines.filter((l) => l.includeInBundle)
if (included.length === 0) {
toast.error("Bundle has no components", `${template.templateName} has no included items to add.`)
return
}
const groupId = `bundle-${bundleSaleTemplateId}-${bundleGroupSeqRef.current++}`
const newLines = included.map((l, idx) => ({
...blankLine(`${groupId}-${l.bundleSaleTemplateLineId || idx}`),
itemId: l.itemId,
warehouseId: warehouseId ?? l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
freeQty: suggestedFreeQty(l.itemId, l.qty),
bundleGroupId: groupId,
bundleName: template.templateName,
bundleQty: 1,
bundleBaseQty: l.qty,
}))
setLines((prev) => [
...prev.filter((line) => line.key !== replaceLineKey || line.itemId !== 0),
...newLines,
])
toast.success("Bundle added", `${template.templateName} — ${included.length} line(s) added.`)
} catch (err) {
toast.error("Could not add bundle", errorMessage(err))
}
}
// Rescales every component line in the group from its per-bundle base qty (never from the
// already-scaled current qty) so repeated edits stay exact instead of compounding.
function updateBundleQty(groupId: string, rawBundleQty: number) {
const bundleQty = Math.max(1, Math.floor(rawBundleQty) || 1)
setLines((prev) =>
prev.map((line) => {
if (line.bundleGroupId !== groupId) return line
const baseQty = line.bundleBaseQty ?? line.qty
const qty = baseQty * bundleQty
return { ...line, bundleQty, qty, freeQty: suggestedFreeQty(line.itemId, qty) }
}),
)
}
// Combined handler for each line's item/bundle search box — routes to the right action based
// on the "item:"/"bundle:" value prefix (see lineComboOptions above).
function selectLineOption(key: string, value: string | null) {
if (!value) return
const sep = value.indexOf(":")
const kind = value.slice(0, sep)
const id = Number(value.slice(sep + 1))
if (kind === "bundle") {
addBundle(id, key)
} else {
selectItem(key, id)
}
}
// Deep-linked from the Bundle Templates page's "Sell" button (?bundleTemplateId=...) — applies
// once masters have loaded, and only once even if this effect re-runs.
useEffect(() => {
if (loading || !bundleTemplateIdFromQuery) return
if (appliedBundleRef.current === bundleTemplateIdFromQuery) return
appliedBundleRef.current = bundleTemplateIdFromQuery
addBundle(Number(bundleTemplateIdFromQuery))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, bundleTemplateIdFromQuery])
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function removeBundleGroup(groupId: string) {
setLines((prev) => {
const next = prev.filter((line) => line.bundleGroupId !== groupId)
return next.length === 0 ? [{ ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }] : next
})
}
// Groups consecutive component lines added from the same bundle into one display row, so the
// table shows "Bundle: X" once instead of one row per stock item inside it.
const displayRows = useMemo<DisplayRow[]>(() => {
const rows: DisplayRow[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (line.bundleGroupId) {
const groupId = line.bundleGroupId
const groupLines: Line[] = []
while (i < lines.length && lines[i].bundleGroupId === groupId) {
groupLines.push(lines[i])
i++
}
rows.push({ type: "bundle", groupId, bundleName: groupLines[0].bundleName ?? "Bundle", lines: groupLines })
} else {
rows.push({ type: "line", line })
i++
}
}
return rows
}, [lines])
const grossTotal = useMemo(
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
[lines],
@@ -218,262 +388,286 @@ export default function NewSalesInvoicePage() {
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
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/invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Create & open invoice"}
</Button>
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">Invoice header</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<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 className="text-xs">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<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 className="text-xs">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2B">B2B</SelectItem>
<SelectItem value="B2C">B2C</SelectItem>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Credit">Credit</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice summary</Label>
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
Draft
</Badge>
<span>{lkr.format(payableTotal)}</span>
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
{c.customerCode} — {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
<SelectItem value="Cash" className="text-base">Cash</SelectItem>
<SelectItem value="Credit" className="text-base">Credit</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Invoice lines</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">#</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => {
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const lineDiscount =
line.discountMode === "Amount"
? Number(line.discountAmount || 0)
: lineGross * (Number(line.discountPct || 0) / 100)
const lineNet = Math.max(0, lineGross - lineDiscount)
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" onClick={addLine}>
<Plus className="size-5" />
Add line
</Button>
</div>
</div>
return (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-64">
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="1"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
>
<Trash2 className="size-4" />
</Button>
</TableCell>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3 text-sm">#</TableHead>
<TableHead className="h-12 min-w-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</section>
</TableHeader>
<TableBody>
{displayRows.map((row, idx) => {
if (row.type === "bundle") {
const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<TableRow key={row.groupId}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="font-medium">Bundle: {row.bundleName}</div>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
</li>
))}
</ul>
</TableCell>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="1"
step="1"
value={bundleQty}
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
className="h-11 text-right text-sm tabular-nums"
aria-label="Bundle quantity"
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
{bundleFreeQty || "—"}
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
}
<section className="flex justify-end">
{/* <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h3 className="text-sm font-semibold">Active FOC schemes</h3>
<ul className="mt-3 space-y-2 text-sm">
{activeFocSchemes.length > 0 ? (
activeFocSchemes.map((scheme) => (
<li key={scheme.id} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{scheme.schemeLabel}</span>
<span className="text-xs text-muted-foreground">{scheme.slipNo}</span>
</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{scheme.productLabel}</span>
<span>{scheme.warehouseName}</span>
</div>
</li>
))
) : (
<li className="rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
No active free-issue schemes found.
</li>
)}
</ul>
</div> */}
const line = row.line
const { net: lineNet } = lineTotals(line)
const scheme = freeIssueSchemeFor(line.itemId)
<dl className="w-full max-w-sm rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]">
{[
["Gross", lkr.format(grossTotal)],
["Discount", `-${lkr.format(discountTotal)}`],
["Net", lkr.format(netTotal)],
["Tax", lkr.format(taxTotal)],
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between py-1.5">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-mono tabular-nums">{v}</dd>
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-right text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 text-right text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</p>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(lineNet)}</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
))}
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
<dt className="font-semibold">Payable</dt>
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
</div>
</dl>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Save invoice"}
</Button>
</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">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Payable</span>
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewSalesInvoicePage() {
return (
<Suspense fallback={<Skeleton className="h-12 w-full" />}>
<NewSalesInvoiceContent />
</Suspense>
)
}
@@ -2,34 +2,30 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: SalesInvoiceStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
{ value: "Posted", label: "Approved" },
{ value: "Cancelled", label: "Cancelled" },
]
export default function SalesInvoicesPage() {
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
@@ -39,6 +35,9 @@ export default function SalesInvoicesPage() {
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -58,6 +57,34 @@ export default function SalesInvoicesPage() {
.catch((err) => setError(errorMessage(err)))
}, [page, status])
async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId)
try {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.invoiceNo)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
setConfirmingId(null)
}
}
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
setRows(
(prev) =>
prev?.map((r) =>
r.salesInvoiceId === payment.salesInvoiceId
? {
...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) =>
@@ -100,14 +127,14 @@ export default function SalesInvoicesPage() {
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
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 ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
{t.label}
</button>
))}
</div>
@@ -158,7 +185,7 @@ export default function SalesInvoicesPage() {
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -177,18 +204,49 @@ export default function SalesInvoicesPage() {
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.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">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View invoice ${row.invoiceNo}`}
>
<Eye className="size-4" />
</Link>
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "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/sales/invoices/${row.salesInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.invoiceNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
@@ -234,6 +292,8 @@ export default function SalesInvoicesPage() {
</>
)}
</div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { FileBarChart, FileText, Lock, PackageX, ReceiptText } from "lucide-react"
import { FileBarChart, FileText, LayoutList, Lock, PackageX, ReceiptText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -11,6 +11,12 @@ const sections = [
href: "/dashboard/sales/invoices",
icon: FileText,
},
{
title: "Bundle Templates",
description: "Define reusable bundles — sell them straight from Create Sale.",
href: "/dashboard/sales/bundles/templates",
icon: LayoutList,
},
{
title: "Free Issues",
description: "Promotional free-issue slips.",
@@ -0,0 +1,214 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { BellRing, Check, ShieldAlert } from "lucide-react"
import { notificationConfigApi } from "@/lib/api/notification-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { NotificationChannel, NotificationConfig } from "@/types/notifications"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "Email", label: "Email" },
{ key: "Sms", label: "SMS" },
{ key: "InApp", label: "In-App" },
]
export default function NotificationConfigPage() {
const [config, setConfig] = useState<NotificationConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
notificationConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
const modules = config?.modules ?? []
const hasModules = modules.length > 0
const rows = useMemo(
() => modules.map((m) => ({
...m,
enabledCount: [m.emailEnabled, m.smsEnabled, m.inAppEnabled].filter(Boolean).length,
})),
[modules]
)
function updateModule(moduleKey: string, channel: NotificationChannel, checked: boolean) {
setConfig((prev) => {
if (!prev) return prev
return {
...prev,
modules: prev.modules.map((m) =>
m.moduleKey === moduleKey
? {
...m,
emailEnabled: channel === "Email" ? checked : m.emailEnabled,
smsEnabled: channel === "Sms" ? checked : m.smsEnabled,
inAppEnabled: channel === "InApp" ? checked : m.inAppEnabled,
}
: m
),
}
})
}
async function handleSave() {
if (!config) return
setSaving(true)
try {
const res = await notificationConfigApi.update(
{
modules: config.modules.map((m) => ({
moduleKey: m.moduleKey,
emailEnabled: m.emailEnabled,
smsEnabled: m.smsEnabled,
inAppEnabled: m.inAppEnabled,
})),
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Notification configuration saved")
} catch (err) {
const detail = errorMessage(err)
toast.error("Could not save notification configuration", detail)
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
load()
} else {
setError(detail)
}
} finally {
setSaving(false)
}
}
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">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<BellRing className="size-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Notification Config</h1>
<p className="text-base text-muted-foreground">
Choose which channels each module can use. A module may use none, one, or many channels.
</p>
</div>
</div>
<Button size="lg" onClick={handleSave} disabled={saving || !config}>
<Check className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-72 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">
Empty selections are allowed.
</p>
<p className="text-sm text-muted-foreground">
If a module has no channels enabled, the notification resolver skips delivery for that module.
</p>
</div>
</div>
{!hasModules ? (
<div className="rounded-lg border border-dashed px-6 py-16 text-center text-muted-foreground">
No notification modules were discovered in the backend.
</div>
) : (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Module</TableHead>
{CHANNELS.map((channel) => (
<TableHead key={channel.key} className="h-12 px-3 text-sm text-center">
{channel.label}
</TableHead>
))}
<TableHead className="h-12 px-3 text-sm text-center">Enabled</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((module) => (
<TableRow key={module.moduleKey}>
<TableCell className="px-3 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">{module.label}</span>
<span className="text-sm text-muted-foreground">{module.description}</span>
<span className="text-xs text-muted-foreground">{module.moduleKey}</span>
</div>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.emailEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "Email", checked === true)}
aria-label={`${module.label} email channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.smsEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "Sms", checked === true)}
aria-label={`${module.label} sms channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.inAppEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "InApp", checked === true)}
aria-label={`${module.label} in-app channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Badge
variant="outline"
className={cn(
"h-7 min-w-16 justify-center border-transparent px-2.5 text-sm",
module.enabledCount > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
)}
>
{module.enabledCount}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
import { BellRing, Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
const cards = [
{
@@ -14,6 +14,12 @@ const cards = [
icon: Users,
description: "Create users and assign their roles",
},
{
title: "Notification Config",
href: "/dashboard/settings/notification-config",
icon: BellRing,
description: "Choose which channels each module can use",
},
{
title: "Product Configuration",
href: "/dashboard/products/settings",
@@ -1,74 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSale } from "@/types/bundles"
export default function BundlePrintPage() {
const params = useParams<{ id: string }>()
const bundleSaleId = Number(params.id)
const [bundle, setBundle] = useState<BundleSale | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(bundleSaleId)) {
setError(`Invalid bundle id '${params.id}'.`)
return
}
bundleApi.getBundle(bundleSaleId).then((res) => setBundle(res)).catch((err) => setError(errorMessage(err)))
}, [bundleSaleId, params.id])
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 print...</div>
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Bundle Sales</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{bundle.bundleNo}</h1>
<p className="mt-2 text-sm text-muted-foreground">{bundle.bundleName}</p>
</div>
<div className="grid gap-3 text-sm md:grid-cols-3">
<div><div className="text-muted-foreground">Customer</div><div className="font-medium">{bundle.customerSnapshotName}</div></div>
<div><div className="text-muted-foreground">Warehouse</div><div className="font-medium">{bundle.warehouseId}</div></div>
<div><div className="text-muted-foreground">Status</div><div className="font-medium">{bundle.status}</div></div>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bundle.lines.map((line) => (
<TableRow key={line.bundleSaleLineId}>
<TableCell>{line.itemId}</TableCell>
<TableCell>{line.description}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
@@ -1,99 +0,0 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSaleSummary } from "@/types/bundles"
function statusClass(status: BundleSaleSummary["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"
}
}
function BundleBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<BundleSaleSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
bundleApi.listBundles({ page: 1, pageSize: 200 }).then((res) => setRows(res.items)).catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery = `${row.bundleNo} ${row.customerSnapshotName} ${row.bundleName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!rows) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">Loading bundle print data...</div>
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Bundle Sales</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Bundle Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current bundle sales.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bundle</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Grand</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.bundleSaleId}>
<TableCell className="font-medium">{row.bundleNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function BundleBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<BundleBatchPrintContent />
</Suspense>
)
}
@@ -27,9 +27,11 @@ import {
Inbox,
Landmark,
LayoutGrid,
LayoutList,
LayoutTemplate,
LineChart,
ListTree,
Lock,
Menu,
Package,
PackageCheck,
@@ -116,8 +118,9 @@ const navItems: {
chevron: true,
children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
{ title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
@@ -154,6 +157,7 @@ const navItems: {
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
{ title: "Produced", code: "production.produced", href: "/dashboard/production/produced", icon: PackageCheck },
],
},
{
@@ -114,6 +114,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
@@ -7,7 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -17,14 +17,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { toast } from "@/components/ui/toast"
interface SalesInvoicePaymentDialogProps {
invoice: SalesInvoice | null
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
// (id/no/status/balance) is already on the summary, so a list row can open it without a
// separate getInvoice() round trip. SalesInvoice extends SalesInvoiceSummary, so the detail
// page's full invoice still satisfies this as-is.
invoice: SalesInvoiceSummary | null
open: boolean
onOpenChange: (open: boolean) => void
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
* it started that way or this dialog auto-posted it first. */
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
}
/** Record a customer payment against a posted invoice's balance — full or partial, into an
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
* invoice skips straight to payment. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
@@ -64,16 +72,32 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
setSubmitting(true)
try {
if (invoice.status === "Draft") {
try {
await salesApi.postInvoice(invoice.salesInvoiceId)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
return
}
}
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
amount: amountNum,
glBankAccountId: Number(glBankAccountId),
reference: reference || undefined,
})
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
onPaid(invoice, payment)
const fullyPaid = amountNum >= invoice.totals.balanceAmount
toast.success(
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
)
onPaid(payment, "Posted")
onOpenChange(false)
} catch (err) {
toast.error("Could not record payment", errorMessage(err))
toast.error(
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
errorMessage(err)
)
} finally {
setSubmitting(false)
}
@@ -83,10 +107,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Pay invoice {invoice.invoiceNo}</DialogTitle>
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
</p>
@@ -134,7 +159,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : "Record payment"}
{submitting
? "Recording…"
: invoice.status === "Draft"
? "Confirm & record payment"
: "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,57 @@
import { Badge } from "@/components/ui/badge"
import { SalesInvoiceStatus } from "@/types/sales"
// Same fixed-width convention as components/receiving/status-badges.tsx's GRN badges — every
// pill is the same size regardless of label length ("Draft" vs "Approved" vs "Partial").
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
function statusClass(status: SalesInvoiceStatus) {
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 — the underlying SalesInvoiceStatus value
// stays "Posted" (that's what the API sends/expects), only the displayed label changes.
const statusLabel: Record<SalesInvoiceStatus, string> = {
Draft: "Draft",
Posted: "Approved",
Cancelled: "Cancelled",
}
export function SalesInvoiceStatusBadge({ status }: { status: SalesInvoiceStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${statusClass(status)}`}>
{statusLabel[status]}
</Badge>
)
}
export type SalesPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function salesPaymentStatus(paidAmount: number, balanceAmount: number): SalesPaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function paymentClass(status: SalesPaymentStatus) {
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 paymentLabel: Record<SalesPaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function SalesPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = salesPaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
+36 -1
View File
@@ -47,7 +47,36 @@ export function readCsrfToken(): string | null {
return match ? decodeURIComponent(match[1]) : null
}
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
// erp_at is short-lived; erp_rt (a separate, refresh-route-scoped cookie) outlives it. Before
// this, an expired erp_at surfaced as a bare 401 on whatever the user happened to click next
// (confirming an invoice, recording a payment, ...) with no attempt to renew the session — this
// dedupes concurrent 401s onto one /auth/refresh-token call so every caller gets one retry.
let refreshPromise: Promise<boolean> | null = null
function refreshSession(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = fetch(`${API_BASE}/auth/refresh-token`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: "{}",
})
.then((res) => res.ok)
.catch(() => false)
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
function redirectToLogin() {
if (typeof window === "undefined") return
const next = `${window.location.pathname}${window.location.search}`
window.location.href = `/login?next=${encodeURIComponent(next)}`
}
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
@@ -70,6 +99,12 @@ async function rawRequest(path: string, options: RequestOptions = {}): Promise<R
body: isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : undefined,
})
if (response.status === 401 && !isRetry && !path.startsWith("/auth/")) {
const refreshed = await refreshSession()
if (refreshed) return rawRequest(path, options, true)
redirectToLogin()
}
if (!response.ok) {
let problem: ProblemDetails
try {
+5
View File
@@ -7,6 +7,7 @@ import {
BundleSaleTemplate,
BundleSaleTemplateSummary,
CreateBundleSaleRequest,
CreateBundleSaleTemplateRequest,
UpdateBundleSaleRequest,
} from "@/types/bundles"
@@ -46,4 +47,8 @@ export const bundleApi = {
getTemplate(bundleSaleTemplateId: number): Promise<BundleSaleTemplate> {
return apiRequest<BundleSaleTemplate>(`/bundle-sales/templates/${bundleSaleTemplateId}`)
},
createTemplate(request: CreateBundleSaleTemplateRequest): Promise<BundleSaleTemplate> {
return apiRequest<BundleSaleTemplate>("/bundle-sales/templates", { method: "POST", body: request })
},
}
@@ -0,0 +1,13 @@
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { NotificationConfig, UpdateNotificationConfigRequest } from "@/types/notifications"
export const notificationConfigApi = {
get(): Promise<ApiResult<NotificationConfig>> {
return apiRequestWithETag<NotificationConfig>("/notification-config")
},
update(request: UpdateNotificationConfigRequest, ifMatch: string): Promise<ApiResult<NotificationConfig>> {
return apiRequestWithETag<NotificationConfig>("/notification-config", { method: "PUT", body: request, ifMatch })
},
}
@@ -1,9 +1,9 @@
// Product configuration (docs/11-BACKEND-PHASE1.md §2.8; FR-MD-11). Singleton — no id.
// Product configuration (docs/11-BACKEND-PHASE1.md §2.8; FR-MD-11). Singleton - no id.
//
// `subcategoriesEnabled`/`brandsEnabled` are enforced server-side: an item write carrying
// a gated field while its flag is off returns 422 CONFIG_DISABLED. `itemTypesEnabled` is
// ADVISORY — items hold no item-type reference, so there is nothing for the server to
// reject; this frontend is what honours it by hiding the builder's type section.
// ADVISORY - items hold no item-type reference, so there is nothing for the server to
// reject; the frontend is what honours it by hiding the variant builder.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
@@ -13,7 +13,7 @@ export const productConfigApi = {
return apiRequestWithETag<ProductConfig>("/product-config")
},
/** All three flags are required; a partial body is a 400, never a silent disable. */
/** All flags are required; a partial body is a 400, never a silent disable. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
@@ -18,6 +18,7 @@ import {
CancelRunResult,
CompleteStageRequest,
CreateRunRequest,
ProducedListResponse,
ProductionRunGraph,
ProductionRunStatus,
ProductionRunSummary,
@@ -43,11 +44,24 @@ export interface ListRunsParams {
sort?: string
}
export interface ListProducedParams {
from?: string
to?: string
templateId?: number
warehouseId?: number
status?: ProductionRunStatus
}
export const productionRunsApi = {
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
},
/** Produced-items report (FR-MFG-13): flat rows + a summary total, no pagination. */
listProduced(params: ListProducedParams = {}): Promise<ProducedListResponse> {
return apiRequest<ProducedListResponse>(`/production-runs/produced${buildQuery(params)}`)
},
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
},
+2 -2
View File
@@ -2,5 +2,5 @@ import { ItemListItem } from "@/types/master-data"
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
if (!itemId) return null
return items.find((item) => item.itemId === itemId)?.salePrice ?? null
}
return items.find((item) => item.itemId === itemId)?.sellingFlatPrice ?? null
}
+15
View File
@@ -34,6 +34,21 @@ export interface BundleSaleTemplate {
lines: BundleSaleTemplateLine[]
}
export interface CreateBundleSaleTemplateLineRequest {
itemId: number
warehouseId: number
qty: number
unitPrice: number
includeInBundle: boolean
sortOrder: number
}
export interface CreateBundleSaleTemplateRequest {
templateName: string
description?: string | null
lines: CreateBundleSaleTemplateLineRequest[]
}
export interface BundleSaleLine {
bundleSaleLineId: number
itemId: number
@@ -112,6 +112,8 @@ export interface GeneralLedgerRow {
debitAmount: number
creditAmount: number
runningBalance: number
/** Which module posted this journal (GRN, Sales, ManualLedgerEntry, Acc_Account, ...) — shown as the "Controller" column. */
sourceModule: string | null
}
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
+17 -3
View File
@@ -50,7 +50,9 @@ export interface ItemListItem extends ItemContent {
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
sellingMinPrice: number | null
sellingMaxPrice: number | null
sellingFlatPrice: number | null
status: EntityStatus
}
@@ -85,7 +87,9 @@ export interface Item extends ItemContent {
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
sellingMinPrice: number | null
sellingMaxPrice: number | null
sellingFlatPrice: number | null
status: EntityStatus
reorder: ItemReorderSetting[]
createdAt: string
@@ -112,7 +116,9 @@ export interface CreateItemRequest {
warrantyPeriodMonths?: WarrantyPeriodMonths | null
taxClass?: string | null
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
salePrice?: number | null
sellingMinPrice?: number | null
sellingMaxPrice?: number | null
sellingFlatPrice?: number | null
/**
* Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is
* derived server-side and is deliberately not accepted here.
@@ -281,6 +287,10 @@ export interface ProductConfig {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
sellingPricesEnabled: boolean
serialNumbersEnabled: boolean
batchListsEnabled: boolean
warrantyPeriodsEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
@@ -290,4 +300,8 @@ export interface UpdateProductConfigRequest {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
sellingPricesEnabled: boolean
serialNumbersEnabled: boolean
batchListsEnabled: boolean
warrantyPeriodsEnabled: boolean
}
@@ -0,0 +1,27 @@
export type NotificationChannel = "Email" | "Sms" | "InApp"
export interface NotificationModuleConfig {
moduleKey: string
label: string
description: string
emailEnabled: boolean
smsEnabled: boolean
inAppEnabled: boolean
}
export interface NotificationConfig {
modules: NotificationModuleConfig[]
updatedAt: string | null
updatedBy: number | null
}
export interface UpdateNotificationModuleRequest {
moduleKey: string
emailEnabled?: boolean | null
smsEnabled?: boolean | null
inAppEnabled?: boolean | null
}
export interface UpdateNotificationConfigRequest {
modules: UpdateNotificationModuleRequest[]
}
+42
View File
@@ -306,6 +306,46 @@ export interface ProductionRunSummary {
completedAt: string | null
}
/**
* One row of the produced-items report (`GET /production-runs/produced`) — the terminal
* output of a run that has produced something. `unitCost`/`totalCost` use the same
* `pool / goodQty` formula the server applies at approve (FR-MFG-13); they are final only
* once `status` is `"Completed"` — a leftover return can still shrink the pool between the
* terminal stage's Done and its Approve.
*/
export interface ProducedLine {
runId: number
docNo: string
status: ProductionRunStatus
templateId: number
templateName: string
warehouseId: number
warehouseName: string
itemId: number
itemName: string
producedQty: number
scrappedQty: number
goodQty: number
unitCost: number
totalCost: number
createdAt: string
completedAt: string | null
}
/** Aggregate footer over every row the filter matched (no pagination). */
export interface ProducedSummary {
runCount: number
totalProducedQty: number
totalScrappedQty: number
totalGoodQty: number
totalCost: number
}
export interface ProducedListResponse {
items: ProducedLine[]
summary: ProducedSummary
}
export interface ProductionRunGraph {
runId: number
docNo: string
@@ -338,6 +378,8 @@ export interface CreateRunRequest {
export interface StageQuantityLine {
id: number
plannedQty: number
/** Required only when this lowers a running Stock input below what it has already consumed. */
reasonCodeId?: number | null
}
export interface UpdateStageQuantitiesRequest {
+16 -1
View File
@@ -299,7 +299,7 @@ export interface SalesFreeIssueRewardOption {
itemId: number
sku: string
name: string
salePrice: number | null
sellingFlatPrice: number | null
}
export interface SalesFreeIssueSuggestionLine {
@@ -399,6 +399,19 @@ export interface SalesDayEndItemLine {
revenue: number
}
/** Read-only visibility into that cashier's Sales Invoices for the date — informational only.
* Invoices post their own GL entry on Confirm and have no DayEndId, so they're never locked
* against a close: this is computed live on every read, unlike the frozen Slip/Bundle totals
* alongside it. */
export interface SalesDayEndInvoiceSummary {
invoiceCount: number
invoiceSubtotal: number
invoiceDiscountTotal: number
invoiceTaxTotal: number
invoiceGrandTotal: number
invoiceNumbers: string[]
}
export interface SalesDayEnd {
salesDayEndId: number
docNo: string
@@ -418,6 +431,7 @@ export interface SalesDayEnd {
slipNumbers: string[]
bundleNumbers: string[]
itemBreakdown: SalesDayEndItemLine[]
invoices: SalesDayEndInvoiceSummary
}
export interface SalesDayEndSummary {
@@ -458,6 +472,7 @@ export interface SalesDayEndPreview {
bundleNumbers: string[]
draftSlipsBlockingClose: DraftSlipBlockingClose[]
draftBundleSalesBlockingClose: DraftBundleBlockingClose[]
invoices: SalesDayEndInvoiceSummary
}
export interface CreateSalesDayEndRequest {