Merge pull request 'notification module' (#51) from feat/notification-module into Dev
Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Notifications;
|
||||
|
||||
public sealed record NotificationModuleConfigDto(
|
||||
string ModuleKey,
|
||||
string Label,
|
||||
string Description,
|
||||
bool EmailEnabled,
|
||||
bool SmsEnabled,
|
||||
bool InAppEnabled);
|
||||
|
||||
public sealed record NotificationConfigDto(
|
||||
IReadOnlyList<NotificationModuleConfigDto> Modules,
|
||||
DateTime? UpdatedAt,
|
||||
int? UpdatedBy);
|
||||
|
||||
public sealed class UpdateNotificationModuleRequest
|
||||
{
|
||||
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
|
||||
[Required] public bool? EmailEnabled { get; set; }
|
||||
[Required] public bool? SmsEnabled { get; set; }
|
||||
[Required] public bool? InAppEnabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateNotificationConfigRequest
|
||||
{
|
||||
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<NotificationConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
|
||||
{
|
||||
builder.ToTable("notification_config", t =>
|
||||
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.NotificationConfigId);
|
||||
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasMany(c => c.Modules)
|
||||
.WithOne(m => m.NotificationConfig)
|
||||
.HasForeignKey(m => m.NotificationConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration<NotificationConfigModule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationConfigModule> builder)
|
||||
{
|
||||
builder.ToTable("notification_config_modules");
|
||||
builder.HasKey(m => m.NotificationConfigModuleId);
|
||||
|
||||
builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
|
||||
builder.HasIndex(m => m.ModuleKey).IsUnique();
|
||||
|
||||
builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Generated
+7580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotificationConfigModulePreferences : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "notification_config",
|
||||
columns: table => new
|
||||
{
|
||||
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
|
||||
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_notification_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "notification_config_modules",
|
||||
columns: table => new
|
||||
{
|
||||
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
|
||||
table.ForeignKey(
|
||||
name: "FK_notification_config_modules_notification_config_Notificatio~",
|
||||
column: x => x.NotificationConfigId,
|
||||
principalTable: "notification_config",
|
||||
principalColumn: "NotificationConfigId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
|
||||
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
|
||||
ON CONFLICT ("SubNavItemId") DO UPDATE
|
||||
SET "NavItemId" = EXCLUDED."NavItemId",
|
||||
"Code" = EXCLUDED."Code",
|
||||
"Href" = EXCLUDED."Href",
|
||||
"Icon" = EXCLUDED."Icon",
|
||||
"Label" = EXCLUDED."Label",
|
||||
"SortOrder" = EXCLUDED."SortOrder";
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_UpdatedBy",
|
||||
table: "notification_config",
|
||||
column: "UpdatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_modules_ModuleKey",
|
||||
table: "notification_config_modules",
|
||||
column: "ModuleKey",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_notification_config_modules_NotificationConfigId",
|
||||
table: "notification_config_modules",
|
||||
column: "NotificationConfigId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "notification_config_modules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "notification_config");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductConfigFeatureFlags : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BatchListsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SellingPricesEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SerialNumbersEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WarrantyPeriodsEnabled",
|
||||
table: "product_config",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BatchListsEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SellingPricesEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SerialNumbersEnabled",
|
||||
table: "product_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WarrantyPeriodsEnabled",
|
||||
table: "product_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2354,6 +2354,74 @@ namespace ERPCore.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -2973,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")
|
||||
@@ -2989,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")
|
||||
@@ -3000,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");
|
||||
@@ -5189,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",
|
||||
@@ -6284,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")
|
||||
@@ -7416,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");
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -108,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);
|
||||
@@ -163,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
|
||||
@@ -280,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);
|
||||
|
||||
@@ -337,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 —
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
@@ -129,15 +130,12 @@ export default function NewItemPage() {
|
||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice 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.
|
||||
// "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>>({})
|
||||
const [sellingMinPrice, setSellingMinPrice] = useState("")
|
||||
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
|
||||
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
|
||||
|
||||
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
@@ -147,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([
|
||||
@@ -184,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
|
||||
@@ -358,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.
|
||||
@@ -377,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) {
|
||||
@@ -388,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({
|
||||
@@ -401,14 +440,21 @@ export default function NewItemPage() {
|
||||
brandId,
|
||||
baseUomId,
|
||||
stockNature,
|
||||
trackingMode: "None",
|
||||
warranty,
|
||||
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
|
||||
sellingMinPrice: null,
|
||||
sellingMaxPrice: null,
|
||||
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
|
||||
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
|
||||
sellingFlatPrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : (sellingFlatPrice.trim() ? Number(sellingFlatPrice) : 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).
|
||||
@@ -644,168 +690,170 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Selling prices</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">
|
||||
Optional values for the item. Leave any field blank if it does not apply.
|
||||
{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 className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<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-11 text-base"
|
||||
/>
|
||||
</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-11 text-base"
|
||||
/>
|
||||
</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-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
|
||||
sellingFlatPrice=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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a fixed selling price, or leave it to the item's stock value.
|
||||
</p>
|
||||
</div>
|
||||
{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 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-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"
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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. */}
|
||||
@@ -975,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.
|
||||
@@ -999,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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -287,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
|
||||
}
|
||||
@@ -296,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[]
|
||||
}
|
||||
Reference in New Issue
Block a user