bug fixers 10/20
This commit is contained in:
@@ -92,8 +92,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
[FromQuery] bool? overdue,
|
||||
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.
|
||||
[HttpGet("totals")]
|
||||
@@ -108,8 +109,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
[FromQuery] bool? overdue,
|
||||
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}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -51,6 +51,31 @@ public class ProductConfig
|
||||
/// </summary>
|
||||
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 int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
|
||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||
bool CustomWarrantyPeriodsEnabled,
|
||||
bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
|
||||
[Required] public bool? CustomVariantsEnabled { get; set; }
|
||||
[Required] public bool? ItemImagesEnabled { 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.ItemImagesEnabled).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).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
@@ -213,7 +213,11 @@ public static class DataSeeder
|
||||
WarrantyPeriodsEnabled = true,
|
||||
CustomVariantsEnabled = false,
|
||||
ItemImagesEnabled = false,
|
||||
CustomWarrantyPeriodsEnabled = false
|
||||
CustomWarrantyPeriodsEnabled = false,
|
||||
StockNatureEnabled = true,
|
||||
TrackingModeEnabled = true,
|
||||
LowStockAlertsEnabled = true,
|
||||
AlertWarehouseEnabled = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
+11566
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")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("AlertWarehouseEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("BatchListsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
@@ -4892,6 +4897,11 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("LowStockAlertsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("int unsigned");
|
||||
@@ -4906,11 +4916,21 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("StockNatureEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("SubcategoriesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("TrackingModeEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
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>
|
||||
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<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -321,7 +321,7 @@ public sealed class ItemService : IItemService
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
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 —
|
||||
// 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)
|
||||
=> ValidateFeatureConfiguration(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
request.TrackingMode, request.StockNature, request.Warranty, config);
|
||||
|
||||
/// <summary>
|
||||
/// An item keeps the tracking mode it already has even after that feature is switched off —
|
||||
/// otherwise it could not be edited at all; only a change <em>to</em> a disabled mode is refused.
|
||||
/// An item keeps the tracking mode and stock nature it already has even after that feature is
|
||||
/// switched off — otherwise it could not be edited at all; only a change <em>to</em> a disabled
|
||||
/// value is refused.
|
||||
/// </summary>
|
||||
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode)
|
||||
private static void ValidateFeatureConfiguration(
|
||||
UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode, StockNature currentStockNature)
|
||||
=> ValidateFeatureConfiguration(
|
||||
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(
|
||||
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;
|
||||
|
||||
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 &&
|
||||
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
|
||||
throw new DomainException(
|
||||
|
||||
@@ -62,6 +62,12 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
// Custom warranty periods depend on warranty periods - switching the parent off
|
||||
// switches this off too, so it can never be left on by itself.
|
||||
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.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -82,5 +88,6 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||
c.CustomVariantsEnabled, c.ItemImagesEnabled,
|
||||
c.CustomWarrantyPeriodsEnabled,
|
||||
c.StockNatureEnabled, c.TrackingModeEnabled, c.LowStockAlertsEnabled, c.AlertWarehouseEnabled,
|
||||
c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_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 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);
|
||||
}
|
||||
|
||||
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.
|
||||
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>
|
||||
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))
|
||||
{
|
||||
@@ -106,6 +106,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
// 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 == 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -371,10 +371,21 @@ export default function ExpensesPage() {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{summary ? (
|
||||
<>
|
||||
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} />
|
||||
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} />
|
||||
{/* Cards double as filters (click again to clear). "Outstanding (all time)" ignores the
|
||||
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={`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" />)
|
||||
|
||||
@@ -97,14 +97,23 @@ export default function HelpPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!tickets) return []
|
||||
const term = debouncedSearch.toLowerCase()
|
||||
if (!term) return tickets
|
||||
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 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">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
|
||||
<StatCard label="Open" value={stats.open} icon={Inbox} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
<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} onClick={() => toggleStatus("Open")} active={statusFilter === "Open"} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} onClick={() => toggleStatus("InProgress")} active={statusFilter === "InProgress"} />
|
||||
<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" />)
|
||||
|
||||
@@ -170,10 +170,12 @@ export default function EmployeesPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
{/* Cards double as the status filter (click again to clear). "Inactive" spans several
|
||||
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="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" />)
|
||||
|
||||
@@ -238,7 +238,14 @@ export default function LeaveRequestsPage() {
|
||||
</DialogContent>
|
||||
</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="relative w-full sm:max-w-xs">
|
||||
|
||||
@@ -113,28 +113,31 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{/* Icon-only to keep the column narrow; the label lives in aria-label/title. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="success"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canApprove || approvingId === po.poId}
|
||||
onClick={() => handleApprove(po, reload)}
|
||||
aria-label={`Approve ${po.docNo}`}
|
||||
title={approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canApprove}
|
||||
onClick={() => {
|
||||
setRejectReason("")
|
||||
setRejectTarget({ po, reload })
|
||||
}}
|
||||
aria-label={`Reject ${po.docNo}`}
|
||||
title="Reject"
|
||||
>
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,31 +19,15 @@ import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -147,16 +131,7 @@ function NewPurchaseOrderContent() {
|
||||
const [submitting, setSubmitting] = 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 [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() {
|
||||
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
|
||||
}, [])
|
||||
|
||||
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(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
@@ -446,62 +381,19 @@ function NewPurchaseOrderContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||
<DialogTrigger
|
||||
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!vErrors.name}>
|
||||
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||
<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>
|
||||
<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" onClick={() => setVendorDialogOpen(true)}>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
<CreateVendorDialog
|
||||
open={vendorDialogOpen}
|
||||
onOpenChange={setVendorDialogOpen}
|
||||
description="Create a supplier record without leaving this PO. Its code is generated from the name."
|
||||
onCreated={(vendor) => {
|
||||
// Active vendors only in this picker — a new one is Active, so add it and select it.
|
||||
setVendors((prev) => [...(prev ?? []), vendor])
|
||||
setVendorId(vendor.vendorId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{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 { errorMessage } from "@/lib/error-map"
|
||||
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 { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
@@ -23,6 +23,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [submittingId, setSubmittingId] = 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) {
|
||||
setDeletingId(po.poId)
|
||||
@@ -83,9 +85,16 @@ export default function PurchaseOrdersListPage() {
|
||||
</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
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
renderActions={(po, { reload }) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
|
||||
@@ -78,7 +78,13 @@ export default function RequisitionsListPage() {
|
||||
</Link>
|
||||
</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">
|
||||
<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 [branches, setBranches] = useState<Branch[]>([])
|
||||
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
|
||||
// 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}`
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -61,7 +64,7 @@ export default function RfqsListPage() {
|
||||
</Link>
|
||||
</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 && (
|
||||
<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 { useParams, usePathname, useRouter } from "next/navigation"
|
||||
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 { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { brandsApi } from "@/lib/api/brands"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
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 { validateItemForm } from "@/lib/validations/master-data"
|
||||
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 { ItemPriceHistory } from "@/types/approvals"
|
||||
|
||||
@@ -43,6 +44,8 @@ import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
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
|
||||
@@ -102,6 +105,11 @@ function ItemDetailPage() {
|
||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||
const [uoms, setUoms] = useState<{ uomId: number; 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)
|
||||
|
||||
// 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.
|
||||
const [description, setDescription] = useState("")
|
||||
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 [brandId, setBrandId] = useState<number | null>(null)
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
// Default vendor, tax class, and tracking mode are no longer editable on this page —
|
||||
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||
// Default vendor isn't editable on this page — carried through unchanged so a save doesn't clear it.
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
// Warranty: edited in the Warranty section below (options follow Product configuration).
|
||||
@@ -211,6 +216,7 @@ function ItemDetailPage() {
|
||||
})
|
||||
.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(() => {})
|
||||
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
|
||||
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
|
||||
@@ -218,6 +224,17 @@ function ItemDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// item id is known. A large page size is fine here — one item's lifetime history.
|
||||
useEffect(() => {
|
||||
@@ -313,7 +330,7 @@ function ItemDetailPage() {
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
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
|
||||
// rules set under Reorder settings survive an unrelated edit (name, price, …).
|
||||
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 =
|
||||
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)
|
||||
try {
|
||||
const result = await itemsApi.update(
|
||||
item.itemId,
|
||||
{
|
||||
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
|
||||
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number,
|
||||
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,
|
||||
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
|
||||
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
|
||||
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
|
||||
sellingMinPrice: allowPrices && sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
|
||||
sellingMaxPrice: allowPrices && sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
|
||||
sellingFlatPrice: allowPrices && sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
|
||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||
},
|
||||
@@ -347,7 +376,8 @@ function ItemDetailPage() {
|
||||
)
|
||||
let saved = result.data
|
||||
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()
|
||||
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
|
||||
: []
|
||||
@@ -428,114 +458,171 @@ function ItemDetailPage() {
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to item
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
|
||||
<p className="text-base text-muted-foreground">Update this item's basic info, pricing and classification.</p>
|
||||
</div>
|
||||
{conflict && (
|
||||
<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" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
|
||||
</div>
|
||||
/** 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 (
|
||||
<div className="flex flex-col gap-5 sm:gap-6">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to item
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Edit Item</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
{item.sku} — {item.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<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" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
|
||||
</div>
|
||||
)}
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<section className={sectionClass}>
|
||||
<h2 className="text-lg font-semibold text-foreground">Basic details</h2>
|
||||
<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">
|
||||
<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} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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} />
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Category</Label>
|
||||
<Select<number | null>
|
||||
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>
|
||||
{config?.subcategoriesEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<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]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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} />
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">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>
|
||||
{config?.sellingPricesEnabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling min price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
|
||||
<Label className="text-base">Subcategory (optional)</Label>
|
||||
<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={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
|
||||
</div>
|
||||
)}
|
||||
{config?.brandsEnabled && (
|
||||
<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]} />
|
||||
<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 className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<h2 className="text-lg font-semibold text-foreground">Units & stock</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Base UOM</Label>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
{!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>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
|
||||
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
|
||||
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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>
|
||||
<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 className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
|
||||
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
|
||||
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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>
|
||||
</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">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
|
||||
@@ -547,56 +634,168 @@ function ItemDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{config?.trackingModeEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<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} />
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict || hasHistory}>
|
||||
<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} />
|
||||
</div>
|
||||
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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 }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
|
||||
</div>
|
||||
{config.alertWarehouseEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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 }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<WarrantyFields
|
||||
config={config}
|
||||
customMonths={customWarrantyMonths}
|
||||
warranty={warranty}
|
||||
months={warrantyPeriodMonths}
|
||||
onChange={(w, m) => {
|
||||
setWarranty(w)
|
||||
setWarrantyPeriodMonths(m)
|
||||
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
|
||||
}}
|
||||
error={errors.warrantyPeriodMonths || undefined}
|
||||
disabled={conflict}
|
||||
/>
|
||||
</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>
|
||||
{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 && (
|
||||
<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
|
||||
hideLabel
|
||||
config={config}
|
||||
customMonths={customWarrantyMonths}
|
||||
warranty={warranty}
|
||||
months={warrantyPeriodMonths}
|
||||
onChange={(w, m) => {
|
||||
setWarranty(w)
|
||||
setWarrantyPeriodMonths(m)
|
||||
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
|
||||
}}
|
||||
error={errors.warrantyPeriodMonths || undefined}
|
||||
disabled={conflict}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</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} />
|
||||
</section>
|
||||
|
||||
{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" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -672,15 +871,17 @@ function ItemDetailPage() {
|
||||
|
||||
{/* 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. */}
|
||||
<ItemReorderPanel
|
||||
key={JSON.stringify(item.reorder ?? [])}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder ?? []}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
|
||||
/>
|
||||
{config?.lowStockAlertsEnabled && (
|
||||
<ItemReorderPanel
|
||||
key={JSON.stringify(item.reorder ?? [])}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder ?? []}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
|
||||
@@ -254,6 +254,19 @@ export default function NewItemPage() {
|
||||
if (!config.batchListsEnabled && trackingMode === "Batch") {
|
||||
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") {
|
||||
setWarranty("NonWarranty")
|
||||
setWarrantyPeriodMonths(null)
|
||||
@@ -267,6 +280,10 @@ export default function NewItemPage() {
|
||||
}, [
|
||||
config,
|
||||
trackingMode,
|
||||
stockNature,
|
||||
lowStockAt,
|
||||
reorderQty,
|
||||
warehouseId,
|
||||
warranty,
|
||||
config?.serialNumbersEnabled,
|
||||
config?.batchListsEnabled,
|
||||
@@ -585,7 +602,7 @@ export default function NewItemPage() {
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
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
|
||||
const createdIds: number[] = []
|
||||
try {
|
||||
const effectiveStockNature = config?.stockNatureEnabled ? stockNature : "Stocked"
|
||||
const effectiveTrackingMode =
|
||||
!config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
!config?.trackingModeEnabled
|
||||
? "None"
|
||||
: !config?.serialNumbersEnabled && trackingMode === "Serial"
|
||||
? "None"
|
||||
: !config?.batchListsEnabled && trackingMode === "Batch"
|
||||
? "None"
|
||||
@@ -652,7 +672,7 @@ export default function NewItemPage() {
|
||||
subCategoryId,
|
||||
brandId,
|
||||
baseUomId,
|
||||
stockNature,
|
||||
stockNature: effectiveStockNature,
|
||||
trackingMode: effectiveTrackingMode,
|
||||
warranty: effectiveWarranty,
|
||||
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
|
||||
// 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)
|
||||
if (lowStockAt.trim() && watched.length > 0) {
|
||||
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : null
|
||||
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) }))
|
||||
let failed = 0
|
||||
for (const itemId of createdIds) {
|
||||
@@ -1000,6 +1021,7 @@ export default function NewItemPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config?.stockNatureEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
|
||||
@@ -1013,6 +1035,8 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{config?.trackingModeEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tracking mode</Label>
|
||||
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
|
||||
@@ -1041,9 +1065,11 @@ export default function NewItemPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{config?.lowStockAlertsEnabled && (
|
||||
<section className={sectionClass}>
|
||||
<div>
|
||||
<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."}
|
||||
</p>
|
||||
</div>
|
||||
{config.alertWarehouseEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
@@ -1117,8 +1144,10 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
<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">
|
||||
|
||||
@@ -259,6 +259,12 @@ export default function ItemsPage() {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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">
|
||||
{itemStats ? (
|
||||
<>
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} />
|
||||
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
|
||||
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
|
||||
<StatCard label="Total items" value={itemStats.total} icon={Package} onClick={() => filterByStock("All")} active={stock === "All"} />
|
||||
<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} onClick={() => filterByStock("OutOfStock")} active={stock === "OutOfStock"} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
@@ -426,8 +432,12 @@ export default function ItemsPage() {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
{config?.trackingModeEnabled && (
|
||||
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
|
||||
)}
|
||||
{config?.stockNatureEnabled && (
|
||||
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -56,6 +56,10 @@ export default function ProductSettingsPage() {
|
||||
customVariantsEnabled: config.customVariantsEnabled,
|
||||
itemImagesEnabled: config.itemImagesEnabled,
|
||||
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
|
||||
stockNatureEnabled: config.stockNatureEnabled,
|
||||
trackingModeEnabled: config.trackingModeEnabled,
|
||||
lowStockAlertsEnabled: config.lowStockAlertsEnabled,
|
||||
alertWarehouseEnabled: config.alertWarehouseEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
@@ -224,6 +228,54 @@ export default function ProductSettingsPage() {
|
||||
busy={saving === "brandsEnabled"}
|
||||
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>
|
||||
|
||||
{config.updatedAt && (
|
||||
@@ -249,6 +301,10 @@ const LABELS: Record<keyof ProductConfig, string> = {
|
||||
customVariantsEnabled: "Custom variants",
|
||||
itemImagesEnabled: "Item images",
|
||||
customWarrantyPeriodsEnabled: "Custom warranty periods",
|
||||
stockNatureEnabled: "Stock nature",
|
||||
trackingModeEnabled: "Tracking mode",
|
||||
lowStockAlertsEnabled: "Low stock alert",
|
||||
alertWarehouseEnabled: "Alert warehouse",
|
||||
updatedAt: "Updated at",
|
||||
updatedBy: "Updated by",
|
||||
}
|
||||
@@ -265,6 +321,10 @@ type FlagKey = keyof Pick<
|
||||
| "customVariantsEnabled"
|
||||
| "itemImagesEnabled"
|
||||
| "customWarrantyPeriodsEnabled"
|
||||
| "stockNatureEnabled"
|
||||
| "trackingModeEnabled"
|
||||
| "lowStockAlertsEnabled"
|
||||
| "alertWarehouseEnabled"
|
||||
>
|
||||
|
||||
function ToggleRow({
|
||||
|
||||
@@ -296,7 +296,13 @@ export default function GrnListPage() {
|
||||
</Link>
|
||||
</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
|
||||
searchFirst
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Banknote,
|
||||
CreditCard,
|
||||
FileDown,
|
||||
Hash,
|
||||
Landmark,
|
||||
LineChart,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
@@ -19,73 +20,26 @@ import {
|
||||
|
||||
import { dayEndReportApi } from "@/lib/api/day-end-report"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { formatAmount, formatReportDate, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useClientPagination } from "@/hooks/use-client-pagination"
|
||||
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 { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
|
||||
import { KV, localIso, mobileCard } 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
|
||||
|
||||
/** 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 }
|
||||
|
||||
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
|
||||
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. */
|
||||
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")
|
||||
/** "1 sale" / "12 sales". */
|
||||
const salesCountLabel = (n: number) => `${n} sale${n === 1 ? "" : "s"}`
|
||||
|
||||
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
|
||||
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()
|
||||
// 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 [date, setDate] = useState(localIso())
|
||||
const [from, setFrom] = useState(localIso(-29))
|
||||
const [to, setTo] = useState(localIso())
|
||||
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
|
||||
// Fixed for the page — not user-editable.
|
||||
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : localIso()
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const dayKey = `${date}|${branchId}`
|
||||
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(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(() => {
|
||||
if (!date) return
|
||||
@@ -269,39 +221,15 @@ export default function DayEndReportPage() {
|
||||
}
|
||||
}, [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() {
|
||||
if (!day?.data) return
|
||||
setDownloading("day")
|
||||
setDownloading(true)
|
||||
try {
|
||||
await downloadDayEndReportPdf(day.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
const salesPage = useClientPagination(sales, date)
|
||||
const expensePage = useClientPagination(expenses, date)
|
||||
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -324,7 +251,7 @@ export default function DayEndReportPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
A day's sales, how they were paid, its expenses and the resulting income — plus income day by day.
|
||||
A day's sales, how they were paid, its expenses and the resulting income.
|
||||
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -332,15 +259,23 @@ export default function DayEndReportPage() {
|
||||
|
||||
{/* --- One day ------------------------------------------------------------------------- */}
|
||||
<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 className="flex flex-col gap-2">
|
||||
<Label htmlFor="der-date" className="text-base">Business date</Label>
|
||||
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" />
|
||||
{/* Date on the right, above the Day-wise income / PDF buttons. */}
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{/* Fixed, not editable: today, or the day opened from Day-wise Income (?date=). */}
|
||||
<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 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" />
|
||||
{downloading ? "Creating PDF…" : "Download day PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{day?.error && <p className="text-sm text-destructive">{day.error}</p>}
|
||||
@@ -354,29 +289,20 @@ export default function DayEndReportPage() {
|
||||
{s && (
|
||||
<>
|
||||
<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">
|
||||
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} />
|
||||
<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 label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} hint={salesCountLabel(s.salesCount)} />
|
||||
<StatCard
|
||||
label="Income"
|
||||
value={formatSignedAmount(s.income)}
|
||||
icon={s.income >= 0 ? TrendingUp : TrendingDown}
|
||||
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"}
|
||||
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"}
|
||||
label="Paid amount"
|
||||
value={formatAmount(s.paidAmount)}
|
||||
icon={Wallet}
|
||||
hint={`${salesCountLabel(s.salesCount - s.unpaidSalesCount)} paid`}
|
||||
/>
|
||||
<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>
|
||||
{/* Expenses and income cards live on Day-wise Income (Sales → Day-wise Income). */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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:{" "}
|
||||
@@ -566,172 +492,6 @@ export default function DayEndReportPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,12 +65,13 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
|
||||
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 }[] = [
|
||||
{ value: "All", label: "All payments" },
|
||||
{ value: "Unpaid", label: "Balance due" },
|
||||
{ value: "Paid", label: "Fully paid" },
|
||||
{ value: "Overdue", label: "Overdue" },
|
||||
]
|
||||
|
||||
export default function ApprovedInvoicesPage() {
|
||||
@@ -155,7 +156,8 @@ function ApprovedInvoices() {
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
|
||||
overdue: salesPayment === "Overdue" ? true : undefined,
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
|
||||
@@ -189,7 +191,8 @@ function ApprovedInvoices() {
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
|
||||
overdue: salesPayment === "Overdue" ? true : undefined,
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((data) => setTotalsResult({ key: totalsKey, data }))
|
||||
@@ -265,6 +268,8 @@ function ApprovedInvoices() {
|
||||
|
||||
<InvoiceTotalsCards
|
||||
totals={cardTotals}
|
||||
payment={salesPayment}
|
||||
onPaymentChange={(next) => changeSalesPayment(salesPayment === next ? "All" : next)}
|
||||
pageOnly={pageOnly}
|
||||
scope={[
|
||||
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.
|
||||
*/
|
||||
function InvoiceTotalsCards({
|
||||
totals, scope, pageOnly,
|
||||
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
|
||||
totals, scope, pageOnly, payment, onPaymentChange,
|
||||
}: {
|
||||
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) {
|
||||
return (
|
||||
<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>}
|
||||
</p>
|
||||
<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="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
|
||||
label="Balance due"
|
||||
value={formatAmount(totals.balanceTotal)}
|
||||
@@ -692,6 +704,8 @@ function InvoiceTotalsCards({
|
||||
hint="Still to collect"
|
||||
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
|
||||
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
|
||||
onClick={() => onPaymentChange("Unpaid")}
|
||||
active={payment === "Unpaid"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
@@ -700,6 +714,8 @@ function InvoiceTotalsCards({
|
||||
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")}
|
||||
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
|
||||
onClick={() => onPaymentChange("Overdue")}
|
||||
active={payment === "Overdue"}
|
||||
/>
|
||||
</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 [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||
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() {
|
||||
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) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -65,7 +78,7 @@ export default function UsersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch])
|
||||
useEffect(load, [page, pageSize, debouncedSearch, cardFilter])
|
||||
|
||||
function loadStats() {
|
||||
Promise.all([
|
||||
@@ -138,10 +151,10 @@ export default function UsersPage() {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
|
||||
<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} onClick={() => toggleCardFilter("Active")} active={cardFilter === "Active"} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} onClick={() => toggleCardFilter("Inactive")} active={cardFilter === "Inactive"} />
|
||||
<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" />)
|
||||
|
||||
@@ -43,25 +43,27 @@ export default function CountsListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
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(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query])
|
||||
useEffect(() => setPage(1), [query, pendingOnly])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
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) => {
|
||||
setCounts(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, query, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, query, pendingOnly, activeBranchId, branchingEnabled])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -73,7 +75,7 @@ export default function CountsListPage() {
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
const hasFilters = query.length > 0
|
||||
const hasFilters = query.length > 0 || pendingOnly
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -90,7 +92,7 @@ export default function CountsListPage() {
|
||||
</Link>
|
||||
</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="relative flex-1 basis-0">
|
||||
|
||||
+14
-158
@@ -9,7 +9,6 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
@@ -19,15 +18,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
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 { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
@@ -53,10 +36,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function VendorsPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | 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 [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 [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
|
||||
// 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 [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -131,56 +104,6 @@ export default function VendorsPage() {
|
||||
|
||||
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
|
||||
// references — those can only be deactivated, from the detail page's status toggle.
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Vendor
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
|
||||
</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>
|
||||
<Button size="lg" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-5" />
|
||||
New Vendor
|
||||
</Button>
|
||||
<CreateVendorDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onCreated={(vendor) => {
|
||||
load()
|
||||
setAllVendorCodes((codes) => [...codes, vendor.code])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
LayoutList,
|
||||
LayoutTemplate,
|
||||
LineChart,
|
||||
CalendarRange,
|
||||
ListTree,
|
||||
Lock,
|
||||
MapPin,
|
||||
@@ -138,6 +139,8 @@ export const navItems: {
|
||||
{ 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: "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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -109,6 +109,7 @@ function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
|
||||
if (pathname === "/dashboard/sales/warranties") return "Warranties"
|
||||
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/settings/owner-report") return "Day-End Owner Report"
|
||||
if (pathname === "/dashboard/hrm") return "HRM"
|
||||
|
||||
@@ -78,6 +78,8 @@ export function PurchaseOrderListView({
|
||||
actionsHeader = "Actions",
|
||||
emptyMessage,
|
||||
onTotalChange,
|
||||
status: controlledStatus,
|
||||
onStatusChange,
|
||||
}: {
|
||||
/** Pins the list to one status (the approval queue); hides the status filter. */
|
||||
fixedStatus?: PurchaseOrderStatus
|
||||
@@ -88,6 +90,9 @@ export function PurchaseOrderListView({
|
||||
emptyMessage?: ReactNode
|
||||
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
|
||||
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 { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
@@ -103,7 +108,9 @@ export function PurchaseOrderListView({
|
||||
|
||||
const [searchInput, setSearchInput] = 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 [sortKey, setSortKey] = useState<SortKey>("created")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
|
||||
|
||||
@@ -27,6 +27,7 @@ export function WarrantyFields({
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
hideLabel,
|
||||
}: {
|
||||
config: ProductConfig
|
||||
/** Active rows of the Warranty Periods master, in months. */
|
||||
@@ -36,6 +37,8 @@ export function WarrantyFields({
|
||||
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
/** Leave out the "Warranty" label when the surrounding section already has that heading. */
|
||||
hideLabel?: boolean
|
||||
}) {
|
||||
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).
|
||||
@@ -71,7 +74,7 @@ export function WarrantyFields({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-base">Warranty</Label>
|
||||
{!hideLabel && <Label className="text-base">Warranty</Label>}
|
||||
<RadioGroup
|
||||
value={warranty}
|
||||
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
|
||||
value: number
|
||||
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">
|
||||
{tiles
|
||||
? 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" />)}
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface StatCardProps {
|
||||
valueClassName?: string
|
||||
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
|
||||
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,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
onClick,
|
||||
active = false,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
const isGood = hasDelta && (invert ? !isPositive : isPositive)
|
||||
|
||||
// Clickable cards are real buttons (keyboard + screen reader); plain cards stay divs.
|
||||
const Root = onClick ? "button" : "div"
|
||||
|
||||
return (
|
||||
// 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.
|
||||
<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(
|
||||
"@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",
|
||||
className
|
||||
"@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",
|
||||
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
|
||||
@@ -122,10 +137,11 @@ export function StatCard({
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||
createdBy?: number
|
||||
/** true = fully paid, false = balance still due. */
|
||||
paid?: boolean
|
||||
/** true = past its due date with a balance still owed. */
|
||||
overdue?: boolean
|
||||
/** Matches invoice number or customer name. */
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -394,6 +394,14 @@ export interface ProductConfig {
|
||||
itemImagesEnabled: boolean
|
||||
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
|
||||
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
|
||||
updatedBy: number | null
|
||||
}
|
||||
@@ -410,6 +418,10 @@ export interface UpdateProductConfigRequest {
|
||||
customVariantsEnabled: boolean
|
||||
itemImagesEnabled: boolean
|
||||
customWarrantyPeriodsEnabled: boolean
|
||||
stockNatureEnabled: boolean
|
||||
trackingModeEnabled: boolean
|
||||
lowStockAlertsEnabled: boolean
|
||||
alertWarehouseEnabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user