bug fixers 10/20

This commit is contained in:
2026-10-20 15:33:19 +05:30
parent 0e423b806c
commit 47479fb4f2
43 changed files with 13191 additions and 811 deletions
@@ -92,8 +92,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to, [FromQuery] DateOnly? to,
[FromQuery] int? createdBy, [FromQuery] int? createdBy,
[FromQuery] bool? paid, [FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page. // Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")] [HttpGet("totals")]
@@ -108,8 +109,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to, [FromQuery] DateOnly? to,
[FromQuery] int? createdBy, [FromQuery] int? createdBy,
[FromQuery] bool? paid, [FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -51,6 +51,31 @@ public class ProductConfig
/// </summary> /// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; } public bool CustomWarrantyPeriodsEnabled { get; set; }
/// <summary>
/// Shows the stock-nature choice (Stocked / Non-stocked / Service) on item screens. Off:
/// new items are Stocked. Enforced on item writes - an item keeps the nature it already
/// has, only a change <em>to</em> a non-Stocked nature is refused.
/// </summary>
public bool StockNatureEnabled { get; set; } = true;
/// <summary>
/// Shows the tracking-mode choice on item screens. Off: new items are untracked (None).
/// Enforced on item writes like <see cref="StockNatureEnabled"/> - existing modes are kept.
/// </summary>
public bool TrackingModeEnabled { get; set; } = true;
/// <summary>
/// Shows the low-stock alert (reorder point / qty) section on item create and the item page.
/// Advisory: it gates the item screens; reorder rules already saved keep working.
/// </summary>
public bool LowStockAlertsEnabled { get; set; } = true;
/// <summary>
/// Lets the low-stock alert be pinned to one warehouse. Off: the alert watches every
/// warehouse. Depends on <see cref="LowStockAlertsEnabled"/>; advisory like it.
/// </summary>
public bool AlertWarehouseEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { get; set; }
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled, bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled, bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled, bool CustomWarrantyPeriodsEnabled,
bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
DateTime? UpdatedAt, int? UpdatedBy); DateTime? UpdatedAt, int? UpdatedBy);
/// <summary> /// <summary>
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? CustomVariantsEnabled { get; set; } [Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; } [Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; } [Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
[Required] public bool? StockNatureEnabled { get; set; }
[Required] public bool? TrackingModeEnabled { get; set; }
[Required] public bool? LowStockAlertsEnabled { get; set; }
[Required] public bool? AlertWarehouseEnabled { get; set; }
} }
@@ -32,6 +32,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.StockNatureEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.TrackingModeEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.LowStockAlertsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.AlertWarehouseEnabled).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();
@@ -213,7 +213,11 @@ public static class DataSeeder
WarrantyPeriodsEnabled = true, WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false, CustomVariantsEnabled = false,
ItemImagesEnabled = false, ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false CustomWarrantyPeriodsEnabled = false,
StockNatureEnabled = true,
TrackingModeEnabled = true,
LowStockAlertsEnabled = true,
AlertWarehouseEnabled = true
}); });
return true; return true;
} }
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 AddItemFieldSwitches : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AlertWarehouseEnabled",
table: "product_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "LowStockAlertsEnabled",
table: "product_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "StockNatureEnabled",
table: "product_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "TrackingModeEnabled",
table: "product_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AlertWarehouseEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "LowStockAlertsEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "StockNatureEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "TrackingModeEnabled",
table: "product_config");
}
}
}
@@ -4862,6 +4862,11 @@ namespace ERPCore.Migrations
b.Property<int>("ConfigId") b.Property<int>("ConfigId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("AlertWarehouseEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("BatchListsEnabled") b.Property<bool>("BatchListsEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@@ -4892,6 +4897,11 @@ namespace ERPCore.Migrations
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("LowStockAlertsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("int unsigned"); .HasColumnType("int unsigned");
@@ -4906,11 +4916,21 @@ namespace ERPCore.Migrations
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("StockNatureEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("SubcategoriesEnabled") b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("TrackingModeEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -7,9 +7,9 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default);
/// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary> /// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary>
Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
+23 -7
View File
@@ -321,7 +321,7 @@ public sealed class ItemService : IItemService
await ValidateReferencesAsync( await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId, request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct); request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config, item.TrackingMode); ValidateFeatureConfiguration(request, config, item.TrackingMode, item.StockNature);
// 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
@@ -520,22 +520,38 @@ public sealed class ItemService : IItemService
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config) private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration( => ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config); request.TrackingMode, request.StockNature, request.Warranty, config);
/// <summary> /// <summary>
/// An item keeps the tracking mode it already has even after that feature is switched off — /// An item keeps the tracking mode and stock nature it already has even after that feature is
/// otherwise it could not be edited at all; only a change <em>to</em> a disabled mode is refused. /// switched off — otherwise it could not be edited at all; only a change <em>to</em> a disabled
/// value is refused.
/// </summary> /// </summary>
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode) private static void ValidateFeatureConfiguration(
UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode, StockNature currentStockNature)
=> ValidateFeatureConfiguration( => ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config, currentTrackingMode); request.TrackingMode, request.StockNature, request.Warranty, config, currentTrackingMode, currentStockNature);
private static void ValidateFeatureConfiguration( private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice, decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null) TrackingMode trackingMode, StockNature stockNature, Warranty warranty, ProductConfigDto config,
TrackingMode? currentTrackingMode = null, StockNature? currentStockNature = null)
{ {
var trackingUnchanged = trackingMode == currentTrackingMode; var trackingUnchanged = trackingMode == currentTrackingMode;
if (!config.StockNatureEnabled && stockNature != StockNature.Stocked && stockNature != currentStockNature)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Stock nature is disabled in the product configuration; stockNature must be Stocked.",
422);
if (!config.TrackingModeEnabled && trackingMode != TrackingMode.None && !trackingUnchanged)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Tracking mode is disabled in the product configuration; trackingMode must be None.",
422);
if (!config.SellingPricesEnabled && if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null)) (sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException( throw new DomainException(
@@ -62,6 +62,12 @@ public sealed class ProductConfigService : IProductConfigService
// Custom warranty periods depend on warranty periods - switching the parent off // Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself. // switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value; config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.StockNatureEnabled = request.StockNatureEnabled!.Value;
config.TrackingModeEnabled = request.TrackingModeEnabled!.Value;
config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value;
// The warehouse picker lives in the low-stock alert section - switching alerts off
// switches this off too, so it can never be left on by itself.
config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
@@ -82,5 +88,6 @@ public sealed class ProductConfigService : IProductConfigService
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled, c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled, c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled, c.CustomWarrantyPeriodsEnabled,
c.StockNatureEnabled, c.TrackingModeEnabled, c.LowStockAlertsEnabled, c.AlertWarehouseEnabled,
c.UpdatedAt, c.UpdatedBy); c.UpdatedAt, c.UpdatedBy);
} }
@@ -51,9 +51,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default)
{ {
var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -61,9 +61,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
} }
public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default)
{ {
var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue);
// Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule. // Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule.
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@@ -86,7 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
} }
/// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary> /// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary>
private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid) private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid, bool? overdue = null)
{ {
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
{ {
@@ -106,6 +106,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// paid=true: nothing left to pay; paid=false: a balance is still due. // paid=true: nothing left to pay; paid=false: a balance is still due.
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
if (paid == false) q = q.Where(x => x.BalanceAmount > 0); if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
// overdue=true: past its due date (InvoiceDate) with money still owed — the Overdue card's rule.
if (overdue == true)
{
var today = DateTime.UtcNow.Date;
q = q.Where(x => x.BalanceAmount > 0 && x.InvoiceDate < today);
}
return q; return q;
} }
@@ -371,10 +371,21 @@ export default function ExpensesPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{summary ? ( {summary ? (
<> <>
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} /> {/* Cards double as filters (click again to clear). "Outstanding (all time)" ignores the
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} /> period, so it has no matching list filter and stays plain. */}
<StatCard
label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt}
onClick={() => setStatus((s) => (s === "Posted" ? "All" : "Posted"))} active={status === "Posted"}
/>
<StatCard
label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet}
onClick={() => setPaymentFilter((p) => (p === "Unpaid" ? "All" : "Unpaid"))} active={paymentFilter === "Unpaid"}
/>
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} /> <StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} /> <StatCard
label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle}
onClick={() => setPaymentFilter((p) => (p === "Overdue" ? "All" : "Overdue"))} active={paymentFilter === "Overdue"}
/>
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -97,14 +97,23 @@ export default function HelpPage() {
} }
}, [tickets]) }, [tickets])
// Set by clicking a stat card (click again to clear).
const [statusFilter, setStatusFilter] = useState<"All" | "Open" | "InProgress" | "Resolved">("All")
function toggleStatus(next: "Open" | "InProgress" | "Resolved") {
setStatusFilter((current) => (current === next ? "All" : next))
setPage(1)
}
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!tickets) return [] if (!tickets) return []
const term = debouncedSearch.toLowerCase() const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter( return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term) (t) =>
(statusFilter === "All" || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
) )
}, [tickets, debouncedSearch]) }, [tickets, debouncedSearch, statusFilter])
const totalItems = filtered.length const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -210,10 +219,10 @@ export default function HelpPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} /> <StatCard label="Total tickets" value={stats.total} icon={HelpCircle} onClick={() => { setStatusFilter("All"); setPage(1) }} active={statusFilter === "All"} />
<StatCard label="Open" value={stats.open} icon={Inbox} /> <StatCard label="Open" value={stats.open} icon={Inbox} onClick={() => toggleStatus("Open")} active={statusFilter === "Open"} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} /> <StatCard label="In Progress" value={stats.inProgress} icon={Clock} onClick={() => toggleStatus("InProgress")} active={statusFilter === "InProgress"} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} /> <StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} onClick={() => toggleStatus("Resolved")} active={statusFilter === "Resolved"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -170,10 +170,12 @@ export default function EmployeesPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} /> {/* Cards double as the status filter (click again to clear). "Inactive" spans several
<StatCard label="Active" value={stats.active} icon={UserCheck} /> statuses (suspended, resigned, retired…), so it has no single filter and stays plain. */}
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} onClick={() => setStatusFilter(undefined)} active={statusFilter === undefined} />
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))} active={statusFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} /> <StatCard label="Terminated" value={stats.terminated} icon={UserMinus} onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))} active={statusFilter === "Terminated"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -238,7 +238,14 @@ export default function LeaveRequestsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} /> {/* "Pending" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock,
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
active: statusFilter === "Submitted",
}] : null}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs"> <div className="relative w-full sm:max-w-xs">
@@ -113,28 +113,31 @@ export default function PurchaseOrderApprovalsPage() {
> >
<Eye className="size-4" /> <Eye className="size-4" />
</Link> </Link>
{/* Icon-only to keep the column narrow; the label lives in aria-label/title. */}
<Button <Button
type="button" type="button"
size="sm" size="icon-sm"
variant="success" variant="ghost"
disabled={!canApprove || approvingId === po.poId} disabled={!canApprove || approvingId === po.poId}
onClick={() => handleApprove(po, reload)} onClick={() => handleApprove(po, reload)}
aria-label={`Approve ${po.docNo}`}
title={approvingId === po.poId ? "Approving…" : "Approve"}
> >
<Check className="size-4" /> <Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="icon-sm"
variant="destructive" variant="ghost"
disabled={!canApprove} disabled={!canApprove}
onClick={() => { onClick={() => {
setRejectReason("") setRejectReason("")
setRejectTarget({ po, reload }) setRejectTarget({ po, reload })
}} }}
aria-label={`Reject ${po.docNo}`}
title="Reject"
> >
<X className="size-4" /> <X className="size-4" />
Reject
</Button> </Button>
</> </>
)} )}
@@ -19,31 +19,15 @@ import { validatePoLine } from "@/lib/validations/procurement"
import { formatAmount } from "@/lib/format" import { formatAmount } from "@/lib/format"
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax" import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { import { FieldError } from "@/components/ui/field"
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -147,16 +131,7 @@ function NewPurchaseOrderContent() {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [vendorDialogOpen, setVendorDialogOpen] = useState(false) const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig() const { config: procConfig } = useProcurementConfig()
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
function loadItems() { function loadItems() {
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items)) return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
@@ -187,46 +162,6 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
function resetVendorForm() {
setVName("")
setVTerms("")
setVTaxReg("")
setVCurrency("LKR")
setVOptional(EMPTY_VENDOR_OPTIONAL)
setVErrors({})
}
async function handleCreateVendor() {
const nextErrors: Record<string, string> = {}
if (!vName.trim()) nextErrors.name = "Vendor name is required"
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
setVErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setVSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedVendorCode,
name: vName,
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
currency: vCurrency,
...vendorOptionalPayload(procConfig, vOptional),
})
await loadVendors()
setVendorId(result.data.vendorId)
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setVendorDialogOpen(false)
resetVendorForm()
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setVSubmitting(false)
}
}
useEffect(() => { useEffect(() => {
if (requisitionId) { if (requisitionId) {
requisitionsApi requisitionsApi
@@ -446,62 +381,19 @@ function NewPurchaseOrderContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}> <Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" onClick={() => setVendorDialogOpen(true)}>
<DialogTrigger
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
>
<Plus className="size-5" /> <Plus className="size-5" />
</DialogTrigger> </Button>
<DialogContent className="sm:max-w-md"> <CreateVendorDialog
<DialogHeader className="items-center text-center"> open={vendorDialogOpen}
<DialogTitle>New vendor</DialogTitle> onOpenChange={setVendorDialogOpen}
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription> description="Create a supplier record without leaving this PO. Its code is generated from the name."
</DialogHeader> onCreated={(vendor) => {
<FieldGroup> // Active vendors only in this picker — a new one is Active, so add it and select it.
<Field data-invalid={!!vErrors.name}> setVendors((prev) => [...(prev ?? []), vendor])
<FieldLabel htmlFor="po-v-name">Name</FieldLabel> setVendorId(vendor.vendorId)
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} /> }}
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={vOptional}
onChange={(patch) => setVOptional((prev) => ({ ...prev, ...patch }))}
idPrefix="po-v"
errors={vErrors}
/> />
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
{vSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
{branchingEnabled && isAdmin && ( {branchingEnabled && isAdmin && (
@@ -7,7 +7,7 @@ import { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download" import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
import { PurchaseOrderSummary } from "@/types/procurement" import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
@@ -23,6 +23,8 @@ export default function PurchaseOrdersListPage() {
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null) const [submittingId, setSubmittingId] = useState<number | null>(null)
const [pdfId, setPdfId] = useState<number | null>(null) const [pdfId, setPdfId] = useState<number | null>(null)
// Shared with the list's Status filter, so the "Pending Approval" tile and the dropdown stay in sync.
const [status, setStatus] = useState<PurchaseOrderStatus | "All">("All")
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) { async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
setDeletingId(po.poId) setDeletingId(po.poId)
@@ -83,9 +85,16 @@ export default function PurchaseOrdersListPage() {
</div> </div>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} /> <KpiRow
tiles={stats ? [{
label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock,
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")), active: status === "PendingApproval",
}] : null}
/>
<PurchaseOrderListView <PurchaseOrderListView
status={status}
onStatusChange={setStatus}
renderActions={(po, { reload }) => { renderActions={(po, { reload }) => {
const editable = isPoEditable(po.status) const editable = isPoEditable(po.status)
return ( return (
@@ -78,7 +78,13 @@ export default function RequisitionsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} /> {/* "Open" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Open Requisitions", value: stats.openRequisitions, icon: Send,
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")), active: status === "Submitted",
}] : null}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}> <Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -25,6 +25,8 @@ export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null) const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Set by the "Open RFQs" tile (click again to clear).
const [openOnly, setOpenOnly] = useState(false)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that // Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for. // list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
@@ -46,7 +48,8 @@ export default function RfqsListPage() {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}` return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
} }
const visibleRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs const branchRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
const visibleRfqs = openOnly && branchRfqs ? branchRfqs.filter((r) => r.status === "Open") : branchRfqs
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -61,7 +64,7 @@ export default function RfqsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText }] : null} /> <KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText, onClick: () => setOpenOnly((v) => !v), active: openOnly }] : null} />
{error && ( {error && (
<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>
@@ -3,11 +3,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, usePathname, useRouter } from "next/navigation" import { useParams, usePathname, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react" import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Plus, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals" import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config" import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods" import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
@@ -19,7 +20,7 @@ import { WastageRecord, wastageApi } from "@/lib/api/wastage"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data" import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data" import { Brand, Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, SubCategory, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock" import { LedgerEntry } from "@/types/stock"
import { ItemPriceHistory } from "@/types/approvals" import { ItemPriceHistory } from "@/types/approvals"
@@ -43,6 +44,8 @@ import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel" import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel" import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
import { WarrantyFields } from "@/components/products/WarrantyFields" import { WarrantyFields } from "@/components/products/WarrantyFields"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
/** /**
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and * The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
@@ -102,6 +105,11 @@ function ItemDetailPage() {
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
// Edit screen pickers — shown only while Product configuration has subcategories / brands on.
const [brands, setBrands] = useState<Brand[]>([])
const [subCategoryList, setSubCategoryList] = useState<{ categoryId: number | null; items: SubCategory[] }>({ categoryId: null, items: [] })
const [addUomOpen, setAddUomOpen] = useState(false)
const [addBrandOpen, setAddBrandOpen] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Profile activity: full recent movement history for this item, across every warehouse. // Profile activity: full recent movement history for this item, across every warehouse.
@@ -118,14 +126,11 @@ function ItemDetailPage() {
// No longer editable here — carried through unchanged so a save doesn't silently clear it. // No longer editable here — carried through unchanged so a save doesn't silently clear it.
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null) const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null) const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked") const [stockNature, setStockNature] = useState<StockNature>("Stocked")
// Default vendor, tax class, and tracking mode are no longer editable on this page — // Default vendor isn't editable on this page — carried through unchanged so a save doesn't clear it.
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null) const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None") const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Warranty: edited in the Warranty section below (options follow Product configuration). // Warranty: edited in the Warranty section below (options follow Product configuration).
@@ -211,6 +216,7 @@ function ItemDetailPage() {
}) })
.catch(() => {}) .catch(() => {})
productConfig().then(setConfig).catch(() => {}) productConfig().then(setConfig).catch(() => {})
brandsApi.list({ pageSize: 200, status: "Active" }).then((res) => setBrands(res.items)).catch(() => {})
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {}) warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way. // Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {}) approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
@@ -218,6 +224,17 @@ function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId]) }, [itemId])
// Subcategories are fetched per category, like the create screen.
// Tagged with the category they belong to, so a stale list is never shown for another one.
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) return
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategoryList({ categoryId, items: res.items }))
.catch(() => setSubCategoryList({ categoryId, items: [] }))
}, [categoryId, config?.subcategoriesEnabled])
const subCategories = subCategoryList.categoryId === categoryId ? subCategoryList.items : []
// Recent activity + stock value: independent of the edit form above, loaded once the // Recent activity + stock value: independent of the edit form above, loaded once the
// item id is known. A large page size is fine here — one item's lifetime history. // item id is known. A large page size is fine here — one item's lifetime history.
useEffect(() => { useEffect(() => {
@@ -313,7 +330,7 @@ function ItemDetailPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) { if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period" nextErrors.warrantyPeriodMonths = "Select a warranty period"
} }
if (lowStockAt.trim()) { if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt) const point = Number(lowStockAt)
const qty = Number(reorderQty) const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more" if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -326,20 +343,32 @@ function ItemDetailPage() {
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse // Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
// rules set under Reorder settings survive an unrelated edit (name, price, …). // rules set under Reorder settings survive an unrelated edit (name, price, …).
const saved = alertFromRules(item.reorder ?? []) const saved = alertFromRules(item.reorder ?? [])
// Alert warehouse switched off: the picker is hidden and a changed alert watches every warehouse.
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : saved.warehouseId
const alertChanged = const alertChanged =
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId)) !!config?.lowStockAlertsEnabled &&
(lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || alertWarehouseId !== saved.warehouseId)))
// Follow Product configuration: a switched-off feature's value is not sent (the server
// answers CONFIG_DISABLED for a gated value), so saving still works after a switch is turned off.
const allowPrices = !!config?.sellingPricesEnabled
const allowWarranty = !!config?.warrantyPeriodsEnabled
setSaving(true) setSaving(true)
try { try {
const result = await itemsApi.update( const result = await itemsApi.update(
item.itemId, item.itemId,
{ {
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths, subCategoryId: config?.subcategoriesEnabled ? subCategoryId : null,
brandId: config?.brandsEnabled ? brandId : null,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
warranty: allowWarranty ? warranty : "NonWarranty",
warrantyPeriodMonths: allowWarranty && warranty === "Warranty" ? warrantyPeriodMonths : null,
taxRateId, taxRateId,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null, sellingMinPrice: allowPrices && sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null, sellingMaxPrice: allowPrices && sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null, sellingFlatPrice: allowPrices && sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null, contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null, contentUnit: contentQty.trim() ? contentUnit : null,
}, },
@@ -347,7 +376,8 @@ function ItemDetailPage() {
) )
let saved = result.data let saved = result.data
if (alertChanged) { if (alertChanged) {
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId) const watchedId = config?.alertWarehouseEnabled ? warehouseId : null
const watched = watchedId !== null ? [watchedId] : warehouses.map((w) => w.warehouseId)
const settings = lowStockAt.trim() const settings = lowStockAt.trim()
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) })) ? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
: [] : []
@@ -428,8 +458,19 @@ function ItemDetailPage() {
} }
if (editMode) { if (editMode) {
/** Mobile-first section shell, same as the New Item screen. */
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
// Tracking mode and base UOM give meaning to every stock row already recorded, so they are
// only changeable while the item has no movements yet (the server freezes the UOM too).
const hasHistory = ledger === null || ledger.length > 0
const trackingOptions: TrackingMode[] = [
"None",
...(config?.batchListsEnabled || trackingMode === "Batch" ? (["Batch"] as const) : []),
...(config?.serialNumbersEnabled || trackingMode === "Serial" ? (["Serial"] as const) : []),
]
return ( return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> <div className="flex flex-col gap-5 sm:gap-6">
<Link <Link
href={`/dashboard/products/${item.itemId}`} href={`/dashboard/products/${item.itemId}`}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
@@ -438,9 +479,12 @@ function ItemDetailPage() {
Back to item Back to item
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Edit Item</h1>
<p className="text-base text-muted-foreground">Update this item&apos;s basic info, pricing and classification.</p> <p className="text-sm text-muted-foreground sm:text-base">
{item.sku} — {item.name}
</p>
</div> </div>
{conflict && ( {conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning"> <div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" /> <AlertTriangle className="size-5 shrink-0" />
@@ -450,50 +494,84 @@ function ItemDetailPage() {
</div> </div>
</div> </div>
)} )}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div> <section className={sectionClass}>
)} <h2 className="text-lg font-semibold text-foreground">Basic details</h2>
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base" required>Item name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label> <Label className="text-base" required>SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} /> <Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} /> <FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label> <Label className="text-base">Barcode (optional)</Label>
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12 text-base" disabled={conflict} /> <Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} /> <FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p> <p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Name</Label> <Label className="text-base" required>Category</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> <Select<number | null>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> value={categoryId}
onValueChange={(v) => {
setCategoryId(v)
// A subcategory belongs to one category — the old pick no longer fits.
if (v !== categoryId) setSubCategoryId(null)
}}
disabled={conflict}
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div> </div>
{config?.sellingPricesEnabled && ( {config?.subcategoriesEnabled && (
<>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label> <Label className="text-base">Subcategory (optional)</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} /> <Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
disabled={conflict || subCategories.length === 0}
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">None</SelectItem>
{subCategories.map((s) => <SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price (fixed value)</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval (Settings → Approvals).{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)} )}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
disabled={conflict}
items={brands.map((b) => ({ label: b.name, value: b.brandId }))}
>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base"><SelectValue placeholder="Select brand" /></SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">None</SelectItem>
{brands.map((b) => <SelectItem key={b.brandId} value={b.brandId} className="text-base">{b.name}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddBrandOpen(true)} aria-label="Add brand" disabled={conflict}>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div> </div>
</>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label> <Label className="text-base">Tax rate</Label>
@@ -506,21 +584,28 @@ function ItemDetailPage() {
</Select> </Select>
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p> <p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div> </div>
</section>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Units &amp; stock</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label> <Label className="text-base" required>Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}> <div className="flex gap-2">
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger> <Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict || hasHistory} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent> <SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent>
</Select> </Select>
{!hasHistory && (
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddUomOpen(true)} aria-label="Add UOM" disabled={conflict}>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
)}
</div>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} /> <FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
{hasHistory && <p className="text-sm text-muted-foreground">Locked — this item already has stock history.</p>}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label> <Label className="text-base">Content size</Label>
@@ -536,6 +621,8 @@ function ItemDetailPage() {
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."} {item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
</p> </p>
</div> </div>
{/* Off: hidden, and the item keeps the nature it already has (the server allows that). */}
{config?.stockNatureEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label> <Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}> <Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
@@ -547,17 +634,49 @@ function ItemDetailPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{config?.trackingModeEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label> <Label className="text-base">Tracking mode</Label>
<div className="flex gap-2"> <Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict || hasHistory}>
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} /> <SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>
{trackingOptions.map((m) => <SelectItem key={m} value={m} className="text-base">{m}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{hasHistory
? "Locked — this item already has stock history, so its tracking can't change."
: config.serialNumbersEnabled
? "Choose Serial if each unit needs its own serial number on receipt."
: config.batchListsEnabled
? "Batch tracking is available; serial tracking is currently disabled in product settings."
: "Batch and serial tracking are currently disabled in product settings."}
</p>
</div>
)}
</div>
</section>
{config?.lowStockAlertsEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
<div className="grid grid-cols-2 gap-2">
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert at" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} /> <Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
</div> </div>
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} /> <FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."} {alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${config.alertWarehouseEnabled && warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p> </p>
</div> </div>
{config.alertWarehouseEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label> <Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
@@ -567,11 +686,54 @@ function ItemDetailPage() {
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)} {warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p> <p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
)}
</div>
</section>
)}
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
{config?.sellingPricesEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
<p className="text-sm text-muted-foreground">Optional — leave blank to sell at stock value.</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">Min price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Max price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Flat price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div> </div>
</div> </div>
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval (Settings → Approvals).{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</section>
)}
{config?.warrantyPeriodsEnabled && ( {config?.warrantyPeriodsEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">Mark whether this item is sold under warranty.</p>
</div>
<WarrantyFields <WarrantyFields
hideLabel
config={config} config={config}
customMonths={customWarrantyMonths} customMonths={customWarrantyMonths}
warranty={warranty} warranty={warranty}
@@ -584,16 +746,53 @@ function ItemDetailPage() {
error={errors.warrantyPeriodMonths || undefined} error={errors.warrantyPeriodMonths || undefined}
disabled={conflict} disabled={conflict}
/> />
</section>
)} )}
</div>
)}
{/* Shown whenever the item has images so they can still be removed; uploads follow the Item images switch. */}
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
</h2>
{!imagesEnabled && <p className="text-sm text-muted-foreground">Adding images is switched off in Product configuration.</p>}
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} /> <ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> </section>
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}> {saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{saveError}</div>
)}
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
<Link href={`/dashboard/products/${item.itemId}`} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSave} disabled={saving || conflict} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </div>
</div>
<AddUomDialog
open={addUomOpen}
onOpenChange={setAddUomOpen}
onCreated={(uom) => {
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
setBaseUomId(uom.uomId)
}}
/>
<AddBrandDialog
open={addBrandOpen}
onOpenChange={setAddBrandOpen}
onCreated={(brand) => {
setBrands((prev) => [...prev, brand])
setBrandId(brand.brandId)
}}
/>
</div> </div>
) )
} }
@@ -672,6 +871,7 @@ function ItemDetailPage() {
{/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change {/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
made through the Edit dialog's simple alert fields. */} made through the Edit dialog's simple alert fields. */}
{config?.lowStockAlertsEnabled && (
<ItemReorderPanel <ItemReorderPanel
key={JSON.stringify(item.reorder ?? [])} key={JSON.stringify(item.reorder ?? [])}
itemId={item.itemId} itemId={item.itemId}
@@ -681,6 +881,7 @@ function ItemDetailPage() {
uomName={uomName(item.baseUomId)} uomName={uomName(item.baseUomId)}
onSaved={(settings) => applyItem({ ...item, reorder: settings })} onSaved={(settings) => applyItem({ ...item, reorder: settings })}
/> />
)}
{/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */} {/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
@@ -254,6 +254,19 @@ export default function NewItemPage() {
if (!config.batchListsEnabled && trackingMode === "Batch") { if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None") setTrackingMode("None")
} }
if (!config.trackingModeEnabled && trackingMode !== "None") {
setTrackingMode("None")
}
if (!config.stockNatureEnabled && stockNature !== "Stocked") {
setStockNature("Stocked")
}
if (!config.lowStockAlertsEnabled && (lowStockAt || reorderQty)) {
setLowStockAt("")
setReorderQty("")
}
if (!config.alertWarehouseEnabled && warehouseId !== null) {
setWarehouseId(null)
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") { if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty") setWarranty("NonWarranty")
setWarrantyPeriodMonths(null) setWarrantyPeriodMonths(null)
@@ -267,6 +280,10 @@ export default function NewItemPage() {
}, [ }, [
config, config,
trackingMode, trackingMode,
stockNature,
lowStockAt,
reorderQty,
warehouseId,
warranty, warranty,
config?.serialNumbersEnabled, config?.serialNumbersEnabled,
config?.batchListsEnabled, config?.batchListsEnabled,
@@ -585,7 +602,7 @@ export default function NewItemPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) { if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period" nextErrors.warrantyPeriodMonths = "Select a warranty period"
} }
if (lowStockAt.trim()) { if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt) const point = Number(lowStockAt)
const qty = Number(reorderQty) const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more" if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -629,8 +646,11 @@ export default function NewItemPage() {
let created = 0 let created = 0
const createdIds: number[] = [] const createdIds: number[] = []
try { try {
const effectiveStockNature = config?.stockNatureEnabled ? stockNature : "Stocked"
const effectiveTrackingMode = const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial" !config?.trackingModeEnabled
? "None"
: !config?.serialNumbersEnabled && trackingMode === "Serial"
? "None" ? "None"
: !config?.batchListsEnabled && trackingMode === "Batch" : !config?.batchListsEnabled && trackingMode === "Batch"
? "None" ? "None"
@@ -652,7 +672,7 @@ export default function NewItemPage() {
subCategoryId, subCategoryId,
brandId, brandId,
baseUomId, baseUomId,
stockNature, stockNature: effectiveStockNature,
trackingMode: effectiveTrackingMode, trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty, warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths, warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
@@ -696,8 +716,9 @@ export default function NewItemPage() {
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like // Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
// images — the items exist either way, and the rules can be fixed under Reorder settings. // images — the items exist either way, and the rules can be fixed under Reorder settings.
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId) const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : null
if (lowStockAt.trim() && watched.length > 0) { const watched = alertWarehouseId !== null ? [alertWarehouseId] : warehouses.map((w) => w.warehouseId)
if (config?.lowStockAlertsEnabled && lowStockAt.trim() && watched.length > 0) {
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) })) const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
let failed = 0 let failed = 0
for (const itemId of createdIds) { for (const itemId of createdIds) {
@@ -1000,6 +1021,7 @@ export default function NewItemPage() {
</p> </p>
</div> </div>
)} )}
{config?.stockNatureEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label> <Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}> <Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -1013,6 +1035,8 @@ export default function NewItemPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{config?.trackingModeEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label> <Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> <Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
@@ -1041,9 +1065,11 @@ export default function NewItemPage() {
)} )}
</p> </p>
</div> </div>
)}
</div> </div>
</section> </section>
{config?.lowStockAlertsEnabled && (
<section className={sectionClass}> <section className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2> <h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
@@ -1097,6 +1123,7 @@ export default function NewItemPage() {
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p> </p>
</div> </div>
{config.alertWarehouseEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label> <Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> <Select<number | null>
@@ -1117,8 +1144,10 @@ export default function NewItemPage() {
</Select> </Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p> <p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div> </div>
)}
</div> </div>
</section> </section>
)}
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( {(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
@@ -259,6 +259,12 @@ export default function ItemsPage() {
} }
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
/** Stat-card filter: toggles the stock-level filter (same one as the "Stock level" dropdown). */
function filterByStock(next: ItemStockFilter | "All") {
setStock((current) => (current === next ? "All" : next))
setPage(1)
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0 const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
function changeCategory(next: number | "All") { function changeCategory(next: number | "All") {
@@ -377,9 +383,9 @@ export default function ItemsPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{itemStats ? ( {itemStats ? (
<> <>
<StatCard label="Total items" value={itemStats.total} icon={Package} /> <StatCard label="Total items" value={itemStats.total} icon={Package} onClick={() => filterByStock("All")} active={stock === "All"} />
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} /> <StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} onClick={() => filterByStock("LowStock")} active={stock === "LowStock"} />
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} /> <StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} onClick={() => filterByStock("OutOfStock")} active={stock === "OutOfStock"} />
</> </>
) : ( ) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />) Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -426,8 +432,12 @@ export default function ItemsPage() {
]} ]}
/> />
)} )}
{config?.trackingModeEnabled && (
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} /> <FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
)}
{config?.stockNatureEnabled && (
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} /> <FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
)}
</> </>
} }
> >
@@ -56,6 +56,10 @@ export default function ProductSettingsPage() {
customVariantsEnabled: config.customVariantsEnabled, customVariantsEnabled: config.customVariantsEnabled,
itemImagesEnabled: config.itemImagesEnabled, itemImagesEnabled: config.itemImagesEnabled,
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled, customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
stockNatureEnabled: config.stockNatureEnabled,
trackingModeEnabled: config.trackingModeEnabled,
lowStockAlertsEnabled: config.lowStockAlertsEnabled,
alertWarehouseEnabled: config.alertWarehouseEnabled,
...patch, ...patch,
}, },
etag ?? "", etag ?? "",
@@ -224,6 +228,54 @@ export default function ProductSettingsPage() {
busy={saving === "brandsEnabled"} busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)} onChange={(v) => toggle("brandsEnabled", v)}
/> />
<ToggleRow
label="Stock nature"
description="Lets items be Stocked, Non-stocked or Service. Off hides the choice and new items are Stocked; existing items keep their nature."
checked={config.stockNatureEnabled}
busy={saving === "stockNatureEnabled"}
onChange={(v) => toggle("stockNatureEnabled", v)}
/>
<ToggleRow
label="Tracking mode"
description="Shows the tracking choice (None, Batch, Serial) on items. Off hides it and new items are untracked; existing items keep their mode."
checked={config.trackingModeEnabled}
busy={saving === "trackingModeEnabled"}
onChange={(v) => toggle("trackingModeEnabled", v)}
/>
<ToggleRow
label="Low stock alert"
description="Shows the low-stock alert (alert-at and reorder qty) on item create and the item page. Off hides it; alerts already set keep working."
checked={config.lowStockAlertsEnabled}
busy={saving === "lowStockAlertsEnabled"}
onChange={(v) =>
v
? toggle("lowStockAlertsEnabled", true)
: // The warehouse picker lives in the alert section, so switching it off turns that off too.
save(
"lowStockAlertsEnabled",
{ lowStockAlertsEnabled: false, alertWarehouseEnabled: false },
config.alertWarehouseEnabled
? "Low stock alert and alert warehouse disabled."
: "Low stock alert disabled.",
)
}
/>
<ToggleRow
label="Alert warehouse"
description={
config.lowStockAlertsEnabled
? "Lets the low-stock alert watch one chosen warehouse. Off: the alert watches every warehouse."
: "Needs Low stock alert switched on."
}
checked={config.alertWarehouseEnabled}
busy={saving === "alertWarehouseEnabled"}
disabled={!config.lowStockAlertsEnabled}
onChange={(v) => toggle("alertWarehouseEnabled", v)}
/>
</div> </div>
{config.updatedAt && ( {config.updatedAt && (
@@ -249,6 +301,10 @@ const LABELS: Record<keyof ProductConfig, string> = {
customVariantsEnabled: "Custom variants", customVariantsEnabled: "Custom variants",
itemImagesEnabled: "Item images", itemImagesEnabled: "Item images",
customWarrantyPeriodsEnabled: "Custom warranty periods", customWarrantyPeriodsEnabled: "Custom warranty periods",
stockNatureEnabled: "Stock nature",
trackingModeEnabled: "Tracking mode",
lowStockAlertsEnabled: "Low stock alert",
alertWarehouseEnabled: "Alert warehouse",
updatedAt: "Updated at", updatedAt: "Updated at",
updatedBy: "Updated by", updatedBy: "Updated by",
} }
@@ -265,6 +321,10 @@ type FlagKey = keyof Pick<
| "customVariantsEnabled" | "customVariantsEnabled"
| "itemImagesEnabled" | "itemImagesEnabled"
| "customWarrantyPeriodsEnabled" | "customWarrantyPeriodsEnabled"
| "stockNatureEnabled"
| "trackingModeEnabled"
| "lowStockAlertsEnabled"
| "alertWarehouseEnabled"
> >
function ToggleRow({ function ToggleRow({
@@ -296,7 +296,13 @@ export default function GrnListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} /> {/* "Pending" = Draft (DashboardService) — the tile toggles the Status filter to Draft. */}
<KpiRow
tiles={stats ? [{
label: "Pending GRNs", value: stats.pendingGrns, icon: Clock,
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")), active: status === "Draft",
}] : null}
/>
<FilterBar <FilterBar
searchFirst searchFirst
@@ -1,14 +1,15 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { import {
ArrowLeft, ArrowLeft,
Banknote, Banknote,
CreditCard, CreditCard,
FileDown, FileDown,
Hash,
Landmark, Landmark,
LineChart,
PiggyBank, PiggyBank,
Receipt, Receipt,
ReceiptText, ReceiptText,
@@ -19,73 +20,26 @@ import {
import { dayEndReportApi } from "@/lib/api/day-end-report" import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { formatAmount, formatReportDate, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf" import { downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination" import { useClientPagination } from "@/hooks/use-client-pagination"
import { maskAccountNumber } from "@/types/bank-deposits" import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales" import { DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart" import { KV, localIso, mobileCard } from "@/components/sales/day-end-report-parts"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
/** Local yyyy-MM-dd, `offsetDays` from today. */
function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null } type Loaded<T> = { key: string; data: T | null; error: string | null }
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */ /** "1 sale" / "12 sales". */
function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) { const salesCountLabel = (n: number) => `${n} sale${n === 1 ? "" : "s"}`
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */ /** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) { function ProfitStatement({ day }: { day: DayEndReportDay }) {
@@ -227,35 +181,33 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
) )
} }
export default function DayEndReportPage() { // useSearchParams needs a Suspense boundary for the static build.
export default function DayEndReportRoute() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<DayEndReportPage />
</Suspense>
)
}
function DayEndReportPage() {
// ?date=yyyy-MM-dd opens a given day (Day-wise Income links here); otherwise today.
const dateParam = useSearchParams().get("date")
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to // Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent. // their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-") const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [date, setDate] = useState(localIso()) // Fixed for the page — not user-editable.
const [from, setFrom] = useState(localIso(-29)) const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : localIso()
const [to, setTo] = useState(localIso()) const [downloading, setDownloading] = useState(false)
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading". // Results are tagged with the request they answer; a stale or pending one reads as "loading".
const dayKey = `${date}|${branchId}` const dayKey = `${date}|${branchId}`
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null) const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
const day = dayResult?.key === dayKey ? dayResult : null const day = dayResult?.key === dayKey ? dayResult : null
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => { useEffect(() => {
if (!date) return if (!date) return
@@ -269,39 +221,15 @@ export default function DayEndReportPage() {
} }
}, [date, branchId, dayKey]) }, [date, branchId, dayKey])
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadDay() { async function downloadDay() {
if (!day?.data) return if (!day?.data) return
setDownloading("day") setDownloading(true)
try { try {
await downloadDayEndReportPdf(day.data, branchLabel) await downloadDayEndReportPdf(day.data, branchLabel)
} catch (err) { } catch (err) {
toast.error("Could not create the PDF", errorMessage(err)) toast.error("Could not create the PDF", errorMessage(err))
} finally { } finally {
setDownloading(null) setDownloading(false)
}
}
async function downloadRange() {
if (!range?.data) return
setDownloading("range")
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
} }
} }
@@ -313,7 +241,6 @@ export default function DayEndReportPage() {
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1. // Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
const salesPage = useClientPagination(sales, date) const salesPage = useClientPagination(sales, date)
const expensePage = useClientPagination(expenses, date) const expensePage = useClientPagination(expenses, date)
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -324,7 +251,7 @@ export default function DayEndReportPage() {
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1> <h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
A day&apos;s sales, how they were paid, its expenses and the resulting income — plus income day by day. A day&apos;s sales, how they were paid, its expenses and the resulting income.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>} {branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p> </p>
</div> </div>
@@ -332,16 +259,24 @@ export default function DayEndReportPage() {
{/* --- One day ------------------------------------------------------------------------- */} {/* --- One day ------------------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]"> <section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> {/* Date on the right, above the Day-wise income / PDF buttons. */}
<div className="flex flex-col gap-2"> <div className="flex flex-col items-end gap-3">
<Label htmlFor="der-date" className="text-base">Business date</Label> {/* Fixed, not editable: today, or the day opened from Day-wise Income (?date=). */}
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" /> <div className="flex flex-col items-end gap-1">
<span className="text-sm text-muted-foreground">Business date</span>
<span className="text-lg font-semibold text-foreground tabular-nums">{formatReportDate(date)}</span>
</div> </div>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}> <div className="flex flex-wrap justify-end gap-2">
<Link href="/dashboard/sales/day-wise-income" className={cn(buttonVariants({ variant: "outline" }))}>
<LineChart className="size-4" />
Day-wise income
</Link>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading}>
<FileDown className="size-4" /> <FileDown className="size-4" />
{downloading === "day" ? "Creating PDF…" : "Download day PDF"} {downloading ? "Creating PDF…" : "Download day PDF"}
</Button> </Button>
</div> </div>
</div>
{day?.error && <p className="text-sm text-destructive">{day.error}</p>} {day?.error && <p className="text-sm text-destructive">{day.error}</p>}
@@ -354,29 +289,20 @@ export default function DayEndReportPage() {
{s && ( {s && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Sales</h2> {/* Each amount card carries its sale count as the hint line, instead of a separate count card. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} /> <StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} hint={salesCountLabel(s.salesCount)} />
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<StatCard <StatCard
label="Income" label="Paid amount"
value={formatSignedAmount(s.income)} value={formatAmount(s.paidAmount)}
icon={s.income >= 0 ? TrendingUp : TrendingDown} icon={Wallet}
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"} hint={`${salesCountLabel(s.salesCount - s.unpaidSalesCount)} paid`}
className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
/> />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} hint={`${salesCountLabel(s.cashSalesCount)} by cash`} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} hint={`${salesCountLabel(s.cardSalesCount)} by card`} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} hint={`${salesCountLabel(s.unpaidSalesCount)} not paid`} />
</div> </div>
{/* Expenses and income cards live on Day-wise Income (Sales → Day-wise Income). */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque {s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "} ({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
@@ -566,172 +492,6 @@ export default function DayEndReportPage() {
</> </>
)} )}
</section> </section>
{/* --- Day-wise income ----------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
</div>
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-from">From</Label>
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-to">To</Label>
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} />
</div>
{/* Phones: one card per day (newest first), tap to show that day above. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => d.date && setDate(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div> </div>
) )
} }
@@ -65,12 +65,13 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
type PaymentFilter = "All" | "Unpaid" | "Paid" type PaymentFilter = "All" | "Unpaid" | "Paid" | "Overdue"
const paymentFilters: { value: PaymentFilter; label: string }[] = [ const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "All", label: "All payments" }, { value: "All", label: "All payments" },
{ value: "Unpaid", label: "Balance due" }, { value: "Unpaid", label: "Balance due" },
{ value: "Paid", label: "Fully paid" }, { value: "Paid", label: "Fully paid" },
{ value: "Overdue", label: "Overdue" },
] ]
export default function ApprovedInvoicesPage() { export default function ApprovedInvoicesPage() {
@@ -155,7 +156,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom, from: salesFrom,
to: salesTo, to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid", paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined, q: salesQuery || undefined,
}) })
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null })) .then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
@@ -189,7 +191,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom, from: salesFrom,
to: salesTo, to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid", paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined, q: salesQuery || undefined,
}) })
.then((data) => setTotalsResult({ key: totalsKey, data })) .then((data) => setTotalsResult({ key: totalsKey, data }))
@@ -265,6 +268,8 @@ function ApprovedInvoices() {
<InvoiceTotalsCards <InvoiceTotalsCards
totals={cardTotals} totals={cardTotals}
payment={salesPayment}
onPaymentChange={(next) => changeSalesPayment(salesPayment === next ? "All" : next)}
pageOnly={pageOnly} pageOnly={pageOnly}
scope={[ scope={[
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"), salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
@@ -662,8 +667,15 @@ const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow
* `undefined` = loading. Same StatCard as the other screens' KPI rows. * `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/ */
function InvoiceTotalsCards({ function InvoiceTotalsCards({
totals, scope, pageOnly, totals, scope, pageOnly, payment, onPaymentChange,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) { }: {
totals: SalesInvoiceListTotals | null | undefined
scope: string
pageOnly: boolean
/** The payment filter the cards toggle — clicking a card filters the list (again = clear). */
payment: PaymentFilter
onPaymentChange: (next: PaymentFilter) => void
}) {
if (totals === undefined) { if (totals === undefined) {
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@@ -682,9 +694,9 @@ function InvoiceTotalsCards({
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>} {pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
</p> </p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} /> <StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} onClick={() => onPaymentChange("All")} active={payment === "All"} />
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" /> <StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" /> <StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" onClick={() => onPaymentChange("Paid")} active={payment === "Paid"} />
<StatCard <StatCard
label="Balance due" label="Balance due"
value={formatAmount(totals.balanceTotal)} value={formatAmount(totals.balanceTotal)}
@@ -692,6 +704,8 @@ function InvoiceTotalsCards({
hint="Still to collect" hint="Still to collect"
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined} className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined} iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
onClick={() => onPaymentChange("Unpaid")}
active={payment === "Unpaid"}
/> />
<StatCard <StatCard
label="Overdue" label="Overdue"
@@ -700,6 +714,8 @@ function InvoiceTotalsCards({
hint="Past due date, not fully paid" hint="Past due date, not fully paid"
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")} className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined} iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
onClick={() => onPaymentChange("Overdue")}
active={payment === "Overdue"}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,277 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, FileDown, Receipt, ReceiptText, TrendingDown, TrendingUp } from "lucide-react"
import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { DayEndDailyReport } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { IncomeCell, KV, localIso, mobileCard, ProfitCell } from "@/components/sales/day-end-report-parts"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null }
/**
* Day-wise income (Sales → Day-wise Income): sales, expenses, income and profit/loss per day over a range
* (≤ 92 days), with a chart and a PDF. Clicking a day opens that day in the Day-End Report.
*/
export default function DayWiseIncomePage() {
const router = useRouter()
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [from, setFrom] = useState(localIso(-29))
const [to, setTo] = useState(localIso())
const [downloading, setDownloading] = useState(false)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadRange() {
if (!range?.data) return
setDownloading(true)
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(false)
}
}
function openDay(date: string | null | undefined) {
if (date) router.push(`/dashboard/sales/day-end-report?date=${date}`)
}
// Paged in the browser (the report returns whole lists); a new range starts on page 1.
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day-wise Income</h1>
<p className="text-base text-muted-foreground">
Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days). Click a day to open its Day-End Report.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p>
</div>
</div>
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap sm:justify-end">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dwi-from">From</Label>
<Input id="dwi-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dwi-to">To</Label>
<Input id="dwi-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading}>
<FileDown className="size-4" />
{downloading ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard
label="Sales"
value={formatAmount(range.data.totals.totalSales)}
icon={ReceiptText}
hint={`${range.data.totals.salesCount} sale${range.data.totals.salesCount === 1 ? "" : "s"}`}
/>
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} />
</div>
{/* Phones: one card per day (newest first), tap to open that day's report. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => openDay(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => openDay(d.date)} title="Open this day's Day-End Report">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div>
)
}
@@ -48,10 +48,23 @@ export default function UsersPage() {
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null) const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by clicking a stat card (click again to clear) — narrows the list to that group.
const [cardFilter, setCardFilter] = useState<"All" | "Active" | "Inactive" | "Admins">("All")
function toggleCardFilter(next: "Active" | "Inactive" | "Admins") {
setCardFilter((current) => (current === next ? "All" : next))
setPage(1)
}
function load() { function load() {
usersApi usersApi
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({
page,
pageSize,
q: debouncedSearch || undefined,
status: cardFilter === "Active" || cardFilter === "Inactive" ? cardFilter : undefined,
isAdmin: cardFilter === "Admins" ? true : undefined,
})
.then((res) => { .then((res) => {
setUsers(res.items) setUsers(res.items)
setPagination(res.pagination) setPagination(res.pagination)
@@ -65,7 +78,7 @@ export default function UsersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) useEffect(load, [page, pageSize, debouncedSearch, cardFilter])
function loadStats() { function loadStats() {
Promise.all([ Promise.all([
@@ -138,10 +151,10 @@ export default function UsersPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total users" value={stats.total} icon={UsersIcon} /> <StatCard label="Total users" value={stats.total} icon={UsersIcon} onClick={() => { setCardFilter("All"); setPage(1) }} active={cardFilter === "All"} />
<StatCard label="Active" value={stats.active} icon={UserCheck} /> <StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => toggleCardFilter("Active")} active={cardFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} onClick={() => toggleCardFilter("Inactive")} active={cardFilter === "Inactive"} />
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} /> <StatCard label="Admins" value={stats.admins} icon={ShieldCheck} onClick={() => toggleCardFilter("Admins")} active={cardFilter === "Admins"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -43,25 +43,27 @@ export default function CountsListPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by the "Pending Counts" tile (click again to clear). "Pending" = Counted, awaiting posting (DashboardService).
const [pendingOnly, setPendingOnly] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query]) useEffect(() => setPage(1), [query, pendingOnly])
useEffect(() => { useEffect(() => {
setError(null) setError(null)
stockCountsApi stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }) .list({ page, pageSize, q: query || undefined, status: pendingOnly ? "Counted" : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => { .then((res) => {
setCounts(res.items) setCounts(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false)) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled]) }, [page, pageSize, query, pendingOnly, activeBranchId, branchingEnabled])
function handleShowMore() { function handleShowMore() {
setShowMoreLoading(true) setShowMoreLoading(true)
@@ -73,7 +75,7 @@ export default function CountsListPage() {
}, []) }, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0 const hasFilters = query.length > 0 || pendingOnly
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -90,7 +92,7 @@ export default function CountsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} /> <KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList, onClick: () => setPendingOnly((v) => !v), active: pendingOnly }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0"> <div className="relative flex-1 basis-0">
+9 -153
View File
@@ -9,7 +9,6 @@ import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data" import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
@@ -19,15 +18,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -35,14 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog" import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
@@ -53,10 +36,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function VendorsPage() { export default function VendorsPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null) const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -67,14 +49,7 @@ export default function VendorsPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig() const { config: procConfig } = useProcurementConfig()
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Separate from the paginated table list above — this needs every existing code (up to the // Separate from the paginated table list above — this needs every existing code (up to the
// server's page-size cap) to de-dupe against, not just the current page's 5 rows. // server's page-size cap) to de-dupe against, not just the current page's 5 rows.
@@ -85,8 +60,6 @@ export default function VendorsPage() {
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
@@ -131,56 +104,6 @@ export default function VendorsPage() {
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setTerms("")
setTaxReg("")
setCurrency("LKR")
setOptional(EMPTY_VENDOR_OPTIONAL)
setBranchFilterId(null)
setErrors({})
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
load()
setAllVendorCodes((codes) => [...codes, result.data.code])
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
// Hard delete. The server refuses (409) a vendor any purchase document or item still // Hard delete. The server refuses (409) a vendor any purchase document or item still
// references — those can only be deactivated, from the detail page's status toggle. // references — those can only be deactivated, from the detail page's status toggle.
async function handleDelete(vendor: Vendor) { async function handleDelete(vendor: Vendor) {
@@ -218,85 +141,18 @@ export default function VendorsPage() {
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p> <p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Button size="lg" onClick={() => setOpen(true)}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" /> <Plus className="size-5" />
New Vendor New Vendor
</Button> </Button>
} <CreateVendorDialog
/> open={open}
<DialogContent className="sm:max-w-md"> onOpenChange={setOpen}
<DialogHeader className="items-center text-center"> onCreated={(vendor) => {
<DialogTitle>New vendor</DialogTitle> load()
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription> setAllVendorCodes((codes) => [...codes, vendor.code])
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="v-name" required>Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}} }}
idPrefix="v"
errors={errors}
/> />
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="v-branch" required>Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => { setBranchFilterId(v); clearError("branch") }}>
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -33,6 +33,7 @@ import {
LayoutList, LayoutList,
LayoutTemplate, LayoutTemplate,
LineChart, LineChart,
CalendarRange,
ListTree, ListTree,
Lock, Lock,
MapPin, MapPin,
@@ -138,6 +139,8 @@ export const navItems: {
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock }, { title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart }, { title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// Split out of the Day-End Report page; same data and permission code, so no seeded NavItem of its own.
{ title: "Day-wise Income", code: "sales.day-end-report", href: "/dashboard/sales/day-wise-income", icon: CalendarRange },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, // { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
], ],
}, },
@@ -109,6 +109,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-wise-income") return "Day-wise Income"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices" if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report" if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM" if (pathname === "/dashboard/hrm") return "HRM"
@@ -78,6 +78,8 @@ export function PurchaseOrderListView({
actionsHeader = "Actions", actionsHeader = "Actions",
emptyMessage, emptyMessage,
onTotalChange, onTotalChange,
status: controlledStatus,
onStatusChange,
}: { }: {
/** Pins the list to one status (the approval queue); hides the status filter. */ /** Pins the list to one status (the approval queue); hides the status filter. */
fixedStatus?: PurchaseOrderStatus fixedStatus?: PurchaseOrderStatus
@@ -88,6 +90,9 @@ export function PurchaseOrderListView({
emptyMessage?: ReactNode emptyMessage?: ReactNode
/** Total rows matching the current filters, e.g. for a count badge in the page header. */ /** Total rows matching the current filters, e.g. for a count badge in the page header. */
onTotalChange?: (total: number | null) => void onTotalChange?: (total: number | null) => void
/** Optional controlled status filter — lets the page drive it (e.g. from a stat card). */
status?: PurchaseOrderStatus | "All"
onStatusChange?: (status: PurchaseOrderStatus | "All") => void
}) { }) {
const router = useRouter() const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
@@ -103,7 +108,9 @@ export function PurchaseOrderListView({
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [innerStatus, setInnerStatus] = useState<StatusFilter>("All")
const status = controlledStatus ?? innerStatus
const setStatus = onStatusChange ?? setInnerStatus
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" }) const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
const [sortKey, setSortKey] = useState<SortKey>("created") const [sortKey, setSortKey] = useState<SortKey>("created")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc") const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
@@ -27,6 +27,7 @@ export function WarrantyFields({
onChange, onChange,
error, error,
disabled, disabled,
hideLabel,
}: { }: {
config: ProductConfig config: ProductConfig
/** Active rows of the Warranty Periods master, in months. */ /** Active rows of the Warranty Periods master, in months. */
@@ -36,6 +37,8 @@ export function WarrantyFields({
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
error?: string error?: string
disabled?: boolean disabled?: boolean
/** Leave out the "Warranty" label when the surrounding section already has that heading. */
hideLabel?: boolean
}) { }) {
const groups = warrantyPeriodGroups(config, customMonths) const groups = warrantyPeriodGroups(config, customMonths)
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier). // The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
@@ -71,7 +74,7 @@ export function WarrantyFields({
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label className="text-base">Warranty</Label> {!hideLabel && <Label className="text-base">Warranty</Label>}
<RadioGroup <RadioGroup
value={warranty} value={warranty}
onValueChange={(v) => { onValueChange={(v) => {
@@ -0,0 +1,48 @@
import { formatSignedAmount, profitOrLoss } from "@/lib/format"
import { cn } from "@/lib/utils"
import { TableCell } from "@/components/ui/table"
/** Shared by the Day-End Report and Day-wise Income pages. */
/** Local yyyy-MM-dd, `offsetDays` from today. */
export function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
export function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
export function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
export function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
export const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
@@ -7,6 +7,9 @@ export interface KpiTileSpec {
label: string label: string
value: number value: number
icon?: LucideIcon icon?: LucideIcon
/** Makes the tile a filter toggle for the list below (see StatCard). */
onClick?: () => void
active?: boolean
} }
/** /**
@@ -20,7 +23,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4"> <div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
{tiles {tiles
? tiles.map((tile) => ( ? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} /> <StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} onClick={tile.onClick} active={tile.active} />
)) ))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)} : Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div> </div>
@@ -58,6 +58,10 @@ export interface StatCardProps {
valueClassName?: string valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */ /** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */
onClick?: () => void
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
} }
/** /**
@@ -76,18 +80,29 @@ export function StatCard({
className, className,
valueClassName, valueClassName,
iconClassName, iconClassName,
onClick,
active = false,
}: StatCardProps) { }: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0 const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0 const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive) const isGood = hasDelta && (invert ? !isPositive : isPositive)
// Clickable cards are real buttons (keyboard + screen reader); plain cards stay divs.
const Root = onClick ? "button" : "div"
return ( return (
// A size container: the card reads its *own* width, so it lays out the same whether a page puts // A size container: the card reads its *own* width, so it lays out the same whether a page puts
// it in a 2-up phone grid or a wide desktop row. // it in a 2-up phone grid or a wide desktop row.
<div <Root
{...(onClick
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})}
className={cn( className={cn(
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30", "@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
)} )}
> >
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the {/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
@@ -122,10 +137,11 @@ export function StatCard({
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>} {deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div> </div>
) : ( ) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p> // Wraps rather than truncating, so a hint like "12 sales by card" is never cut off in a narrow card.
hint && <p className="text-xs leading-snug wrap-break-word text-muted-foreground">{hint}</p>
)} )}
</div> </div>
</div> </div>
</div> </Root>
) )
} }
@@ -0,0 +1,200 @@
"use client"
import { useEffect, useState } from "react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
/** Currency isn't asked for on create; new vendors get this (the server requires one). Editable later on the vendor page. */
const DEFAULT_VENDOR_CURRENCY = "LKR"
/**
* The one "New vendor" modal — used by the Vendors list and by the New Purchase Order vendor picker's "+" button,
* so both create vendors the same way. The code is generated from the name (de-duped against every existing code),
* the optional fields follow Procurement Configuration, and the branch follows the header's branch switcher
* (an admin with no branch picked chooses one here).
*/
export function CreateVendorDialog({
open,
onOpenChange,
onCreated,
description = "Create a supplier record. Its code is generated from the name.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (vendor: Vendor) => void
description?: string
}) {
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const { config: procConfig } = useProcurementConfig()
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [pickedBranchId, setPickedBranchId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every existing code (up to the server's page-size cap), not just one page, so the generated code is unique.
const [existingCodes, setExistingCodes] = useState<string[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? pickedBranchId) : myBranchId) : null
const generatedCode = name.trim() ? generateVendorCode(name, existingCodes) : ""
useEffect(() => {
if (!open) return
vendorsApi.list({ pageSize: 200 }).then((res) => setExistingCodes(res.items.map((v) => v.code))).catch(() => {})
}, [open])
useEffect(() => {
if (open && showBranchPicker) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [open, showBranchPicker])
function reset() {
setName("")
setTerms("")
setTaxReg("")
setOptional(EMPTY_VENDOR_OPTIONAL)
setPickedBranchId(null)
setErrors({})
}
function close(next: boolean) {
onOpenChange(next)
if (!next) reset()
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedCode,
name,
...vendorTermsTaxPayload(procConfig, terms, taxReg),
currency: DEFAULT_VENDOR_CURRENCY,
...vendorOptionalPayload(procConfig, optional),
branchId: effectiveBranchId ?? 0,
})
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
onCreated(result.data)
close(false)
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cv-name" required>Name</FieldLabel>
<Input id="cv-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cv-code">Code (auto-generated)</FieldLabel>
<Input id="cv-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="cv-terms">Payment terms (optional)</FieldLabel>
<Input id="cv-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="cv-taxreg">Tax registration (optional)</FieldLabel>
<Input id="cv-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="cv"
errors={errors}
/>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="cv-branch" required>Branch</FieldLabel>
<Select<number | null> value={pickedBranchId} onValueChange={(v) => { setPickedBranchId(v); clearError("branch") }}>
<SelectTrigger id="cv-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+2
View File
@@ -40,6 +40,8 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
createdBy?: number createdBy?: number
/** true = fully paid, false = balance still due. */ /** true = fully paid, false = balance still due. */
paid?: boolean paid?: boolean
/** true = past its due date with a balance still owed. */
overdue?: boolean
/** Matches invoice number or customer name. */ /** Matches invoice number or customer name. */
q?: string q?: string
} }
+12
View File
@@ -394,6 +394,14 @@ export interface ProductConfig {
itemImagesEnabled: boolean itemImagesEnabled: boolean
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */ /** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
customWarrantyPeriodsEnabled: boolean customWarrantyPeriodsEnabled: boolean
/** Server-enforced on item writes: off = new items are Stocked; existing natures are kept. */
stockNatureEnabled: boolean
/** Server-enforced on item writes: off = new items are untracked (None); existing modes are kept. */
trackingModeEnabled: boolean
/** Advisory: shows the low-stock alert section on item screens. Saved reorder rules keep working. */
lowStockAlertsEnabled: boolean
/** Advisory: lets the low-stock alert watch one warehouse. Needs `lowStockAlertsEnabled`. */
alertWarehouseEnabled: boolean
updatedAt: string | null updatedAt: string | null
updatedBy: number | null updatedBy: number | null
} }
@@ -410,6 +418,10 @@ export interface UpdateProductConfigRequest {
customVariantsEnabled: boolean customVariantsEnabled: boolean
itemImagesEnabled: boolean itemImagesEnabled: boolean
customWarrantyPeriodsEnabled: boolean customWarrantyPeriodsEnabled: boolean
stockNatureEnabled: boolean
trackingModeEnabled: boolean
lowStockAlertsEnabled: boolean
alertWarehouseEnabled: boolean
} }
/** /**
+196
View File
@@ -0,0 +1,196 @@
# Notification Flow — Current State & Analysis
How notifications are raised, configured, stored and shown in ERPCore today, every event that
raises one, and the gaps found while tracing the code (2026-10-20).
> **Scope:** the in-app notification system (the bell in the header) and its configuration
> screen (**Settings → Notification Config**). The separate **Day-End Owner Report** (SMS + email
> to the owner) is covered in `DAY_END_OWNER_REPORT_PLAN.md`; how it relates is in §8.
---
## 1. Overview
```
Business action (create GRN, post sale, approve PO, stock drops …)
│
▼
Service ──► INotificationService.RaiseAsync(moduleKey, title, message, entityType, entityId)
│ or NotifyUsersAsync(userIds, moduleKey, …) (targeted: approvals)
▼
NotificationService.DeliverAsync
│ 1. recipients = active users (all of them, or the given ids)
│ 2. channels = NotificationConfigService.ResolveEnabledChannelsAsync(moduleKey)
│ 3. In-App off (or module unknown) ──► STOP, nothing stored
▼
notifications (1 row) + notification_recipients (1 row per user, IsRead = false)
│
▼
Frontend Header bell ──► GET /api/v1/notifications?take=20 (on page load + when the bell opens)
│ → latest 20 for the signed-in user + unread count
└──────────────────► POST /api/v1/notifications/mark-all-read
```
Only the **In-App** channel is delivered. **Email** and **SMS** can be switched on per module in
Settings, but nothing sends them (§7, G2).
---
## 2. Components
| Layer | File | Role |
|---|---|---|
| Module registry | `Domain/Notifications/Modules/NotificationModules.cs` | One `[NotificationModule(key, label, description, sortOrder)]` class per notification type |
| | `Domain/Notifications/NotificationModuleAttribute.cs`, `NotificationModuleDefinition.cs` | Attribute + DTO for the registry |
| | `Services/NotificationModuleCatalog.cs` | Finds every `[NotificationModule]` by reflection at startup; throws on duplicate keys |
| Channels | `Domain/Notifications/NotificationChannel.cs` | `Email`, `Sms`, `InApp` |
| Data | `Domain/Entities/Notification.cs` | The event: `ModuleKey`, `Title`, `Message`, `RelatedEntityType`, `RelatedEntityId`, `CreatedAt` |
| | `Domain/Entities/NotificationRecipient.cs` | Per-user copy: `UserId`, `IsRead`, `ReadAt` |
| | `Domain/Entities/NotificationConfig.cs` | Singleton settings row (`ConfigId = 1`, `RowVersion` → ETag) |
| | `Domain/Entities/NotificationConfigModule.cs` | Per module: `EmailEnabled`, `SmsEnabled`, `InAppEnabled` |
| Delivery | `Services/NotificationService.cs` | `RaiseAsync`, `NotifyUsersAsync`, `GetFeedAsync`, `MarkAllReadAsync` |
| Settings | `Services/NotificationConfigService.cs` | Get/update settings, `ResolveEnabledChannelsAsync(moduleKey)` |
| Low stock | `Services/Stock/LowStockTracker.cs`, `LowStockAlertService.cs`, `Infra/Filters/LowStockAlertFilter.cs` | Collects stock issues during a request, raises `stock.low` after it commits |
| API | `Controllers/NotificationsController.cs` | `GET /api/v1/notifications`, `POST /api/v1/notifications/mark-all-read` |
| | `Controllers/NotificationConfigController.cs` | `GET/PUT /api/v1/notification-config` (policy `settings`) |
| Frontend | `components/Layouts/Header.tsx` | Bell icon, unread badge, popover list, "mark all read" |
| | `app/dashboard/settings/notification-config/page.tsx` | Per-module Email / SMS / In-App switches |
| | `lib/api/notifications.ts`, `lib/api/notification-config.ts` | API clients |
---
## 3. Data model
```
notification_config (1 row)
└─< notification_config_modules (ModuleKey, EmailEnabled, SmsEnabled, InAppEnabled)
notifications (NotificationId, ModuleKey, Title, Message, RelatedEntityType, RelatedEntityId, CreatedAt)
└─< notification_recipients (NotificationId, UserId, IsRead, ReadAt)
```
- One `notifications` row per event; one `notification_recipients` row **per user** who should see it.
- Read state is per user (`IsRead`, `ReadAt`).
- Nothing is ever deleted or archived — both tables grow forever (§7, G7).
---
## 4. Configuration flow
1. **Registry.** Each notification type is a class with `[NotificationModule("grn.created", "GRN Created", "…", 30)]`. The catalog is built once by reflection, so **adding a module = adding one class**; it then appears on the settings screen automatically.
2. **Seed.** `DataSeeder.SeedNotificationConfigAsync` creates the singleton `notification_config` row **with no module rows**.
3. **Settings screen** (`/dashboard/settings/notification-config`): lists every catalog module with three switches (Email, SMS, In-App). Saving sends **every** module (`PUT`, `If-Match` ETag); the server rejects unknown keys (422) and missing keys (422), then replaces all module rows.
4. **At send time** `ResolveEnabledChannelsAsync(moduleKey)` reads that module's row. **No row ⇒ no channels ⇒ nothing is delivered.**
> Consequence: on a fresh database **every notification is off** until an admin opens Settings →
> Notification Config and switches modules on (§7, G3).
---
## 5. Delivery flow (backend)
### 5.1 Broadcast — `RaiseAsync`
Used by almost every trigger. Recipients = **every active user** in the system — no role, branch
or permission filter (§7, G4).
### 5.2 Targeted — `NotifyUsersAsync(userIds, …)`
Used by the approval flow only: the approvers of a pending request, or the requester when it is
approved / rejected. Inactive users in the list are dropped.
### 5.3 Shared steps (`DeliverAsync`)
1. Stop if there are no active recipients.
2. Resolve channels for the module; stop unless **In-App** is enabled.
3. Insert one `Notification` + one `NotificationRecipient` per user, `SaveChangesAsync`.
### 5.4 Timing
Most services call `RaiseAsync` **after** their own transaction commits (e.g. `GrnService.CreateAsync`
commits the GRN, then raises `grn.created`). So a notification never exists for a rolled-back
document — but if saving the notification throws, the request fails **after** the document was
already saved (§7, G6).
### 5.5 Low-stock alerts (`stock.low`)
```
FifoCostingService.ConsumeAsync (every stock issue: sale, transfer, adjustment, return, production)
└─► LowStockTracker.Track(itemId, warehouseId, qty) (per request, in memory)
LowStockAlertFilter (MVC action filter, after the action succeeds)
└─► LowStockAlertService: for each tracked item/warehouse with a reorder rule,
if stock crossed from above the reorder point to at/below it in this request
→ RaiseAsync("stock.low", "Low stock: <item>", "<sku> — <item> is down to <qty> …")
```
It fires only on the crossing (not on every later sale while still low). Unlike the other
triggers this path is already **best-effort**: a failed action drains the tracker (nothing alerted),
and any error in the check is caught and logged without failing the request.
---
## 6. Every trigger
| Module key | Registered | Raised by | When | Recipients |
|---|---|---|---|---|
| `sale.create` | ✅ | `SalesSlipService` | Sales slip created | All active users |
| `sale.posted` | ✅ | `SalesSlipService` | Sales slip posted | All |
| `grn.created` | ✅ | `GrnService` | GRN created | All |
| `grn.confirmed` | ✅ | `GrnService` | GRN confirmed | All |
| `purchase.order.created` | ✅ | `PurchaseOrderService` | PO created | All |
| `purchase.order.pending_approval` | ❌ **not in catalog** | `PurchaseOrderService` | PO submitted for approval | **Never delivered** (G1) |
| `purchase.order.approved` | ✅ | `PurchaseOrderService` | PO approved | All |
| `purchase.order.rejected` | ❌ **not in catalog** | `PurchaseOrderService` | PO rejected | **Never delivered** (G1) |
| `purchase.return.created` | ✅ | `PurchaseReturnService` | Purchase return created | All |
| `stock.adjustment.created` | ✅ | `AdjustmentService` | Adjustment created / posted | All |
| `stock.transfer.created` | ✅ | `TransferService` | Transfer created | All |
| `stock.low` | ✅ | `LowStockAlertService` | Stock crosses a reorder point | All |
| `approval.pending` | ✅ | `ApprovalService` | Stock/price adjustment awaits approval | The approvers |
| `approval.approved` | ✅ | `AdjustmentService`, `PriceAdjustmentService` | Request fully approved | The requester |
| `approval.rejected` | ✅ | `AdjustmentService`, `PriceAdjustmentService` | Request rejected | The requester |
| `item.created` | ✅ | `ItemService` | Item created | All |
| `item.updated` | ✅ | `ItemService` | Item updated | All |
**Not covered at all:** sales invoices (create / post / payment), expenses, bank deposits, purchase
invoices & payments, requisitions, RFQs, stock counts, production runs, HRM (leave, payroll),
day-end close, customer credit limit, overdue invoices.
---
## 7. Gaps & risks
| # | Gap | Impact | Suggested fix |
|---|---|---|---|
| **G1** | `purchase.order.pending_approval` and `purchase.order.rejected` are raised but **not registered** as modules. `ResolveEnabledChannelsAsync` finds no row → returns no channels → silently dropped. | Approvers are never told a PO is waiting; requesters never see a rejection. | Add two `[NotificationModule]` classes; ideally send pending-approval only to PO approvers (`NotifyUsersAsync`) like `approval.pending`. Add a startup check that every key passed to `RaiseAsync` exists (constants class instead of string literals). |
| **G2** | **Email / SMS switches do nothing.** `DeliverAsync` only checks In-App; no sender is called. | Admins switch on email/SMS and nothing happens — misleading. | Reuse the owner-report messaging path (`IMessageSender` → AuthHex `/api/notify/email|sms`), using each user's email / mobile; or hide the two columns until implemented. |
| **G3** | Seed creates the config **without module rows** ⇒ everything off on a new install. | New deployments look like notifications are broken. | Seed every catalog module with In-App on (and add rows for modules added later on startup). |
| **G4** | `RaiseAsync` broadcasts to **every active user** — no role, permission or branch filter. | Cashiers get PO/GRN/item noise; users see other branches' documents; the bell fills up and gets ignored. | Per-module recipient rules: by permission code (e.g. `receiving` for GRN), by role, and by branch of the document. |
| **G5** | Bell shows the latest 20 only; **no per-item read**, no link to the record (`RelatedEntityType/Id` are stored but unused), no "view all" page. | Users can't open the GRN/PO from the alert or work through older ones. | Map `RelatedEntityType` → route (as `SEARCH_TYPE_META` in `Header.tsx` already does for search); mark one as read on click; add `/dashboard/notifications` with paging. |
| **G6** | Notification is saved after the business commit, outside it, and exceptions aren't caught (all triggers except `stock.low`, which is already best-effort). | A notification DB error returns 500 for a document that **was** saved → user retries → duplicate. | Wrap `RaiseAsync` calls in try/catch + log (notifications are best-effort), or write an outbox row inside the same transaction and deliver after. |
| **G7** | No retention. One recipient row per active user per event. | 50 users × a few hundred events/day ≈ tens of thousands of rows a day. | Purge read notifications older than N days (background job, like `OwnerReportJob`); index `(UserId, IsRead)`. |
| **G8** | No real-time push — the bell refreshes only on page load and when opened. | Unread badge is stale while a user stays on one page. | Poll every 60 s while the tab is visible, or SignalR later. |
| **G9** | `ResolveEnabledChannelsAsync` queries the DB on every notification. | An extra query per event (minor). | Cache the module map, invalidate on `PUT /notification-config`. |
| **G10** | Titles/messages are hard-coded English strings in each service. | No templates, no Sinhala/Tamil, inconsistent wording. | Optional: per-module templates in the catalog. |
---
## 8. Relation to the Day-End Owner Report
| | In-app notifications (this doc) | Day-End Owner Report |
|---|---|---|
| Trigger | Business events, as they happen | Once a day, at a configured time (`OwnerReportJob`) |
| Recipients | ERP users (all, or targeted ids) | Owner contacts entered in Settings (not ERP users) |
| Channels | In-App only (Email/SMS switches not implemented) | SMS + email through AuthHex |
| Send log | none | `owner_report_dispatches` (status, attempts, error) |
| Config | Settings → Notification Config | Settings → Configuration → Day-End Owner Report |
The owner report already solved what G2 needs — `IMessageSender` + AuthHex `/api/notify/*` with no
credentials in ERPCore — so wiring Email/SMS for notifications can reuse it rather than add a second
mail path. Note the providers themselves currently fail (Hostinger SMTP outbound disabled; SMS
gateway out of credit/limit) — see the owner report send log.
---
## 9. Recommended order of work
1. **G1** register the two missing PO modules (+ key constants) — small, fixes silent loss.
2. **G3** seed every module with In-App on.
3. **G6** make notification delivery best-effort (try/catch + log).
4. **G5** clickable notifications + mark one read + "view all" page.
5. **G4** recipient rules by permission / role / branch.
6. **G2** Email/SMS delivery via `IMessageSender` (after the SMTP/SMS provider accounts work).
7. **G7 / G8 / G9** retention job, polling, config cache.