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:
2026-08-21 06:40:40 +00:00
41 changed files with 16868 additions and 228 deletions
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
Assert.Equal(7u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -0,0 +1,17 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
@@ -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; namespace ERPCore.Domain.Entities;
/// <summary> /// <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. /// gating optional product master-data features.
/// <para> /// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced /// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is /// 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 /// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>), /// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
/// so there is nothing on a write to reject; the frontend honours it by hiding the /// on a write to reject; the frontend honours it by hiding the variant builder.
/// builder's type section. Reads are never gated, so existing data stays visible after a /// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// flag is switched off. /// <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> /// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1. /// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary> /// </summary>
public class ProductConfig 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 const int SingletonId = 1;
public int ConfigId { get; set; } public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true; public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true; public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { 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; } 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> /// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton. /// 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> /// </summary>
public sealed record ProductConfigDto( public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled, bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy); DateTime? UpdatedAt, int? UpdatedBy);
/// <summary> /// <summary>
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
/// <para> /// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool /// 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 /// 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 /// <c>false</c> and silently switch everything off. Nullable makes the requirement actually
/// actually bind — an omitted flag is a 400, not an accidental disable. /// bind - an omitted flag is a 400, not an accidental disable.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class UpdateProductConfigRequest public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; } [Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; } [Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { 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);
}
}
@@ -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.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true); builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).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). // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion(); 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 = 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 = 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 = 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 }, 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. // 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; // 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); var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct); dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct); // dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct); dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct); dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct); dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct); dirty |= await SeedSalesAsync(db, ct);
@@ -100,7 +101,22 @@ public static class DataSeeder
ConfigId = ProductConfig.SingletonId, ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true, SubcategoriesEnabled = true,
BrandsEnabled = 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; return true;
} }
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary> /// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>(); 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) --- // --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
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);
}
}
}
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 => modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{ {
b.Property<int>("SequenceId") b.Property<int>("SequenceId")
@@ -2973,6 +3041,11 @@ namespace ERPCore.Migrations
b.Property<int>("ConfigId") b.Property<int>("ConfigId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<bool>("BatchListsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("BrandsEnabled") b.Property<bool>("BrandsEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("boolean") .HasColumnType("boolean")
@@ -2989,6 +3062,16 @@ namespace ERPCore.Migrations
.HasColumnType("xid") .HasColumnType("xid")
.HasColumnName("xmin"); .HasColumnName("xmin");
b.Property<bool>("SellingPricesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SerialNumbersEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SubcategoriesEnabled") b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("boolean") .HasColumnType("boolean")
@@ -3000,6 +3083,11 @@ namespace ERPCore.Migrations
b.Property<int?>("UpdatedBy") b.Property<int?>("UpdatedBy")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<bool>("WarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.HasKey("ConfigId"); b.HasKey("ConfigId");
b.HasIndex("UpdatedBy"); b.HasIndex("UpdatedBy");
@@ -5189,6 +5277,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 24,
Code = "settings.notification-config",
Href = "/dashboard/settings/notification-config",
Label = "Notification Config",
NavItemId = 9,
SortOrder = 3,
Status = "Active"
},
new
{ {
SubNavItemId = 23, SubNavItemId = 23,
Code = "sales.bundle-sales", Code = "sales.bundle-sales",
@@ -6284,6 +6382,27 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
@@ -7416,6 +7535,11 @@ namespace ERPCore.Migrations
b.Navigation("Children"); b.Navigation("Children");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.Navigation("Modules");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.Navigation("Components"); b.Navigation("Components");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>(); builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>(); //builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>(); builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>(); builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>(); 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);
}
+49 -5
View File
@@ -1,6 +1,7 @@
using ERPCore.Common.Http; using ERPCore.Common.Http;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items; using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -108,9 +109,11 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct)) 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); throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync( await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId, 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); ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(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)) && 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); throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync( await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId, 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 — // 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 // 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). /// it is advisory and honoured by the frontend only (docs/11 §2.8).
/// </summary> /// </summary>
private async Task ValidateReferencesAsync( 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)) if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); 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> /// <summary>
/// A warranty-tracked item must declare a coverage period (one of /// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none — /// <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> /// <summary>
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. /// 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). /// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
/// </summary> /// </summary>
public sealed class ProductConfigService : IProductConfigService public sealed class ProductConfigService : IProductConfigService
@@ -46,11 +46,15 @@ public sealed class ProductConfigService : IProductConfigService
if (config.RowVersion != expectedRowVersion) if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); 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. // model binder has already rejected anything that would reach here with a null.
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
config.BrandsEnabled = request.BrandsEnabled!.Value; config.BrandsEnabled = request.BrandsEnabled!.Value;
config.ItemTypesEnabled = request.ItemTypesEnabled!.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.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
@@ -67,5 +71,7 @@ public sealed class ProductConfigService : IProductConfigService
} }
private static ProductConfigDto Map(ProductConfig c) => new( 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 [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null) const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked") const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("") const [productName, setProductName] = useState("")
@@ -129,15 +130,12 @@ export default function NewItemPage() {
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set()) const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value); // 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 // "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only. // each row keeps its own flat price in `pricesByKey`.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock") 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 [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = 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. // Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty") const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
@@ -147,8 +145,9 @@ export default function NewItemPage() {
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// A row shows its own override if set, otherwise it follows the shared fix value. // Each row owns its own flat price. The default field is only a bulk-fill helper.
const priceFor = (key: string) => pricesByKey[key] ?? fixValue const priceFor = (key: string) => pricesByKey[key] ?? ""
const variantPriceFor = (variantKey: string, kind: "min" | "max" | "flat") => pricesByKey[`${variantKey}::${kind}`] ?? ""
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -184,6 +183,35 @@ export default function NewItemPage() {
.catch(() => setSubCategories([])) .catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled]) }, [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 ?? "" const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/** /**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated * 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) { if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time." 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" nextErrors.warrantyPeriodMonths = "Select a warranty period"
} }
// Should never fire — addValue is the real gate — so it catches stale state only. // Should never fire — addValue is the real gate — so it catches stale state only.
@@ -377,7 +405,7 @@ export default function NewItemPage() {
setErrors(nextErrors) setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0. // In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors = const nextPriceErrors =
priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {} config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors) setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
if (baseUomId === null) { if (baseUomId === null) {
@@ -388,6 +416,17 @@ export default function NewItemPage() {
setSubmitting(true) setSubmitting(true)
let created = 0 let created = 0
try { 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) { for (const variant of variants) {
const content = contentForVariant(variant) const content = contentForVariant(variant)
await itemsApi.create({ await itemsApi.create({
@@ -401,14 +440,21 @@ export default function NewItemPage() {
brandId, brandId,
baseUomId, baseUomId,
stockNature, stockNature,
trackingMode: "None", trackingMode: effectiveTrackingMode,
warranty, warranty: effectiveWarranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null, warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
sellingMinPrice: null, sellingMinPrice:
sellingMaxPrice: null, allowSellingPrices && variantPriceFor(variant.key, "min").trim()
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null, ? Number(variantPriceFor(variant.key, "min"))
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null, : null,
sellingFlatPrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : (sellingFlatPrice.trim() ? Number(sellingFlatPrice) : 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; // 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 // 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). // isn't size (colour variants of one 500 ml bottle all hold 500 ml).
@@ -644,168 +690,170 @@ export default function NewItemPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> <div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<div className="flex flex-col gap-4 rounded-xl border p-5"> <Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<div> <SelectTrigger className="h-12! w-full text-base">
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2> <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"> <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> </p>
</div> </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> </div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends {config?.sellingPricesEnabled && (
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 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> <div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2> <h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose a fixed selling price, or leave it to the item&apos;s stock value. Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p> </p>
</div> </div>
<div className="inline-flex w-fit rounded-lg border p-1"> <div className="inline-flex w-fit rounded-lg border p-1">
<button <button
type="button" type="button"
onClick={() => setPriceMode("stock")} onClick={() => setPriceMode("stock")}
className={cn( className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors", "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", priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)} )}
> >
Use stock value Use stock value
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPriceMode("fixed")} onClick={() => setPriceMode("fixed")}
className={cn( className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors", "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", priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)} )}
> >
Fixed price Fixed price
</button> </button>
</div> </div>
</div>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<div className="flex max-w-xs flex-col gap-2"> <div className="flex max-w-xl flex-wrap items-end gap-3">
<Label className="text-base">Fix value (applies to all variants)</Label> <div className="flex min-w-56 flex-1 flex-col gap-2">
<Input <Label className="text-base">Bulk default flat price</Label>
type="number" <Input
min="0" type="number"
step="0.01" min="0"
inputMode="decimal" step="0.01"
value={fixValue} inputMode="decimal"
onChange={(e) => setFixValue(e.target.value)} value={defaultFlatPrice}
placeholder="0.00" onChange={(e) => setDefaultFlatPrice(e.target.value)}
className="h-11 text-base" 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"> <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> </p>
</div> </div>
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */} <div className="inline-flex w-fit rounded-lg border p-1">
<div className="flex flex-col gap-4 rounded-xl border p-5"> <button
<div> type="button"
<h2 className="text-lg font-semibold text-foreground">Warranty</h2> onClick={() => {
<p className="text-sm text-muted-foreground"> setWarranty("NonWarranty")
Mark whether every generated variant is sold under warranty. setWarrantyPeriodMonths(null)
</p> }}
</div> className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
<div className="inline-flex w-fit rounded-lg border p-1"> warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
<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)}
> >
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}> Non-Warranty
<SelectValue placeholder="Select period" /> </button>
</SelectTrigger> <button
<SelectContent> type="button"
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => ( onClick={() => setWarranty("Warranty")}
<SelectItem key={months} value={months} className="text-base"> className={cn(
{months} months "rounded-md px-4 py-2 text-base font-medium transition-colors",
</SelectItem> warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
))} )}
</SelectContent> >
</Select> Warranty
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} /> </button>
</div> </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 {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */} 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">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead> <TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && ( {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 {/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN. Item contract and no initial-receipt flow — stock arrives via a GRN.
@@ -999,29 +1051,55 @@ export default function NewItemPage() {
{contentLabelFor(variant)} {contentLabelFor(variant)}
</TableCell> </TableCell>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<TableCell className="px-3 py-2.5"> <>
<Input <TableCell className="px-3 py-2.5">
type="number" <Input
min="0" type="number"
step="0.01" min="0"
inputMode="decimal" step="0.01"
value={priceFor(variant.key)} inputMode="decimal"
onChange={(e) => { value={pricesByKey[`${variant.key}::min`] ?? ""}
const value = e.target.value onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
setPricesByKey((prev) => ({ ...prev, [variant.key]: value })) placeholder="0.00"
setPriceErrors((prev) => { className="h-10 w-28 text-base"
if (!prev[variant.key]) return prev />
const next = { ...prev } </TableCell>
delete next[variant.key] <TableCell className="px-3 py-2.5">
return next <Input
}) type="number"
}} min="0"
placeholder="0.00" step="0.01"
aria-invalid={!!priceErrors[variant.key]} inputMode="decimal"
className="h-10 w-28 text-base" value={pricesByKey[`${variant.key}::max`] ?? ""}
/> onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} /> placeholder="0.00"
</TableCell> 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"> <TableCell className="py-2.5 pr-3 pl-0">
<Button <Button
@@ -12,12 +12,11 @@ import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast" 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 * Item types are the product-variant builder, while the other flags control whether item
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED). * pricing, tracking and warranty-period fields are available when creating or editing an item.
* `itemTypesEnabled` has no control on this screen but is still part of the record, so * The server enforces the fields that are truly written to the item record.
* every save round-trips its current value unchanged (the server rejects a partial body).
*/ */
export default function ProductSettingsPage() { export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null) const [config, setConfig] = useState<ProductConfig | null>(null)
@@ -38,17 +37,28 @@ export default function ProductSettingsPage() {
useEffect(load, []) 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 if (!config) return
setSaving(flag) setSaving(flag)
try { 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( const res = await productConfigApi.update(
{ {
subcategoriesEnabled: config.subcategoriesEnabled, subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled, brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled, itemTypesEnabled: config.itemTypesEnabled,
sellingPricesEnabled: config.sellingPricesEnabled,
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next, [flag]: next,
}, },
etag ?? "", etag ?? "",
@@ -58,7 +68,7 @@ export default function ProductSettingsPage() {
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`) toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) { } catch (err) {
toast.error("Could not save configuration", errorMessage(err)) toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind load()
} finally { } finally {
setSaving(null) 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> <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 && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <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="border-t" />
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2"> <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 <ToggleRow
label="Subcategories" 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} checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"} busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)} 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", subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands", 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({ 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 Link from "next/link"
import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react" import { BellRing, Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
const cards = [ const cards = [
{ {
@@ -14,6 +14,12 @@ const cards = [
icon: Users, icon: Users,
description: "Create users and assign their roles", 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", title: "Product Configuration",
href: "/dashboard/products/settings", href: "/dashboard/products/settings",
@@ -114,6 +114,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products/brands") return "Brands" if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/item-types") return "Item Types" if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration" 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 (pathname === "/dashboard/settings/company-profile") return "Company Profile"
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item" 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 // `subcategoriesEnabled`/`brandsEnabled` are enforced server-side: an item write carrying
// a gated field while its flag is off returns 422 CONFIG_DISABLED. `itemTypesEnabled` is // 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 // 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. // reject; the frontend is what honours it by hiding the variant builder.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client" import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common" import { ApiResult } from "@/types/common"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data" import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
@@ -13,7 +13,7 @@ export const productConfigApi = {
return apiRequestWithETag<ProductConfig>("/product-config") 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>> { update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch }) return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
}, },
+8
View File
@@ -287,6 +287,10 @@ export interface ProductConfig {
subcategoriesEnabled: boolean subcategoriesEnabled: boolean
brandsEnabled: boolean brandsEnabled: boolean
itemTypesEnabled: boolean itemTypesEnabled: boolean
sellingPricesEnabled: boolean
serialNumbersEnabled: boolean
batchListsEnabled: boolean
warrantyPeriodsEnabled: boolean
updatedAt: string | null updatedAt: string | null
updatedBy: number | null updatedBy: number | null
} }
@@ -296,4 +300,8 @@ export interface UpdateProductConfigRequest {
subcategoriesEnabled: boolean subcategoriesEnabled: boolean
brandsEnabled: boolean brandsEnabled: boolean
itemTypesEnabled: 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[]
}