Merge pull request 'Dev' (#118) from Dev into production

Reviewed-on: #118
This commit was merged in pull request #118.
This commit is contained in:
2026-10-21 11:10:56 +05:30
82 changed files with 39616 additions and 1669 deletions
@@ -0,0 +1,77 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.OwnerReport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
/// configuration screens (BranchConfigController, GlAccountConfigController).
/// </summary>
[Route("api/v1/settings/owner-report")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class OwnerReportController : ApiControllerBase
{
// One test send per minute (whole deployment) so the button can't be used to spam the owner.
private static readonly SemaphoreSlim TestGate = new(1, 1);
private static DateTime _lastTestUtc = DateTime.MinValue;
private readonly IOwnerReportService _service;
public OwnerReportController(IOwnerReportService service) => _service = service;
[HttpGet]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportConfigDto>> Get(CancellationToken ct)
{
var result = await _service.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<OwnerReportConfigDto>> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
{
var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("preview")]
[ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
=> Ok(await _service.PreviewAsync(date, ct));
[HttpPost("test")]
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
{
await TestGate.WaitAsync(ct);
try
{
if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
return StatusCode(StatusCodes.Status429TooManyRequests,
new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
_lastTestUtc = DateTime.UtcNow;
}
finally
{
TestGate.Release();
}
return Ok(await _service.SendTestAsync(request?.Date, ct));
}
[HttpGet("dispatches")]
[ProducesResponseType(typeof(PagedResponse<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _service.ListDispatchesAsync(query, ct));
}
@@ -92,8 +92,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to, [FromQuery] DateOnly? to,
[FromQuery] int? createdBy, [FromQuery] int? createdBy,
[FromQuery] bool? paid, [FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page. // Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")] [HttpGet("totals")]
@@ -108,8 +109,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to, [FromQuery] DateOnly? to,
[FromQuery] int? createdBy, [FromQuery] int? createdBy,
[FromQuery] bool? paid, [FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
public string TemplateCode { get; set; } = string.Empty; public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty; public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>
/// What the whole bundle sells for. On save it is split across the included lines' unit prices
/// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
/// 0 on templates created before bundle prices existed — their line prices stand as entered.
/// </summary>
public decimal BundlePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,79 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Day-end owner report settings — a singleton row, same shape as <see cref="SalesConfig"/>
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When <see cref="Enabled"/>, OwnerReportJob sends the
/// day's figures to every active <see cref="OwnerReportRecipient"/> at <see cref="SendAtLocalTime"/>.
/// </summary>
public class OwnerReportConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Master switch — nothing is sent automatically while off ("Send test now" still works).</summary>
public bool Enabled { get; set; }
public bool SmsEnabled { get; set; } = true;
public bool EmailEnabled { get; set; } = true;
/// <summary>Local time of day (DayEndReport:TimeZone) after which the day's report goes out.</summary>
public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
/// <summary>Adds one summary row per active branch when branching is switched on.</summary>
public bool IncludeBranchBreakdown { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
}
/// <summary>Someone who receives the day-end report — by SMS, email or both.</summary>
public class OwnerReportRecipient
{
public int RecipientId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).</summary>
public string? Mobile { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// One send of one day's report on one channel to one recipient — the send log, and the guard
/// against sending twice: (BusinessDate, Channel, RecipientId, IsTest) is unique for automatic
/// sends, so a restart or a second server instance can't send the same day again.
/// </summary>
public class OwnerReportDispatch
{
public long DispatchId { get; set; }
public DateOnly BusinessDate { get; set; }
public OwnerReportChannel Channel { get; set; }
public int? RecipientId { get; set; }
public OwnerReportRecipient? Recipient { get; set; }
/// <summary>Number / email address at send time — kept even if the recipient is edited later.</summary>
public string Destination { get; set; } = string.Empty;
public string RecipientName { get; set; } = string.Empty;
public OwnerReportDispatchStatus Status { get; set; }
public int Attempts { get; set; }
public string? Error { get; set; }
/// <summary>Test sends ("Send test now") — never block or count as the day's automatic send.</summary>
public bool IsTest { get; set; }
/// <summary>For tests: a unique token so repeated tests don't collide on the unique index.</summary>
public string DedupeKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public DateTime? NextAttemptAt { get; set; }
}
@@ -51,6 +51,31 @@ public class ProductConfig
/// </summary> /// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; } public bool CustomWarrantyPeriodsEnabled { get; set; }
/// <summary>
/// Shows the stock-nature choice (Stocked / Non-stocked / Service) on item screens. Off:
/// new items are Stocked. Enforced on item writes - an item keeps the nature it already
/// has, only a change <em>to</em> a non-Stocked nature is refused.
/// </summary>
public bool StockNatureEnabled { get; set; } = true;
/// <summary>
/// Shows the tracking-mode choice on item screens. Off: new items are untracked (None).
/// Enforced on item writes like <see cref="StockNatureEnabled"/> - existing modes are kept.
/// </summary>
public bool TrackingModeEnabled { get; set; } = true;
/// <summary>
/// Shows the low-stock alert (reorder point / qty) section on item create and the item page.
/// Advisory: it gates the item screens; reorder rules already saved keep working.
/// </summary>
public bool LowStockAlertsEnabled { get; set; } = true;
/// <summary>
/// Lets the low-stock alert be pinned to one warehouse. Off: the alert watches every
/// warehouse. Depends on <see cref="LowStockAlertsEnabled"/>; advisory like it.
/// </summary>
public bool AlertWarehouseEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { get; set; }
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).</summary>
public enum OwnerReportChannel
{
Sms,
Email,
}
/// <summary>State of one owner-report send.</summary>
public enum OwnerReportDispatchStatus
{
Pending,
Sent,
Failed,
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).</summary>
/// <param name="SendAtLocalTime">"HH:mm" in <paramref name="TimeZone"/>.</param>
public sealed record OwnerReportConfigDto(
bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
IReadOnlyList<OwnerReportRecipientDto> Recipients, string TimeZone, DateTime? UpdatedAt, int? UpdatedBy);
public sealed record OwnerReportRecipientDto(int RecipientId, string Name, string? Mobile, string? Email, bool IsActive);
public sealed class UpdateOwnerReportConfigRequest
{
[Required] public bool? Enabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? IncludeBranchBreakdown { get; set; }
/// <summary>"HH:mm", 24-hour.</summary>
[Required, RegularExpression(@"^([01]\d|2[0-3]):[0-5]\d$", ErrorMessage = "Send time must be HH:mm (24-hour).")]
public string SendAtLocalTime { get; set; } = "21:00";
/// <summary>The full list — recipients not in it are removed. At most 5.</summary>
[Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
public List<OwnerReportRecipientInput> Recipients { get; set; } = new();
}
public sealed class OwnerReportRecipientInput
{
/// <summary>Existing recipient to update; null adds a new one.</summary>
public int? RecipientId { get; set; }
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
[StringLength(20)] public string? Mobile { get; set; }
[StringLength(200), EmailAddress] public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>The report as it would be sent for <paramref name="Date"/>.</summary>
public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
public sealed class OwnerReportTestRequest
{
/// <summary>Day to report; null = today in the report's time zone.</summary>
public DateOnly? Date { get; set; }
}
public sealed record OwnerReportSendResultDto(string Channel, string RecipientName, string Destination, bool Ok, string? Error);
public sealed record OwnerReportDispatchDto(
long DispatchId, DateOnly BusinessDate, string Channel, string RecipientName, string Destination,
string Status, int Attempts, string? Error, bool IsTest, DateTime CreatedAt, DateTime? SentAt);
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled, bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled, bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled, bool CustomWarrantyPeriodsEnabled,
bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
DateTime? UpdatedAt, int? UpdatedBy); DateTime? UpdatedAt, int? UpdatedBy);
/// <summary> /// <summary>
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? CustomVariantsEnabled { get; set; } [Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; } [Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; } [Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
[Required] public bool? StockNatureEnabled { get; set; }
[Required] public bool? TrackingModeEnabled { get; set; }
[Required] public bool? LowStockAlertsEnabled { get; set; }
[Required] public bool? AlertWarehouseEnabled { get; set; }
} }
+8 -2
View File
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
decimal UnitPrice, bool IncludeInBundle, int SortOrder); decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto( public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines); EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto( public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt); EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto( public sealed record BundleSalePostingIssueDto(
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
{ {
[Required] public string TemplateName { get; set; } = string.Empty; [Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>
/// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
/// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
/// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
/// </summary>
[Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
} }
@@ -11,11 +11,11 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`: /// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits.</summary> /// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.</summary>
public static class PhoneRules public static class PhoneRules
{ {
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$"; public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$";
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567)."; public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567), or 94 followed by 9 digits (94771234567 or +94771234567).";
} }
public sealed class CreateWarehouseRequest public sealed class CreateWarehouseRequest
@@ -154,6 +154,67 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct); => CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- Notify -------------------------------------------------------------
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> NotifyAsync("sms", new { to, text }, ct);
/// <summary>
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
/// </summary>
private async Task<IReadOnlyList<AuthHexNotifyResult>> NotifyAsync(string channel, object body, CancellationToken ct)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
foreach (var (key, value) in AdminHeaders())
httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
HttpResponseMessage httpResponse;
try
{
httpResponse = await _http.SendAsync(httpRequest, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
}
NotifyResponse? payload = null;
try
{
payload = await httpResponse.Content.ReadFromJsonAsync<NotifyResponse>(JsonOptions, ct);
}
catch (JsonException)
{
// Fall through — reported below with the HTTP status.
}
if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
{
var message = payload?.Message
?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
? "The authentication service does not support sending messages yet (no /api/notify)."
: $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
}
return payload.Results;
}
private sealed class NotifyResponse
{
public List<AuthHexNotifyResult>? Results { get; set; }
public string? Message { get; set; }
}
// ---- Transport -------------------------------------------------------- // ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,4 +57,12 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct); Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct); Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
// SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
} }
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
@@ -0,0 +1,55 @@
using ERPCore.Infra.Auth.AuthHex;
namespace ERPCore.Infra.Messaging;
/// <summary>Per-recipient outcome of a send.</summary>
public sealed record SendResult(string To, bool Ok, string? Error);
/// <summary>
/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
/// Implementations report per-recipient failures in the result and only throw when the whole
/// call failed (e.g. AuthHex unreachable).
/// </summary>
public interface IMessageSender
{
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
public sealed class AuthHexMessageSender : IMessageSender
{
private readonly IAuthHexClient _authHex;
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
public async Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
}
/// <summary>
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
/// development and tests, so nothing reaches real phones or inboxes.
/// </summary>
public sealed class LogOnlyMessageSender : IMessageSender
{
private readonly ILogger<LogOnlyMessageSender> _logger;
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
}
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
builder.HasIndex(x => x.TemplateCode).IsUnique(); builder.HasIndex(x => x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200); builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000); builder.Property(x => x.Description).HasMaxLength(1000);
builder.Property(x => x.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
builder.Property(x => x.Status) builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active); .HasDefaultValue(EntityStatus.Active);
@@ -0,0 +1,75 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
{
public void Configure(EntityTypeBuilder<OwnerReportConfig> builder)
{
builder.ToTable("owner_report_config", t =>
t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.SendAtLocalTime).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration<OwnerReportRecipient>
{
public void Configure(EntityTypeBuilder<OwnerReportRecipient> builder)
{
builder.ToTable("owner_report_recipients");
builder.HasKey(r => r.RecipientId);
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.Property(r => r.Mobile).HasMaxLength(20);
builder.Property(r => r.Email).HasMaxLength(200);
builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
}
}
public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration<OwnerReportDispatch>
{
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
{
builder.ToTable("owner_report_dispatches");
builder.HasKey(d => d.DispatchId);
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Status).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
builder.Property(d => d.Error).HasMaxLength(1000);
builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
// The "send once" guard: one automatic send per day, channel and recipient. Test sends get
// a unique DedupeKey so any number of them can coexist.
builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
builder.HasIndex(d => d.CreatedAt);
// Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
builder.HasOne(d => d.Recipient)
.WithMany()
.HasForeignKey(d => d.RecipientId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -32,6 +32,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false); builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.StockNatureEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.TrackingModeEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.LowStockAlertsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.AlertWarehouseEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag). // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion(); builder.Property(c => c.RowVersion).IsRowVersion();
@@ -64,6 +64,7 @@ public static class DataSeeder
dirty |= await SeedProcurementConfigAsync(db, ct); dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct); dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedOwnerReportConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct); if (dirty) await db.SaveChangesAsync(ct);
@@ -212,7 +213,11 @@ public static class DataSeeder
WarrantyPeriodsEnabled = true, WarrantyPeriodsEnabled = true,
CustomVariantsEnabled = false, CustomVariantsEnabled = false,
ItemImagesEnabled = false, ItemImagesEnabled = false,
CustomWarrantyPeriodsEnabled = false CustomWarrantyPeriodsEnabled = false,
StockNatureEnabled = true,
TrackingModeEnabled = true,
LowStockAlertsEnabled = true,
AlertWarehouseEnabled = true
}); });
return true; return true;
} }
@@ -309,6 +314,15 @@ public static class DataSeeder
}); });
return true; return true;
} }
// Off by default: the owner report only starts sending once someone adds recipients and switches it on.
private static async Task<bool> SeedOwnerReportConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.OwnerReportConfig.AnyAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)) return false;
db.OwnerReportConfig.Add(new OwnerReportConfig { ConfigId = OwnerReportConfig.SingletonId, Enabled = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct) private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{ {
@@ -55,6 +55,9 @@ public class ErpDbContext : DbContext
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>(); public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary> /// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>(); public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<OwnerReportConfig> OwnerReportConfig => Set<OwnerReportConfig>();
public DbSet<OwnerReportRecipient> OwnerReportRecipients => Set<OwnerReportRecipient>();
public DbSet<OwnerReportDispatch> OwnerReportDispatches => Set<OwnerReportDispatch>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>(); public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>(); public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
@@ -17,8 +17,8 @@ namespace ERPCore.Migrations
/// <remarks> /// <remarks>
/// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of /// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of
/// <c>DropCheckConstraint</c>: MariaDB spells the removal <c>DROP CONSTRAINT</c> (MySQL's /// <c>DropCheckConstraint</c>: MariaDB spells the removal <c>DROP CONSTRAINT</c> (MySQL's
/// <c>DROP CHECK</c> is not a safe assumption here), and <c>IF EXISTS</c> keeps this /// <c>DROP CHECK</c> is not a safe assumption here). It is a plain drop, not <c>IF EXISTS</c>:
/// re-runnable on a database where the broken constraint was already removed by hand. /// that form is MariaDB-only and a syntax error on MySQL 8, which broke fresh local databases.
/// </remarks> /// </remarks>
/// <inheritdoc /> /// <inheritdoc />
public partial class FixCheckConstraintQuotingForMySql : Migration public partial class FixCheckConstraintQuotingForMySql : Migration
@@ -26,13 +26,13 @@ namespace ERPCore.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;"); DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (`ConfigId` = 1);"); migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (`ConfigId` = 1);");
migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;"); DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (`ConfigId` = 1);"); migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (`ConfigId` = 1);");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;"); DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (`Months` BETWEEN 1 AND 600);"); migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (`Months` BETWEEN 1 AND 600);");
} }
@@ -40,14 +40,23 @@ namespace ERPCore.Migrations
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
// Restores the original (broken) expressions so the migration is a faithful inverse. // Restores the original (broken) expressions so the migration is a faithful inverse.
migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;"); DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (\"ConfigId\" = 1);"); migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (\"ConfigId\" = 1);");
migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;"); DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (\"ConfigId\" = 1);"); migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (\"ConfigId\" = 1);");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;"); DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (\"Months\" BETWEEN 1 AND 600);"); migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (\"Months\" BETWEEN 1 AND 600);");
} }
/// <summary>
/// Plain <c>DROP CONSTRAINT</c> — valid on MariaDB and on MySQL 8.0.19+. Deliberately not
/// <c>DROP CONSTRAINT IF EXISTS</c>, which is MariaDB-only and is a syntax error on MySQL 8,
/// breaking fresh local databases. The constraints always exist at this point: the earlier
/// migrations create them.
/// </summary>
private static void DropCheck(MigrationBuilder migrationBuilder, string table, string constraint)
=> migrationBuilder.Sql($"ALTER TABLE `{table}` DROP CONSTRAINT `{constraint}`;");
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddOwnerReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "owner_report_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
EmailEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
SendAtLocalTime = table.Column<TimeOnly>(type: "time(6)", nullable: false),
IncludeBranchBreakdown = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_config", x => x.ConfigId);
table.CheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_owner_report_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_recipients",
columns: table => new
{
RecipientId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Mobile = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
Email = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true, collation: "utf8mb4_unicode_ci"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_recipients", x => x.RecipientId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_dispatches",
columns: table => new
{
DispatchId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
Channel = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientId = table.Column<int>(type: "int", nullable: true),
Destination = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Status = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true, collation: "utf8mb4_unicode_ci"),
IsTest = table.Column<bool>(type: "tinyint(1)", nullable: false),
DedupeKey = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false, collation: "utf8mb4_unicode_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SentAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_dispatches", x => x.DispatchId);
table.ForeignKey(
name: "FK_owner_report_dispatches_owner_report_recipients_RecipientId",
column: x => x.RecipientId,
principalTable: "owner_report_recipients",
principalColumn: "RecipientId",
onDelete: ReferentialAction.SetNull);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_owner_report_config_UpdatedBy",
table: "owner_report_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_CreatedAt",
table: "owner_report_dispatches",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_RecipientId",
table: "owner_report_dispatches",
column: "RecipientId");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_Status_NextAttemptAt",
table: "owner_report_dispatches",
columns: new[] { "Status", "NextAttemptAt" });
migrationBuilder.CreateIndex(
name: "ux_owner_report_dispatch_once",
table: "owner_report_dispatches",
columns: new[] { "BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "owner_report_config");
migrationBuilder.DropTable(
name: "owner_report_dispatches");
migrationBuilder.DropTable(
name: "owner_report_recipients");
}
}
}
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBundleTemplatePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BundlePrice",
table: "bundle_sale_templates",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BundlePrice",
table: "bundle_sale_templates");
}
}
}
@@ -794,6 +794,12 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BundleSaleTemplateId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<decimal>("BundlePrice")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)")
.HasDefaultValue(0m);
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -3733,6 +3739,158 @@ namespace ERPCore.Migrations
b.ToTable("opening_stock_lines", (string)null); b.ToTable("opening_stock_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<bool>("EmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<bool>("IncludeBranchBreakdown")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<TimeOnly>("SendAtLocalTime")
.HasColumnType("time(6)");
b.Property<bool>("SmsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("owner_report_config", null, t =>
{
t.HasCheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.Property<long>("DispatchId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("DispatchId"));
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateOnly>("BusinessDate")
.HasColumnType("date");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DedupeKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Destination")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Error")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsTest")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<int?>("RecipientId")
.HasColumnType("int");
b.Property<string>("RecipientName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime?>("SentAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.HasKey("DispatchId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientId");
b.HasIndex("Status", "NextAttemptAt");
b.HasIndex("BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey")
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
b.ToTable("owner_report_dispatches", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportRecipient", b =>
{
b.Property<int>("RecipientId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RecipientId"));
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<string>("Mobile")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("RecipientId");
b.ToTable("owner_report_recipients", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.Property<int>("PayrollLineId") b.Property<int>("PayrollLineId")
@@ -4710,6 +4868,11 @@ namespace ERPCore.Migrations
b.Property<int>("ConfigId") b.Property<int>("ConfigId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("AlertWarehouseEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("BatchListsEnabled") b.Property<bool>("BatchListsEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@@ -4740,6 +4903,11 @@ namespace ERPCore.Migrations
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("LowStockAlertsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("int unsigned"); .HasColumnType("int unsigned");
@@ -4754,11 +4922,21 @@ namespace ERPCore.Migrations
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("StockNatureEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("SubcategoriesEnabled") b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("TrackingModeEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -9631,6 +9809,26 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); b.Navigation("Warehouse");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.HasOne("ERPCore.Domain.Entities.OwnerReportRecipient", "Recipient")
.WithMany()
.HasForeignKey("RecipientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Recipient");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
+19 -1
View File
@@ -50,9 +50,17 @@ if (string.IsNullOrWhiteSpace(connectionString))
"ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json " "ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json "
+ "or as the ConnectionStrings__DefaultConnection environment variable."); + "or as the ConnectionStrings__DefaultConnection environment variable.");
// Optional override for a different local server, e.g. "8.0.44-mysql" in user-secrets
// (Database:ServerVersion) — Pomelo otherwise emits MariaDB-only SQL (INSERT … RETURNING) that
// MySQL rejects. Unset everywhere else, so the MariaDB 10.11 default above still applies.
var serverVersionOverride = builder.Configuration["Database:ServerVersion"];
ServerVersion serverVersion = string.IsNullOrWhiteSpace(serverVersionOverride)
? new MariaDbServerVersion(new Version(10, 11, 15))
: ServerVersion.Parse(serverVersionOverride);
builder.Services.AddDbContext<ErpDbContext>(o => builder.Services.AddDbContext<ErpDbContext>(o =>
{ {
o.UseMySql(connectionString, new MariaDbServerVersion(new Version(10, 11, 15))); o.UseMySql(connectionString, serverVersion);
}); });
// ProblemDetails (RFC 7807) + domain-exception mapping // ProblemDetails (RFC 7807) + domain-exception mapping
@@ -126,6 +134,16 @@ builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>(); builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>(); builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>(); builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
// Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go through AuthHex, which
// holds the SMTP / SMS credentials; Messaging:Provider = "Log" writes them to the log instead.
builder.Services.AddScoped<ERPCore.Services.OwnerReport.OwnerReportContentBuilder>();
builder.Services.AddScoped<ERPCore.Services.OwnerReport.IOwnerReportService, ERPCore.Services.OwnerReport.OwnerReportService>();
if (string.Equals(builder.Configuration["Messaging:Provider"], "Log", StringComparison.OrdinalIgnoreCase))
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.LogOnlyMessageSender>();
else
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.AuthHexMessageSender>();
builder.Services.AddHostedService<ERPCore.Services.OwnerReport.OwnerReportJob>();
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>(); builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>(); builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>(); builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
+37 -2
View File
@@ -67,7 +67,7 @@ public sealed class BundleSaleService : IBundleSaleService
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto( return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total); x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
} }
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default) public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
@@ -78,6 +78,7 @@ public sealed class BundleSaleService : IBundleSaleService
template.TemplateCode, template.TemplateCode,
template.TemplateName, template.TemplateName,
template.Description, template.Description,
template.BundlePrice,
template.Status, template.Status,
template.CreatedAt, template.CreatedAt,
template.UpdatedAt, template.UpdatedAt,
@@ -92,10 +93,18 @@ public sealed class BundleSaleService : IBundleSaleService
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct), TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
TemplateName = request.TemplateName, TemplateName = request.TemplateName,
Description = request.Description, Description = request.Description,
BundlePrice = request.BundlePrice ?? 0m,
Status = EntityStatus.Active, Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
if (request.BundlePrice is { } bundlePrice)
{
if (!request.Lines.Any(l => l.IncludeInBundle))
throw new DomainException(ErrorCodes.Validation, "A bundle needs at least one included line to carry its price.", 422);
AllocateBundlePrice(request.Lines, bundlePrice);
}
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct)) if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
@@ -118,12 +127,38 @@ public sealed class BundleSaleService : IBundleSaleService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return new BundleSaleTemplateDto( return new BundleSaleTemplateDto(
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundlePrice,
template.Status, template.CreatedAt, template.UpdatedAt, template.Status, template.CreatedAt, template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
} }
/// <summary>
/// Splits the bundle price over the included lines' unit prices, in proportion to each line's
/// UnitPrice × Qty as sent (the items' normal value), or to Qty when no line carries a price.
/// Line totals are rounded to 2 dp with the last included line taking the rounding remainder, so the
/// totals add up to the bundle price; excluded lines are priced 0.
/// </summary>
private static void AllocateBundlePrice(List<CreateBundleSaleTemplateLineRequest> lines, decimal bundlePrice)
{
var included = lines.Where(l => l.IncludeInBundle).ToList();
var weights = included.Select(l => l.UnitPrice * l.Qty).ToList();
if (weights.Sum() <= 0) weights = included.Select(l => l.Qty).ToList();
var totalWeight = weights.Sum();
var allocated = 0m;
for (var i = 0; i < included.Count; i++)
{
var lineTotal = i == included.Count - 1
? bundlePrice - allocated
: Math.Round(bundlePrice * weights[i] / totalWeight, 2, MidpointRounding.AwayFromZero);
allocated += lineTotal;
included[i].UnitPrice = Math.Round(lineTotal / included[i].Qty, 4, MidpointRounding.AwayFromZero);
}
foreach (var line in lines.Where(l => !l.IncludeInBundle)) line.UnitPrice = 0m;
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{ {
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines); IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
@@ -7,9 +7,9 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default);
/// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary> /// <summary>Count and amount totals over every invoice matching the same filters as <see cref="ListAsync"/>.</summary>
Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
+23 -7
View File
@@ -321,7 +321,7 @@ public sealed class ItemService : IItemService
await ValidateReferencesAsync( await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId, request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct); request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config, item.TrackingMode); ValidateFeatureConfiguration(request, config, item.TrackingMode, item.StockNature);
// The base UOM is the sole meaning of every quantity recorded against this item — // The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any // stock layers, ledger rows and document lines are all plain counts of it. Once any
@@ -520,22 +520,38 @@ public sealed class ItemService : IItemService
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config) private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration( => ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config); request.TrackingMode, request.StockNature, request.Warranty, config);
/// <summary> /// <summary>
/// An item keeps the tracking mode it already has even after that feature is switched off — /// An item keeps the tracking mode and stock nature it already has even after that feature is
/// otherwise it could not be edited at all; only a change <em>to</em> a disabled mode is refused. /// switched off — otherwise it could not be edited at all; only a change <em>to</em> a disabled
/// value is refused.
/// </summary> /// </summary>
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode) private static void ValidateFeatureConfiguration(
UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode, StockNature currentStockNature)
=> ValidateFeatureConfiguration( => ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config, currentTrackingMode); request.TrackingMode, request.StockNature, request.Warranty, config, currentTrackingMode, currentStockNature);
private static void ValidateFeatureConfiguration( private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice, decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null) TrackingMode trackingMode, StockNature stockNature, Warranty warranty, ProductConfigDto config,
TrackingMode? currentTrackingMode = null, StockNature? currentStockNature = null)
{ {
var trackingUnchanged = trackingMode == currentTrackingMode; var trackingUnchanged = trackingMode == currentTrackingMode;
if (!config.StockNatureEnabled && stockNature != StockNature.Stocked && stockNature != currentStockNature)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Stock nature is disabled in the product configuration; stockNature must be Stocked.",
422);
if (!config.TrackingModeEnabled && trackingMode != TrackingMode.None && !trackingUnchanged)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Tracking mode is disabled in the product configuration; trackingMode must be None.",
422);
if (!config.SellingPricesEnabled && if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null)) (sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException( throw new DomainException(
@@ -0,0 +1,193 @@
using System.Globalization;
using System.Net;
using System.Text;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Reports;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
/// <summary>The day-end report rendered for each channel.</summary>
public sealed record OwnerReportContent(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml, string EmailText);
/// <summary>
/// Turns one day's <see cref="DayEndReportDto"/> into the owner SMS (short, ASCII so it stays a
/// cheap GSM message) and email (full detail) — docs/DAY_END_OWNER_REPORT_PLAN.md §1 / §3.3.
/// Figures come straight from <see cref="IDayEndReportService"/>, so they always match the
/// Day-End Report screen for the same day.
/// </summary>
public sealed class OwnerReportContentBuilder
{
public const string AppName = "Hexa ERP";
private const int TopSales = 10;
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private readonly IDayEndReportService _report;
private readonly IRepository<Branch> _branches;
private readonly IRepository<BranchConfig> _branchConfig;
private readonly IConfiguration _configuration;
public OwnerReportContentBuilder(
IDayEndReportService report, IRepository<Branch> branches, IRepository<BranchConfig> branchConfig, IConfiguration configuration)
{
_report = report;
_branches = branches;
_branchConfig = branchConfig;
_configuration = configuration;
}
public async Task<OwnerReportContent> BuildAsync(DateOnly date, bool includeBranchBreakdown, CancellationToken ct)
{
// branchId null = every branch. Inside the background job there is no signed-in user, so
// ICurrentBranchContext is inactive and this really is the company-wide total.
var day = await _report.GetDayAsync(date, null, ct);
var branchRows = new List<(string Name, DayEndReportDayDto Summary)>();
var branchingOn = await _branchConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => c.BranchingEnabled).FirstOrDefaultAsync(ct);
if (includeBranchBreakdown && branchingOn)
{
var branches = await _branches.Query().AsNoTracking()
.Where(b => b.Status == EntityStatus.Active).OrderBy(b => b.Name).Select(b => new { b.BranchId, b.Name }).ToListAsync(ct);
if (branches.Count > 1)
foreach (var b in branches)
branchRows.Add((b.Name, (await _report.GetDayAsync(date, b.BranchId, ct)).Summary));
}
var s = day.Summary;
var subject = $"Day-end report - {date.ToString("dd MMM yyyy", Inv)} - Sales LKR {Amt(s.TotalSales)}";
return new OwnerReportContent(date, BuildSms(date, s), subject, BuildHtml(date, day, branchRows), BuildText(date, day));
}
// ---- SMS ------------------------------------------------------------------
private static string BuildSms(DateOnly date, DayEndReportDayDto s)
{
var sb = new StringBuilder();
sb.Append(AppName).Append(" - Day end ").Append(date.ToString("dd/MM/yyyy", Inv)).Append('\n');
sb.Append("Sales: LKR ").Append(Amt(s.TotalSales)).Append(" (").Append(s.SalesCount).Append(")\n");
sb.Append("Paid: ").Append(Amt(s.PaidAmount)).Append(" | Due: ").Append(Amt(s.DueAmount)).Append('\n');
sb.Append("Cash: ").Append(Amt(s.PaidByCash)).Append(" | Card: ").Append(Amt(s.PaidByCard)).Append('\n');
sb.Append("Expenses: ").Append(Amt(s.ExpenseTotal)).Append('\n');
sb.Append(ProfitLabel(s.NetProfit)).Append(": ").Append(Amt(Math.Abs(s.NetProfit))).Append('\n');
sb.Append("Cash in hand: ").Append(Amt(s.ClosingCashInHand));
return sb.ToString();
}
// ---- Email ------------------------------------------------------------------
private string BuildHtml(DateOnly date, DayEndReportDto day, List<(string Name, DayEndReportDayDto Summary)> branches)
{
var s = day.Summary;
var h = new StringBuilder();
h.Append("<div style=\"font-family:Arial,Helvetica,sans-serif;color:#1f2937;max-width:680px;margin:0 auto\">");
h.Append($"<h2 style=\"margin:0 0 4px\">Day-end report</h2>");
h.Append($"<p style=\"margin:0 0 16px;color:#6b7280\">{E(date.ToString("dddd, dd MMMM yyyy", Inv))} &middot; all branches</p>");
Section(h, "Summary", new (string, string)[]
{
("Total sales", $"LKR {Amt(s.TotalSales)} ({s.SalesCount} sales)"),
("Paid", Amt(s.PaidAmount)),
("Due", Amt(s.DueAmount)),
("Paid by cash", Amt(s.PaidByCash)),
("Paid by card", Amt(s.PaidByCard)),
("Paid by other (transfer / cheque)", Amt(s.PaidByOther)),
("Expenses", Amt(s.ExpenseTotal)),
("Income (sales - expenses)", Signed(s.Income)),
("Cost of goods sold", Amt(s.CostOfGoodsSold)),
("Gross profit", Signed(s.GrossProfit)),
(ProfitLabel(s.NetProfit), Amt(Math.Abs(s.NetProfit))),
}, emphasiseLast: true, lastIsLoss: s.NetProfit < 0);
Section(h, "Cash", new (string, string)[]
{
("Opening cash in hand", Amt(s.OpeningCashInHand)),
("Net cash today", Signed(s.NetCash)),
("Deposited to bank", Amt(s.CashDeposited)),
("Closing cash in hand", Amt(s.ClosingCashInHand)),
("Collected on earlier invoices", Amt(s.EarlierInvoiceCollections)),
}, emphasiseLast: false, lastIsLoss: false);
if (branches.Count > 0)
Table(h, "By branch", new[] { "Branch", "Sales", "Paid", "Expenses", "Net profit" }, firstNumericColumn: 1,
branches.Select(x => new[] { x.Name, Amt(x.Summary.TotalSales), Amt(x.Summary.PaidAmount), Amt(x.Summary.ExpenseTotal), Signed(x.Summary.NetProfit) }));
var top = day.Sales.OrderByDescending(x => x.Total).Take(TopSales).ToList();
if (top.Count > 0)
Table(h, day.Sales.Count > TopSales ? $"Top {TopSales} sales (of {day.Sales.Count})" : "Sales",
new[] { "No.", "Customer", "Paid by", "Total" }, firstNumericColumn: 3,
top.Select(x => new[] { x.DocNo, x.CustomerName, x.PaidBy, Amt(x.Total) }));
if (day.Expenses.Count > 0)
Table(h, "Expenses", new[] { "No.", "Category / title", "Amount" }, firstNumericColumn: 2,
day.Expenses.Select(x => new[] { x.DocNo, $"{x.CategoryName} / {x.TitleName}", Amt(x.Amount) }));
var link = ReportLink(date);
if (link is not null)
h.Append($"<p style=\"margin:20px 0 0\"><a href=\"{E(link)}\" style=\"color:#e11d48\">Open the Day-End Report</a></p>");
h.Append($"<p style=\"margin:20px 0 0;font-size:12px;color:#9ca3af\">Sent automatically by {AppName}.</p>");
h.Append("</div>");
return h.ToString();
}
private string BuildText(DateOnly date, DayEndReportDto day)
{
var text = new StringBuilder(BuildSms(date, day.Summary));
var link = ReportLink(date);
if (link is not null) text.Append("\n\nOpen the Day-End Report: ").Append(link);
text.Append("\n\nSent automatically by ").Append(AppName).Append('.');
return text.ToString();
}
/// <summary>Link back to the report screen, when OwnerReport:AppUrl is configured (e.g. https://erp.example.com).</summary>
private string? ReportLink(DateOnly date)
{
var baseUrl = _configuration["OwnerReport:AppUrl"]?.TrimEnd('/');
return string.IsNullOrWhiteSpace(baseUrl) ? null : $"{baseUrl}/dashboard/sales/day-end-report?date={date:yyyy-MM-dd}";
}
private static void Section(StringBuilder h, string title, (string Label, string Value)[] rows, bool emphasiseLast, bool lastIsLoss)
{
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:14px\">");
for (var i = 0; i < rows.Length; i++)
{
var last = i == rows.Length - 1 && emphasiseLast;
var colour = last ? (lastIsLoss ? "#dc2626" : "#16a34a") : "#1f2937";
var weight = last ? "bold" : "normal";
h.Append($"<tr><td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;color:#4b5563\">{E(rows[i].Label)}</td>");
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:{weight};color:{colour}\">{E(rows[i].Value)}</td></tr>");
}
h.Append("</table>");
}
private static string SectionTitle(string title)
=> $"<h3 style=\"margin:20px 0 6px;font-size:15px;color:#111827\">{E(title)}</h3>";
/// <summary>A titled table; columns from <paramref name="firstNumericColumn"/> on are right-aligned amounts. Cells are HTML-encoded here.</summary>
private static void Table(StringBuilder h, string title, string[] headers, int firstNumericColumn, IEnumerable<string[]> rows)
{
string Align(int i) => i >= firstNumericColumn ? "right" : "left";
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:13px\"><tr>");
for (var i = 0; i < headers.Length; i++)
h.Append($"<th style=\"padding:6px 8px;background:#f9fafb;border-bottom:1px solid #e5e7eb;text-align:{Align(i)}\">{E(headers[i])}</th>");
h.Append("</tr>");
foreach (var cells in rows)
{
h.Append("<tr>");
for (var i = 0; i < cells.Length; i++)
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:{Align(i)}\">{E(cells[i])}</td>");
h.Append("</tr>");
}
h.Append("</table>");
}
private static string ProfitLabel(decimal net) => net < 0 ? "Net loss" : "Net profit";
private static string Amt(decimal v) => v.ToString("#,##0.00", Inv);
private static string Signed(decimal v) => (v < 0 ? "-" : "") + Amt(Math.Abs(v));
private static string E(string v) => WebUtility.HtmlEncode(v);
}
@@ -0,0 +1,49 @@
namespace ERPCore.Services.OwnerReport;
/// <summary>
/// Sends the day-end owner report once its configured time has passed
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.4). Wakes every minute; all the "is it time / already
/// sent / retry" logic lives in <see cref="IOwnerReportService.RunScheduledAsync"/>.
/// Never lets an exception escape — a failed tick is logged and the next one tries again.
/// </summary>
public sealed class OwnerReportJob : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<OwnerReportJob> _logger;
public OwnerReportJob(IServiceScopeFactory scopes, ILogger<OwnerReportJob> logger)
{
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Let startup (migrations, seeding) finish before the first tick.
try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); }
catch (OperationCanceledException) { return; }
using var timer = new PeriodicTimer(Interval);
do
{
try
{
// Fresh scope per tick: its own DbContext, and no HTTP user (so the day-end
// figures are company-wide, not limited to someone's branch).
using var scope = _scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<IOwnerReportService>().RunScheduledAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "Owner report tick failed; will retry on the next tick.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
}
@@ -0,0 +1,360 @@
using System.Text.RegularExpressions;
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Warehouses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Messaging;
using ERPCore.Infra.Persistence;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
public interface IOwnerReportService
{
Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default);
Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default);
Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default);
/// <summary>One scheduler tick (OwnerReportJob): sends today's report once its time has come.</summary>
Task RunScheduledAsync(CancellationToken ct = default);
}
/// <summary>
/// Day-end owner report: settings, preview, test sends, the send log and the scheduled daily
/// send (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through <see cref="IMessageSender"/>
/// (AuthHex), so this service never handles SMTP or SMS credentials.
/// </summary>
public sealed class OwnerReportService : IOwnerReportService
{
private const int MaxRecipients = 5;
private const int MaxAttempts = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(5);
private static readonly Regex SriLankanPhone = new(PhoneRules.Pattern, RegexOptions.Compiled);
private readonly ErpDbContext _db;
private readonly OwnerReportContentBuilder _content;
private readonly IMessageSender _sender;
private readonly BusinessDayClock _clock;
private readonly ICurrentUser _currentUser;
private readonly ILogger<OwnerReportService> _logger;
public OwnerReportService(
ErpDbContext db, OwnerReportContentBuilder content, IMessageSender sender, BusinessDayClock clock,
ICurrentUser currentUser, ILogger<OwnerReportService> logger)
{
_db = db;
_content = content;
_sender = sender;
_clock = clock;
_currentUser = currentUser;
_logger = logger;
}
// ---- Settings ------------------------------------------------------------------
public async Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var recipients = await _db.OwnerReportRecipients.AsNoTracking().OrderBy(r => r.RecipientId).ToListAsync(ct);
return new ETagged<OwnerReportConfigDto>(Map(config, recipients), config.RowVersion);
}
public async Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
// Validate everything before touching the rows.
if (request.Recipients.Count > MaxRecipients)
throw Invalid($"At most {MaxRecipients} recipients.");
var cleaned = request.Recipients.Select(CleanRecipient).ToList();
var config = await LoadConfigAsync(asNoTracking: false, ct);
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
config.Enabled = request.Enabled!.Value;
config.SmsEnabled = request.SmsEnabled!.Value;
config.EmailEnabled = request.EmailEnabled!.Value;
config.IncludeBranchBreakdown = request.IncludeBranchBreakdown!.Value;
config.SendAtLocalTime = TimeOnly.ParseExact(request.SendAtLocalTime, "HH:mm");
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
if (config.Enabled && !cleaned.Any(r => r.IsActive))
throw Invalid("Add at least one active recipient before switching the report on.");
if (config.Enabled && !config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both before switching the report on.");
// Replace the recipient list: update kept rows, add new ones, remove the rest.
var existing = await _db.OwnerReportRecipients.ToListAsync(ct);
var keptIds = cleaned.Where(r => r.RecipientId is not null).Select(r => r.RecipientId!.Value).ToHashSet();
_db.OwnerReportRecipients.RemoveRange(existing.Where(e => !keptIds.Contains(e.RecipientId)));
foreach (var input in cleaned)
{
var row = input.RecipientId is int id ? existing.FirstOrDefault(e => e.RecipientId == id) : null;
if (input.RecipientId is not null && row is null)
throw Invalid($"Recipient {input.RecipientId} no longer exists — reload and try again.");
row ??= _db.OwnerReportRecipients.Add(new OwnerReportRecipient()).Entity;
row.Name = input.Name;
row.Mobile = input.Mobile;
row.Email = input.Email;
row.IsActive = input.IsActive;
}
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
}
return await GetAsync(ct);
}
// ---- Preview / test ---------------------------------------------------------------
public async Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var content = await _content.BuildAsync(date ?? _clock.Today, config.IncludeBranchBreakdown, ct);
return new OwnerReportPreviewDto(content.Date, content.SmsText, content.EmailSubject, content.EmailHtml);
}
public async Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
if (!config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both to send a test.");
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
if (recipients.Count == 0)
throw Invalid("Add at least one active recipient and save before sending a test.");
var day = date ?? _clock.Today;
var content = await _content.BuildAsync(day, config.IncludeBranchBreakdown, ct);
// Tests are logged like real sends but flagged, with a unique key so they never collide
// with (or stand in for) the day's automatic send.
var batch = Guid.NewGuid().ToString("N");
var rows = Targets(config, recipients)
.Select(t => NewDispatch(day, t.Channel, t.Recipient, t.Destination, isTest: true, dedupeKey: batch))
.ToList();
_db.OwnerReportDispatches.AddRange(rows);
await _db.SaveChangesAsync(ct);
await SendAsync(rows, content, ct);
return rows.Select(r => new OwnerReportSendResultDto(
r.Channel.ToString(), r.RecipientName, r.Destination, r.Status == OwnerReportDispatchStatus.Sent, r.Error)).ToList();
}
public async Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default)
{
var q = _db.OwnerReportDispatches.AsNoTracking();
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(d => d.DispatchId).Skip(query.Skip).Take(query.PageSize)
.Select(d => new OwnerReportDispatchDto(
d.DispatchId, d.BusinessDate, d.Channel.ToString(), d.RecipientName, d.Destination,
d.Status.ToString(), d.Attempts, d.Error, d.IsTest, d.CreatedAt, d.SentAt))
.ToListAsync(ct);
return PagedResponse<OwnerReportDispatchDto>.Create(rows, query.Page, query.PageSize, total);
}
// ---- Scheduled send ----------------------------------------------------------------
public async Task RunScheduledAsync(CancellationToken ct = default)
{
var config = await _db.OwnerReportConfig.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct);
if (config is null || !config.Enabled || (!config.SmsEnabled && !config.EmailEnabled)) return;
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clock.Zone);
if (TimeOnly.FromDateTime(nowLocal) < config.SendAtLocalTime) return;
// Only today: a day missed while the server was down is sent once it is back, the same
// day — older days are never sent automatically (plan R8).
var today = DateOnly.FromDateTime(nowLocal);
await EnsureTodaysDispatchesAsync(config, today, ct);
var now = DateTime.UtcNow;
var due = await _db.OwnerReportDispatches
.Where(d => d.BusinessDate == today && !d.IsTest && d.Attempts < MaxAttempts
&& (d.Status == OwnerReportDispatchStatus.Pending || d.Status == OwnerReportDispatchStatus.Failed)
&& (d.NextAttemptAt == null || d.NextAttemptAt <= now))
.ToListAsync(ct);
if (due.Count == 0) return;
// Claim each row before sending (attempt counter as an optimistic lock), so a second
// server instance running the same tick skips it instead of sending it again.
var claimed = new List<OwnerReportDispatch>();
foreach (var d in due)
{
var updated = await _db.OwnerReportDispatches
.Where(x => x.DispatchId == d.DispatchId && x.Attempts == d.Attempts)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.NextAttemptAt, now.Add(RetryDelay)), ct);
if (updated == 1)
{
d.Attempts += 1;
d.NextAttemptAt = now.Add(RetryDelay);
claimed.Add(d);
}
}
if (claimed.Count == 0) return;
var content = await _content.BuildAsync(today, config.IncludeBranchBreakdown, ct);
await SendAsync(claimed, content, ct);
_logger.LogInformation("Owner report for {Date}: {Sent} sent, {Failed} failed.",
today, claimed.Count(d => d.Status == OwnerReportDispatchStatus.Sent), claimed.Count(d => d.Status == OwnerReportDispatchStatus.Failed));
}
/// <summary>Creates today's Pending row for each active recipient × enabled channel that doesn't have one yet.</summary>
private async Task EnsureTodaysDispatchesAsync(OwnerReportConfig config, DateOnly today, CancellationToken ct)
{
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
var existing = (await _db.OwnerReportDispatches.AsNoTracking()
.Where(d => d.BusinessDate == today && !d.IsTest)
.Select(d => new { d.Channel, d.RecipientId })
.ToListAsync(ct))
.Select(d => (d.Channel, d.RecipientId))
.ToHashSet();
var missing = Targets(config, recipients)
.Where(t => !existing.Contains((t.Channel, (int?)t.Recipient.RecipientId)))
.Select(t => NewDispatch(today, t.Channel, t.Recipient, t.Destination, isTest: false, dedupeKey: string.Empty))
.ToList();
if (missing.Count == 0) return;
_db.OwnerReportDispatches.AddRange(missing);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Another instance created them first (unique index) — theirs stand.
_db.ChangeTracker.Clear();
}
}
// ---- Sending -----------------------------------------------------------------------
/// <summary>Sends <paramref name="rows"/> (grouped per channel) and records each outcome.</summary>
private async Task SendAsync(List<OwnerReportDispatch> rows, OwnerReportContent content, CancellationToken ct)
{
foreach (var group in rows.GroupBy(r => r.Channel))
{
var destinations = group.Select(r => r.Destination).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
IReadOnlyList<SendResult> results;
try
{
results = group.Key == OwnerReportChannel.Email
? await _sender.SendEmailAsync(destinations, content.EmailSubject, content.EmailHtml, content.EmailText, ct)
: await _sender.SendSmsAsync(destinations, content.SmsText, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// The whole call failed (e.g. AuthHex unreachable) — every row of this channel failed.
_logger.LogWarning(ex, "Owner report {Channel} send failed.", group.Key);
results = destinations.Select(d => new SendResult(d, false, ex.Message)).ToList();
}
foreach (var row in group)
{
var result = results.FirstOrDefault(r => string.Equals(r.To, row.Destination, StringComparison.OrdinalIgnoreCase));
if (result is { Ok: true })
{
row.Status = OwnerReportDispatchStatus.Sent;
row.SentAt = DateTime.UtcNow;
row.Error = null;
row.NextAttemptAt = null;
}
else
{
row.Status = OwnerReportDispatchStatus.Failed;
row.Error = Truncate(result?.Error ?? "No result returned for this recipient.", 1000);
}
}
}
// Rows may be tracked (test) or detached copies (scheduled) — attach updates either way.
foreach (var row in rows)
{
var entry = _db.Entry(row);
if (entry.State == EntityState.Detached) _db.OwnerReportDispatches.Attach(row);
entry = _db.Entry(row);
entry.Property(r => r.Status).IsModified = true;
entry.Property(r => r.SentAt).IsModified = true;
entry.Property(r => r.Error).IsModified = true;
entry.Property(r => r.NextAttemptAt).IsModified = true;
}
await _db.SaveChangesAsync(ct);
}
private static IEnumerable<(OwnerReportChannel Channel, OwnerReportRecipient Recipient, string Destination)> Targets(
OwnerReportConfig config, IEnumerable<OwnerReportRecipient> recipients)
{
foreach (var r in recipients)
{
if (config.SmsEnabled && !string.IsNullOrWhiteSpace(r.Mobile)) yield return (OwnerReportChannel.Sms, r, r.Mobile!);
if (config.EmailEnabled && !string.IsNullOrWhiteSpace(r.Email)) yield return (OwnerReportChannel.Email, r, r.Email!);
}
}
private static OwnerReportDispatch NewDispatch(
DateOnly day, OwnerReportChannel channel, OwnerReportRecipient recipient, string destination, bool isTest, string dedupeKey)
=> new()
{
BusinessDate = day,
Channel = channel,
RecipientId = recipient.RecipientId,
RecipientName = recipient.Name,
Destination = destination,
Status = OwnerReportDispatchStatus.Pending,
Attempts = isTest ? 1 : 0,
IsTest = isTest,
DedupeKey = dedupeKey,
CreatedAt = DateTime.UtcNow,
};
// ---- Helpers ------------------------------------------------------------------------
private async Task<OwnerReportConfig> LoadConfigAsync(bool asNoTracking, CancellationToken ct)
{
var q = asNoTracking ? _db.OwnerReportConfig.AsNoTracking() : _db.OwnerReportConfig;
return await q.FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)
?? throw new NotFoundException("Owner report settings have not been seeded.");
}
private OwnerReportConfigDto Map(OwnerReportConfig c, IEnumerable<OwnerReportRecipient> recipients) => new(
c.Enabled, c.SmsEnabled, c.EmailEnabled, c.SendAtLocalTime.ToString("HH:mm"), c.IncludeBranchBreakdown,
recipients.Select(r => new OwnerReportRecipientDto(r.RecipientId, r.Name, r.Mobile, r.Email, r.IsActive)).ToList(),
_clock.Zone.Id, c.UpdatedAt, c.UpdatedBy);
/// <summary>Trims, validates and normalises one recipient (mobile → +94XXXXXXXXX, whatever local/94/+94 form was typed).</summary>
private static OwnerReportRecipientInput CleanRecipient(OwnerReportRecipientInput input)
{
var name = input.Name.Trim();
var mobile = string.IsNullOrWhiteSpace(input.Mobile) ? null : input.Mobile.Trim();
var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
if (name.Length == 0) throw Invalid("Every recipient needs a name.");
if (mobile is null && email is null) throw Invalid($"{name}: add a mobile number, an email or both.");
if (mobile is not null)
{
if (!SriLankanPhone.IsMatch(mobile)) throw Invalid($"{name}: {PhoneRules.Message}");
var digits = Regex.Replace(mobile, @"\D", "");
mobile = "+94" + (digits.StartsWith("94") ? digits[2..] : digits[1..]);
}
return new OwnerReportRecipientInput { RecipientId = input.RecipientId, Name = name, Mobile = mobile, Email = email?.ToLowerInvariant(), IsActive = input.IsActive };
}
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max];
}
@@ -62,6 +62,12 @@ public sealed class ProductConfigService : IProductConfigService
// Custom warranty periods depend on warranty periods - switching the parent off // Custom warranty periods depend on warranty periods - switching the parent off
// switches this off too, so it can never be left on by itself. // switches this off too, so it can never be left on by itself.
config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value; config.CustomWarrantyPeriodsEnabled = config.WarrantyPeriodsEnabled && request.CustomWarrantyPeriodsEnabled!.Value;
config.StockNatureEnabled = request.StockNatureEnabled!.Value;
config.TrackingModeEnabled = request.TrackingModeEnabled!.Value;
config.LowStockAlertsEnabled = request.LowStockAlertsEnabled!.Value;
// The warehouse picker lives in the low-stock alert section - switching alerts off
// switches this off too, so it can never be left on by itself.
config.AlertWarehouseEnabled = config.LowStockAlertsEnabled && request.AlertWarehouseEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
@@ -82,5 +88,6 @@ public sealed class ProductConfigService : IProductConfigService
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled, c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.CustomVariantsEnabled, c.ItemImagesEnabled, c.CustomVariantsEnabled, c.ItemImagesEnabled,
c.CustomWarrantyPeriodsEnabled, c.CustomWarrantyPeriodsEnabled,
c.StockNatureEnabled, c.TrackingModeEnabled, c.LowStockAlertsEnabled, c.AlertWarehouseEnabled,
c.UpdatedAt, c.UpdatedBy); c.UpdatedAt, c.UpdatedBy);
} }
@@ -51,9 +51,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default)
{ {
var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -61,9 +61,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
} }
public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) public async Task<SalesInvoiceListTotalsDto> TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, bool? overdue = null, CancellationToken ct = default)
{ {
var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue);
// Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule. // Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule.
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@@ -86,7 +86,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
} }
/// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary> /// <summary>The list's filters, shared with <see cref="TotalsAsync"/> so the totals always cover exactly the listed rows.</summary>
private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid) private static IQueryable<SalesInvoice> Filter(IQueryable<SalesInvoice> q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid, bool? overdue = null)
{ {
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
{ {
@@ -106,6 +106,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// paid=true: nothing left to pay; paid=false: a balance is still due. // paid=true: nothing left to pay; paid=false: a balance is still due.
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
if (paid == false) q = q.Where(x => x.BalanceAmount > 0); if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
// overdue=true: past its due date (InvoiceDate) with money still owed — the Overdue card's rule.
if (overdue == true)
{
var today = DateTime.UtcNow.Date;
q = q.Where(x => x.BalanceAmount > 0 && x.InvoiceDate < today);
}
return q; return q;
} }
+9
View File
@@ -661,3 +661,12 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
- **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts. - **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts.
- **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`. - **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`.
- **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing. - **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing.
### 2026-10-19 — Day-end owner report (SMS + email to the owner)
- **What:** every evening (default 21:00, `DayEndReport:TimeZone`) the day's figures from `DayEndReportService.GetDayAsync` go to the owner — short SMS + full HTML email (summary, cash, per-branch, top 10 sales, expenses). Plan: `docs/DAY_END_OWNER_REPORT_PLAN.md`.
- **Data:** `owner_report_config` (singleton, seeded off), `owner_report_recipients` (≤ 5; mobile normalised to `+94XXXXXXXXX`, Sri Lankan `PhoneRules`), `owner_report_dispatches` (send log + "send once" guard: unique `(BusinessDate, Channel, RecipientId, IsTest, DedupeKey)`). Migration `AddOwnerReport` (additive; generated in MariaDB mode).
- **Sending:** `IMessageSender` → `AuthHexMessageSender` → AuthHex `POST /api/notify/email|sms` (new, admin-key gated) — **SMTP/SMS credentials stay in AuthHex**. `Messaging:Provider = Log` swaps in `LogOnlyMessageSender`. Optional `OwnerReport:AppUrl` adds a link back to the Day-End Report.
- **Schedule:** `OwnerReportJob` (`BackgroundService`, 1-min tick) → `OwnerReportService.RunScheduledAsync`: creates today's rows once the time passes, claims each row by bumping `Attempts` (multi-instance safe), retries failures up to 3× every 5 min; only today is ever sent automatically.
- **API:** `GET/PUT /api/v1/settings/owner-report` (ETag), `GET .../preview?date`, `POST .../test` (1/min), `GET .../dispatches` — `settings` nav policy, like the other config screens.
- **AuthHex (`ERP_Auth_Service`):** `NotifyController`, `ThirdPartyService.SendCustomEmailAsync` / `SendTextSmsAsync` (OTP methods untouched), `SmtpSettings:SenderName` → "Hexa ERP".
- **Verified locally (MySQL 8, `Messaging:Provider=Log`):** migration + seed applied; with a test recipient and send time 00:00 the job sent 1 SMS + 1 email, and later ticks sent nothing more. Real AuthHex delivery **not yet tested** (AuthHex must be redeployed with `/api/notify`).
+5
View File
@@ -407,3 +407,8 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`. 5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. 6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`.
- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. - Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change.
### 2026-10-19 — Settings → Configuration → Day-End Owner Report
- New page `app/dashboard/settings/owner-report` (`lib/api/owner-report.ts`, `types/owner-report.ts`), card on Settings → Configuration, header + breadcrumb titles.
- Sections: Delivery (master switch, SMS, Email, branch breakdown, send time), Recipients (≤ 5; name / mobile with the Sri Lankan phone rule + input limiter / email; active switch), Preview (SMS bubble + sandboxed email iframe for any day), Send test now (confirm dialog; blocked while there are unsaved changes), Send log (cards on phones, table on desktop, pagination). Draft + sticky Save/Discard bar; every validation error also toasts.
- `tsc` + `eslint` clean for the new files; not yet clicked through in a browser.
@@ -371,10 +371,21 @@ export default function ExpensesPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{summary ? ( {summary ? (
<> <>
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} /> {/* Cards double as filters (click again to clear). "Outstanding (all time)" ignores the
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} /> period, so it has no matching list filter and stays plain. */}
<StatCard
label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt}
onClick={() => setStatus((s) => (s === "Posted" ? "All" : "Posted"))} active={status === "Posted"}
/>
<StatCard
label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet}
onClick={() => setPaymentFilter((p) => (p === "Unpaid" ? "All" : "Unpaid"))} active={paymentFilter === "Unpaid"}
/>
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} /> <StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} /> <StatCard
label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle}
onClick={() => setPaymentFilter((p) => (p === "Overdue" ? "All" : "Overdue"))} active={paymentFilter === "Overdue"}
/>
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -97,14 +97,23 @@ export default function HelpPage() {
} }
}, [tickets]) }, [tickets])
// Set by clicking a stat card (click again to clear).
const [statusFilter, setStatusFilter] = useState<"All" | "Open" | "InProgress" | "Resolved">("All")
function toggleStatus(next: "Open" | "InProgress" | "Resolved") {
setStatusFilter((current) => (current === next ? "All" : next))
setPage(1)
}
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!tickets) return [] if (!tickets) return []
const term = debouncedSearch.toLowerCase() const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter( return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term) (t) =>
(statusFilter === "All" || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
) )
}, [tickets, debouncedSearch]) }, [tickets, debouncedSearch, statusFilter])
const totalItems = filtered.length const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -210,10 +219,10 @@ export default function HelpPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} /> <StatCard label="Total tickets" value={stats.total} icon={HelpCircle} onClick={() => { setStatusFilter("All"); setPage(1) }} active={statusFilter === "All"} />
<StatCard label="Open" value={stats.open} icon={Inbox} /> <StatCard label="Open" value={stats.open} icon={Inbox} onClick={() => toggleStatus("Open")} active={statusFilter === "Open"} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} /> <StatCard label="In Progress" value={stats.inProgress} icon={Clock} onClick={() => toggleStatus("InProgress")} active={statusFilter === "InProgress"} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} /> <StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} onClick={() => toggleStatus("Resolved")} active={statusFilter === "Resolved"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -170,10 +170,12 @@ export default function EmployeesPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} /> {/* Cards double as the status filter (click again to clear). "Inactive" spans several
<StatCard label="Active" value={stats.active} icon={UserCheck} /> statuses (suspended, resigned, retired…), so it has no single filter and stays plain. */}
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} onClick={() => setStatusFilter(undefined)} active={statusFilter === undefined} />
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))} active={statusFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} /> <StatCard label="Terminated" value={stats.terminated} icon={UserMinus} onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))} active={statusFilter === "Terminated"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -238,7 +238,14 @@ export default function LeaveRequestsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} /> {/* "Pending" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock,
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
active: statusFilter === "Submitted",
}] : null}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs"> <div className="relative w-full sm:max-w-xs">
@@ -113,28 +113,31 @@ export default function PurchaseOrderApprovalsPage() {
> >
<Eye className="size-4" /> <Eye className="size-4" />
</Link> </Link>
{/* Icon-only to keep the column narrow; the label lives in aria-label/title. */}
<Button <Button
type="button" type="button"
size="sm" size="icon-sm"
variant="success" variant="ghost"
disabled={!canApprove || approvingId === po.poId} disabled={!canApprove || approvingId === po.poId}
onClick={() => handleApprove(po, reload)} onClick={() => handleApprove(po, reload)}
aria-label={`Approve ${po.docNo}`}
title={approvingId === po.poId ? "Approving…" : "Approve"}
> >
<Check className="size-4" /> <Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="icon-sm"
variant="destructive" variant="ghost"
disabled={!canApprove} disabled={!canApprove}
onClick={() => { onClick={() => {
setRejectReason("") setRejectReason("")
setRejectTarget({ po, reload }) setRejectTarget({ po, reload })
}} }}
aria-label={`Reject ${po.docNo}`}
title="Reject"
> >
<X className="size-4" /> <X className="size-4" />
Reject
</Button> </Button>
</> </>
)} )}
@@ -19,31 +19,15 @@ import { validatePoLine } from "@/lib/validations/procurement"
import { formatAmount } from "@/lib/format" import { formatAmount } from "@/lib/format"
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax" import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { import { FieldError } from "@/components/ui/field"
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -147,16 +131,7 @@ function NewPurchaseOrderContent() {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [vendorDialogOpen, setVendorDialogOpen] = useState(false) const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig() const { config: procConfig } = useProcurementConfig()
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
function loadItems() { function loadItems() {
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items)) return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
@@ -187,46 +162,6 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
function resetVendorForm() {
setVName("")
setVTerms("")
setVTaxReg("")
setVCurrency("LKR")
setVOptional(EMPTY_VENDOR_OPTIONAL)
setVErrors({})
}
async function handleCreateVendor() {
const nextErrors: Record<string, string> = {}
if (!vName.trim()) nextErrors.name = "Vendor name is required"
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
setVErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setVSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedVendorCode,
name: vName,
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
currency: vCurrency,
...vendorOptionalPayload(procConfig, vOptional),
})
await loadVendors()
setVendorId(result.data.vendorId)
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setVendorDialogOpen(false)
resetVendorForm()
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setVSubmitting(false)
}
}
useEffect(() => { useEffect(() => {
if (requisitionId) { if (requisitionId) {
requisitionsApi requisitionsApi
@@ -446,62 +381,19 @@ function NewPurchaseOrderContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}> <Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" onClick={() => setVendorDialogOpen(true)}>
<DialogTrigger <Plus className="size-5" />
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />} </Button>
> <CreateVendorDialog
<Plus className="size-5" /> open={vendorDialogOpen}
</DialogTrigger> onOpenChange={setVendorDialogOpen}
<DialogContent className="sm:max-w-md"> description="Create a supplier record without leaving this PO. Its code is generated from the name."
<DialogHeader className="items-center text-center"> onCreated={(vendor) => {
<DialogTitle>New vendor</DialogTitle> // Active vendors only in this picker — a new one is Active, so add it and select it.
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription> setVendors((prev) => [...(prev ?? []), vendor])
</DialogHeader> setVendorId(vendor.vendorId)
<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>
</div> </div>
</div> </div>
{branchingEnabled && isAdmin && ( {branchingEnabled && isAdmin && (
@@ -7,7 +7,7 @@ import { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download" import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
import { PurchaseOrderSummary } from "@/types/procurement" import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
@@ -23,6 +23,8 @@ export default function PurchaseOrdersListPage() {
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null) const [submittingId, setSubmittingId] = useState<number | null>(null)
const [pdfId, setPdfId] = useState<number | null>(null) const [pdfId, setPdfId] = useState<number | null>(null)
// Shared with the list's Status filter, so the "Pending Approval" tile and the dropdown stay in sync.
const [status, setStatus] = useState<PurchaseOrderStatus | "All">("All")
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) { async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
setDeletingId(po.poId) setDeletingId(po.poId)
@@ -83,9 +85,16 @@ export default function PurchaseOrdersListPage() {
</div> </div>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} /> <KpiRow
tiles={stats ? [{
label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock,
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")), active: status === "PendingApproval",
}] : null}
/>
<PurchaseOrderListView <PurchaseOrderListView
status={status}
onStatusChange={setStatus}
renderActions={(po, { reload }) => { renderActions={(po, { reload }) => {
const editable = isPoEditable(po.status) const editable = isPoEditable(po.status)
return ( return (
@@ -78,7 +78,13 @@ export default function RequisitionsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} /> {/* "Open" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Open Requisitions", value: stats.openRequisitions, icon: Send,
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")), active: status === "Submitted",
}] : null}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}> <Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -25,6 +25,8 @@ export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null) const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Set by the "Open RFQs" tile (click again to clear).
const [openOnly, setOpenOnly] = useState(false)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that // Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for. // list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
@@ -46,7 +48,8 @@ export default function RfqsListPage() {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}` return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
} }
const visibleRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs const branchRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
const visibleRfqs = openOnly && branchRfqs ? branchRfqs.filter((r) => r.status === "Open") : branchRfqs
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -61,7 +64,7 @@ export default function RfqsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText }] : null} /> <KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText, onClick: () => setOpenOnly((v) => !v), active: openOnly }] : null} />
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -3,11 +3,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, usePathname, useRouter } from "next/navigation" import { useParams, usePathname, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react" import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Plus, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals" import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config" import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods" import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
@@ -19,7 +20,7 @@ import { WastageRecord, wastageApi } from "@/lib/api/wastage"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data" import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data" import { Brand, Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, SubCategory, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock" import { LedgerEntry } from "@/types/stock"
import { ItemPriceHistory } from "@/types/approvals" import { ItemPriceHistory } from "@/types/approvals"
@@ -43,6 +44,8 @@ import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel" import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel" import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
import { WarrantyFields } from "@/components/products/WarrantyFields" import { WarrantyFields } from "@/components/products/WarrantyFields"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
/** /**
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and * The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
@@ -102,6 +105,11 @@ function ItemDetailPage() {
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
// Edit screen pickers — shown only while Product configuration has subcategories / brands on.
const [brands, setBrands] = useState<Brand[]>([])
const [subCategoryList, setSubCategoryList] = useState<{ categoryId: number | null; items: SubCategory[] }>({ categoryId: null, items: [] })
const [addUomOpen, setAddUomOpen] = useState(false)
const [addBrandOpen, setAddBrandOpen] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Profile activity: full recent movement history for this item, across every warehouse. // Profile activity: full recent movement history for this item, across every warehouse.
@@ -118,14 +126,11 @@ function ItemDetailPage() {
// No longer editable here — carried through unchanged so a save doesn't silently clear it. // No longer editable here — carried through unchanged so a save doesn't silently clear it.
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null) const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null) const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked") const [stockNature, setStockNature] = useState<StockNature>("Stocked")
// Default vendor, tax class, and tracking mode are no longer editable on this page — // Default vendor isn't editable on this page — carried through unchanged so a save doesn't clear it.
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null) const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None") const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Warranty: edited in the Warranty section below (options follow Product configuration). // Warranty: edited in the Warranty section below (options follow Product configuration).
@@ -211,6 +216,7 @@ function ItemDetailPage() {
}) })
.catch(() => {}) .catch(() => {})
productConfig().then(setConfig).catch(() => {}) productConfig().then(setConfig).catch(() => {})
brandsApi.list({ pageSize: 200, status: "Active" }).then((res) => setBrands(res.items)).catch(() => {})
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {}) warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way. // Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {}) approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
@@ -218,6 +224,17 @@ function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId]) }, [itemId])
// Subcategories are fetched per category, like the create screen.
// Tagged with the category they belong to, so a stale list is never shown for another one.
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) return
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategoryList({ categoryId, items: res.items }))
.catch(() => setSubCategoryList({ categoryId, items: [] }))
}, [categoryId, config?.subcategoriesEnabled])
const subCategories = subCategoryList.categoryId === categoryId ? subCategoryList.items : []
// Recent activity + stock value: independent of the edit form above, loaded once the // Recent activity + stock value: independent of the edit form above, loaded once the
// item id is known. A large page size is fine here — one item's lifetime history. // item id is known. A large page size is fine here — one item's lifetime history.
useEffect(() => { useEffect(() => {
@@ -313,7 +330,7 @@ function ItemDetailPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) { if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period" nextErrors.warrantyPeriodMonths = "Select a warranty period"
} }
if (lowStockAt.trim()) { if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt) const point = Number(lowStockAt)
const qty = Number(reorderQty) const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more" if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -326,20 +343,32 @@ function ItemDetailPage() {
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse // Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
// rules set under Reorder settings survive an unrelated edit (name, price, …). // rules set under Reorder settings survive an unrelated edit (name, price, …).
const saved = alertFromRules(item.reorder ?? []) const saved = alertFromRules(item.reorder ?? [])
// Alert warehouse switched off: the picker is hidden and a changed alert watches every warehouse.
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : saved.warehouseId
const alertChanged = const alertChanged =
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId)) !!config?.lowStockAlertsEnabled &&
(lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || alertWarehouseId !== saved.warehouseId)))
// Follow Product configuration: a switched-off feature's value is not sent (the server
// answers CONFIG_DISABLED for a gated value), so saving still works after a switch is turned off.
const allowPrices = !!config?.sellingPricesEnabled
const allowWarranty = !!config?.warrantyPeriodsEnabled
setSaving(true) setSaving(true)
try { try {
const result = await itemsApi.update( const result = await itemsApi.update(
item.itemId, item.itemId,
{ {
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths, subCategoryId: config?.subcategoriesEnabled ? subCategoryId : null,
brandId: config?.brandsEnabled ? brandId : null,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
warranty: allowWarranty ? warranty : "NonWarranty",
warrantyPeriodMonths: allowWarranty && warranty === "Warranty" ? warrantyPeriodMonths : null,
taxRateId, taxRateId,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null, sellingMinPrice: allowPrices && sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null, sellingMaxPrice: allowPrices && sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null, sellingFlatPrice: allowPrices && sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null, contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null, contentUnit: contentQty.trim() ? contentUnit : null,
}, },
@@ -347,7 +376,8 @@ function ItemDetailPage() {
) )
let saved = result.data let saved = result.data
if (alertChanged) { if (alertChanged) {
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId) const watchedId = config?.alertWarehouseEnabled ? warehouseId : null
const watched = watchedId !== null ? [watchedId] : warehouses.map((w) => w.warehouseId)
const settings = lowStockAt.trim() const settings = lowStockAt.trim()
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) })) ? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
: [] : []
@@ -428,114 +458,171 @@ function ItemDetailPage() {
} }
if (editMode) { if (editMode) {
return ( /** Mobile-first section shell, same as the New Item screen. */
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
<Link // Tracking mode and base UOM give meaning to every stock row already recorded, so they are
href={`/dashboard/products/${item.itemId}`} // only changeable while the item has no movements yet (the server freezes the UOM too).
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground" const hasHistory = ledger === null || ledger.length > 0
> const trackingOptions: TrackingMode[] = [
<ArrowLeft className="size-4" /> "None",
Back to item ...(config?.batchListsEnabled || trackingMode === "Batch" ? (["Batch"] as const) : []),
</Link> ...(config?.serialNumbersEnabled || trackingMode === "Serial" ? (["Serial"] as const) : []),
<div> ]
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
<p className="text-base text-muted-foreground">Update this item&apos;s basic info, pricing and classification.</p> return (
</div> <div className="flex flex-col gap-5 sm:gap-6">
{conflict && ( <Link
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning"> href={`/dashboard/products/${item.itemId}`}
<AlertTriangle className="size-5 shrink-0" /> className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
<div className="flex flex-col gap-2"> >
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p> <ArrowLeft className="size-4" />
<Button size="sm" variant="outline" onClick={load}>Reload</Button> Back to item
</div> </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> </div>
)} </div>
{saveError && !conflict && ( )}
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)} <section className={sectionClass}>
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6"> <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="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"> <div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label> <Label className="text-base">Subcategory (optional)</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} /> <Select<number | null>
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} /> value={subCategoryId}
</div> onValueChange={setSubCategoryId}
<div className="flex flex-col gap-2"> disabled={conflict || subCategories.length === 0}
<Label className="text-base">Barcode (optional)</Label> items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
<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]} /> <SelectTrigger className="h-12! w-full text-base">
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p> <SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</div> </SelectTrigger>
<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>
<SelectContent> <SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem> <SelectItem value={null} className="text-base">None</SelectItem>
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)} {subCategories.map((s) => <SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">{s.name}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </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>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Category</Label> <Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}> <div className="flex gap-2">
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger> <Select<number | null>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent> value={brandId}
</Select> onValueChange={setBrandId}
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} /> disabled={conflict}
items={brands.map((b) => ({ label: b.name, value: b.brandId }))}
>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base"><SelectValue placeholder="Select brand" /></SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">None</SelectItem>
{brands.map((b) => <SelectItem key={b.brandId} value={b.brandId} className="text-base">{b.name}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddBrandOpen(true)} aria-label="Add brand" disabled={conflict}>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div> </div>
<div className="flex flex-col gap-2"> )}
<Label className="text-base">Base UOM</Label> <div className="flex flex-col gap-2">
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}> <Label className="text-base">Tax rate</Label>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger> <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 &amp; 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> <SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent>
</Select> </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>
<div className="flex flex-col gap-2"> <FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
<Label className="text-base">Content size</Label> {hasHistory && <p className="text-sm text-muted-foreground">Locked — this item already has stock history.</p>}
<div className="flex gap-2"> </div>
<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" /> <div className="flex flex-col gap-2">
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}> <Label className="text-base">Content size</Label>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger> <div className="flex gap-2">
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent> <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> <Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
</div> <SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} /> <SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
<p className="text-sm text-muted-foreground"> </Select>
{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> </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"> <div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label> <Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}> <Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
@@ -547,56 +634,168 @@ function ItemDetailPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{config?.trackingModeEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label> <Label className="text-base">Tracking mode</Label>
<div className="flex gap-2"> <Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict || hasHistory}>
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} /> <SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>
{trackingOptions.map((m) => <SelectItem key={m} value={m} className="text-base">{m}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{hasHistory
? "Locked — this item already has stock history, so its tracking can't change."
: config.serialNumbersEnabled
? "Choose Serial if each unit needs its own serial number on receipt."
: config.batchListsEnabled
? "Batch tracking is available; serial tracking is currently disabled in product settings."
: "Batch and serial tracking are currently disabled in product settings."}
</p>
</div>
)}
</div>
</section>
{config?.lowStockAlertsEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
<div className="grid grid-cols-2 gap-2">
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert at" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} /> <Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
</div> </div>
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} /> <FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."} {alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${config.alertWarehouseEnabled && warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> {config.alertWarehouseEnabled && (
<Label className="text-base">Warehouse (optional)</Label> <div className="flex flex-col gap-2">
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}> <Label className="text-base">Warehouse (optional)</Label>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
<SelectContent> <SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem> <SelectContent>
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)} <SelectItem value={null} className="text-base">Every warehouse</SelectItem>
</SelectContent> {warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
</Select> </SelectContent>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p> </Select>
</div> <p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
)}
</div> </div>
{config?.warrantyPeriodsEnabled && ( </section>
<WarrantyFields )}
config={config}
customMonths={customWarrantyMonths} {(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
warranty={warranty} <div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
months={warrantyPeriodMonths} {config?.sellingPricesEnabled && (
onChange={(w, m) => { <section className={sectionClass}>
setWarranty(w) <div>
setWarrantyPeriodMonths(m) <h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" })) <p className="text-sm text-muted-foreground">Optional — leave blank to sell at stock value.</p>
}} </div>
error={errors.warrantyPeriodMonths || undefined} <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
disabled={conflict} <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> </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> </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 ( return (
<div className="flex flex-col gap-6"> <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 {/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
made through the Edit dialog's simple alert fields. */} made through the Edit dialog's simple alert fields. */}
<ItemReorderPanel {config?.lowStockAlertsEnabled && (
key={JSON.stringify(item.reorder ?? [])} <ItemReorderPanel
itemId={item.itemId} key={JSON.stringify(item.reorder ?? [])}
stockNature={item.stockNature} itemId={item.itemId}
settings={item.reorder ?? []} stockNature={item.stockNature}
warehouses={warehouses} settings={item.reorder ?? []}
uomName={uomName(item.baseUomId)} warehouses={warehouses}
onSaved={(settings) => applyItem({ ...item, reorder: settings })} 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). */} {/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
@@ -254,6 +254,19 @@ export default function NewItemPage() {
if (!config.batchListsEnabled && trackingMode === "Batch") { if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None") setTrackingMode("None")
} }
if (!config.trackingModeEnabled && trackingMode !== "None") {
setTrackingMode("None")
}
if (!config.stockNatureEnabled && stockNature !== "Stocked") {
setStockNature("Stocked")
}
if (!config.lowStockAlertsEnabled && (lowStockAt || reorderQty)) {
setLowStockAt("")
setReorderQty("")
}
if (!config.alertWarehouseEnabled && warehouseId !== null) {
setWarehouseId(null)
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") { if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty") setWarranty("NonWarranty")
setWarrantyPeriodMonths(null) setWarrantyPeriodMonths(null)
@@ -267,6 +280,10 @@ export default function NewItemPage() {
}, [ }, [
config, config,
trackingMode, trackingMode,
stockNature,
lowStockAt,
reorderQty,
warehouseId,
warranty, warranty,
config?.serialNumbersEnabled, config?.serialNumbersEnabled,
config?.batchListsEnabled, config?.batchListsEnabled,
@@ -585,7 +602,7 @@ export default function NewItemPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) { if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period" nextErrors.warrantyPeriodMonths = "Select a warranty period"
} }
if (lowStockAt.trim()) { if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt) const point = Number(lowStockAt)
const qty = Number(reorderQty) const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more" if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -629,8 +646,11 @@ export default function NewItemPage() {
let created = 0 let created = 0
const createdIds: number[] = [] const createdIds: number[] = []
try { try {
const effectiveStockNature = config?.stockNatureEnabled ? stockNature : "Stocked"
const effectiveTrackingMode = const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial" !config?.trackingModeEnabled
? "None"
: !config?.serialNumbersEnabled && trackingMode === "Serial"
? "None" ? "None"
: !config?.batchListsEnabled && trackingMode === "Batch" : !config?.batchListsEnabled && trackingMode === "Batch"
? "None" ? "None"
@@ -652,7 +672,7 @@ export default function NewItemPage() {
subCategoryId, subCategoryId,
brandId, brandId,
baseUomId, baseUomId,
stockNature, stockNature: effectiveStockNature,
trackingMode: effectiveTrackingMode, trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty, warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths, warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
@@ -696,8 +716,9 @@ export default function NewItemPage() {
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like // Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
// images — the items exist either way, and the rules can be fixed under Reorder settings. // images — the items exist either way, and the rules can be fixed under Reorder settings.
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId) const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : null
if (lowStockAt.trim() && watched.length > 0) { const watched = alertWarehouseId !== null ? [alertWarehouseId] : warehouses.map((w) => w.warehouseId)
if (config?.lowStockAlertsEnabled && lowStockAt.trim() && watched.length > 0) {
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) })) const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
let failed = 0 let failed = 0
for (const itemId of createdIds) { for (const itemId of createdIds) {
@@ -1000,6 +1021,7 @@ export default function NewItemPage() {
</p> </p>
</div> </div>
)} )}
{config?.stockNatureEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label> <Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}> <Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -1013,6 +1035,8 @@ export default function NewItemPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{config?.trackingModeEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label> <Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> <Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
@@ -1041,9 +1065,11 @@ export default function NewItemPage() {
)} )}
</p> </p>
</div> </div>
)}
</div> </div>
</section> </section>
{config?.lowStockAlertsEnabled && (
<section className={sectionClass}> <section className={sectionClass}>
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2> <h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
@@ -1097,6 +1123,7 @@ export default function NewItemPage() {
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p> </p>
</div> </div>
{config.alertWarehouseEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label> <Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> <Select<number | null>
@@ -1117,8 +1144,10 @@ export default function NewItemPage() {
</Select> </Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p> <p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div> </div>
)}
</div> </div>
</section> </section>
)}
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( {(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
@@ -259,6 +259,12 @@ export default function ItemsPage() {
} }
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
/** Stat-card filter: toggles the stock-level filter (same one as the "Stock level" dropdown). */
function filterByStock(next: ItemStockFilter | "All") {
setStock((current) => (current === next ? "All" : next))
setPage(1)
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0 const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
function changeCategory(next: number | "All") { function changeCategory(next: number | "All") {
@@ -377,9 +383,9 @@ export default function ItemsPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{itemStats ? ( {itemStats ? (
<> <>
<StatCard label="Total items" value={itemStats.total} icon={Package} /> <StatCard label="Total items" value={itemStats.total} icon={Package} onClick={() => filterByStock("All")} active={stock === "All"} />
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} /> <StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} onClick={() => filterByStock("LowStock")} active={stock === "LowStock"} />
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} /> <StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} onClick={() => filterByStock("OutOfStock")} active={stock === "OutOfStock"} />
</> </>
) : ( ) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />) Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -426,8 +432,12 @@ export default function ItemsPage() {
]} ]}
/> />
)} )}
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} /> {config?.trackingModeEnabled && (
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} /> <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, customVariantsEnabled: config.customVariantsEnabled,
itemImagesEnabled: config.itemImagesEnabled, itemImagesEnabled: config.itemImagesEnabled,
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled, customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
stockNatureEnabled: config.stockNatureEnabled,
trackingModeEnabled: config.trackingModeEnabled,
lowStockAlertsEnabled: config.lowStockAlertsEnabled,
alertWarehouseEnabled: config.alertWarehouseEnabled,
...patch, ...patch,
}, },
etag ?? "", etag ?? "",
@@ -224,6 +228,54 @@ export default function ProductSettingsPage() {
busy={saving === "brandsEnabled"} busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)} onChange={(v) => toggle("brandsEnabled", v)}
/> />
<ToggleRow
label="Stock nature"
description="Lets items be Stocked, Non-stocked or Service. Off hides the choice and new items are Stocked; existing items keep their nature."
checked={config.stockNatureEnabled}
busy={saving === "stockNatureEnabled"}
onChange={(v) => toggle("stockNatureEnabled", v)}
/>
<ToggleRow
label="Tracking mode"
description="Shows the tracking choice (None, Batch, Serial) on items. Off hides it and new items are untracked; existing items keep their mode."
checked={config.trackingModeEnabled}
busy={saving === "trackingModeEnabled"}
onChange={(v) => toggle("trackingModeEnabled", v)}
/>
<ToggleRow
label="Low stock alert"
description="Shows the low-stock alert (alert-at and reorder qty) on item create and the item page. Off hides it; alerts already set keep working."
checked={config.lowStockAlertsEnabled}
busy={saving === "lowStockAlertsEnabled"}
onChange={(v) =>
v
? toggle("lowStockAlertsEnabled", true)
: // The warehouse picker lives in the alert section, so switching it off turns that off too.
save(
"lowStockAlertsEnabled",
{ lowStockAlertsEnabled: false, alertWarehouseEnabled: false },
config.alertWarehouseEnabled
? "Low stock alert and alert warehouse disabled."
: "Low stock alert disabled.",
)
}
/>
<ToggleRow
label="Alert warehouse"
description={
config.lowStockAlertsEnabled
? "Lets the low-stock alert watch one chosen warehouse. Off: the alert watches every warehouse."
: "Needs Low stock alert switched on."
}
checked={config.alertWarehouseEnabled}
busy={saving === "alertWarehouseEnabled"}
disabled={!config.lowStockAlertsEnabled}
onChange={(v) => toggle("alertWarehouseEnabled", v)}
/>
</div> </div>
{config.updatedAt && ( {config.updatedAt && (
@@ -249,6 +301,10 @@ const LABELS: Record<keyof ProductConfig, string> = {
customVariantsEnabled: "Custom variants", customVariantsEnabled: "Custom variants",
itemImagesEnabled: "Item images", itemImagesEnabled: "Item images",
customWarrantyPeriodsEnabled: "Custom warranty periods", customWarrantyPeriodsEnabled: "Custom warranty periods",
stockNatureEnabled: "Stock nature",
trackingModeEnabled: "Tracking mode",
lowStockAlertsEnabled: "Low stock alert",
alertWarehouseEnabled: "Alert warehouse",
updatedAt: "Updated at", updatedAt: "Updated at",
updatedBy: "Updated by", updatedBy: "Updated by",
} }
@@ -265,6 +321,10 @@ type FlagKey = keyof Pick<
| "customVariantsEnabled" | "customVariantsEnabled"
| "itemImagesEnabled" | "itemImagesEnabled"
| "customWarrantyPeriodsEnabled" | "customWarrantyPeriodsEnabled"
| "stockNatureEnabled"
| "trackingModeEnabled"
| "lowStockAlertsEnabled"
| "alertWarehouseEnabled"
> >
function ToggleRow({ function ToggleRow({
@@ -296,7 +296,13 @@ export default function GrnListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} /> {/* "Pending" = Draft (DashboardService) — the tile toggles the Status filter to Draft. */}
<KpiRow
tiles={stats ? [{
label: "Pending GRNs", value: stats.pendingGrns, icon: Clock,
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")), active: status === "Draft",
}] : null}
/>
<FilterBar <FilterBar
searchFirst searchFirst
@@ -0,0 +1,285 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Combobox } from "@/components/ui/combobox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
let keySeq = 0
const blankLine = (warehouseId: number): Line => ({
key: `line-${++keySeq}`,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
/** 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"
/**
* New bundle template (Sales → Bundle Templates → New) — a full page rather than a modal, so a bundle can carry
* as many component lines as it needs. The template code is generated by the server on save.
*/
export default function NewBundleTemplatePage() {
const router = useRouter()
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
// What the whole bundle sells for — the server splits it over the components on save.
const [bundlePrice, setBundlePrice] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine(defaultWarehouseId)])
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
const itemOptions = useMemo(() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })), [items])
// What the included items would sell for on their own (their flat prices) — shown beside the bundle price
// for reference, and sent as each line's weight when the server splits the bundle price.
const normalValue = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
const bundlePriceNum = bundlePrice.trim() === "" ? null : Number(bundlePrice)
const customerSaves = bundlePriceNum !== null && normalValue > 0 ? normalValue - bundlePriceNum : null
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (bundlePriceNum === null || Number.isNaN(bundlePriceNum) || bundlePriceNum <= 0) return setError("Enter a bundle price above 0.")
if (lines.length === 0) return setError("Add at least one component line.")
if (!lines.some((line) => line.includeInBundle)) return setError("Include at least one line in the bundle.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
if (lines.some((line) => !(Number(line.qty) > 0))) return setError("Every line needs a quantity above 0.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
bundlePrice: bundlePriceNum,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
router.push("/dashboard/sales/bundles/templates")
} catch (err) {
setError(errorMessage(err))
toast.error("Could not save bundle template", errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-5 sm:gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/bundles/templates" className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to bundle templates">
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">New Bundle Template</h1>
<p className="text-sm text-muted-foreground sm:text-base">The template code is generated automatically when you save.</p>
</div>
</div>
{loading && <Skeleton className="h-64 w-full" />}
{!loading && (
<>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Template details</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="bt-name" className="text-base" required>Template name</Label>
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" className="h-12! text-base" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bt-warehouse" className="text-base">Warehouse</Label>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger id="bt-warehouse" className="h-12! w-full text-base"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Every component is sold from this warehouse.</p>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="bt-description" className="text-base">Description (optional)</Label>
<Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" className="h-12! text-base" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bt-price" className="text-base" required>Bundle price</Label>
<Input id="bt-price" type="number" min="0" step="0.01" inputMode="decimal" value={bundlePrice} onChange={(e) => setBundlePrice(e.target.value)} placeholder="0.00" className="h-12! font-mono text-base tabular-nums" />
<p className="text-sm text-muted-foreground">
What the whole bundle sells for.
{normalValue > 0 && <> Items normally sell for <span className="font-medium text-foreground tabular-nums">{formatAmount(normalValue)}</span>{customerSaves !== null && customerSaves > 0 && <> — customer saves <span className="font-medium text-success tabular-nums">{formatAmount(customerSaves)}</span></>}.</>}
</p>
</div>
</div>
</section>
<section className={sectionClass}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Components</h2>
<p className="text-sm text-muted-foreground">The items in this bundle. Excluded lines are kept on the template but not added when it&apos;s sold.</p>
</div>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, blankLine(warehouseId ?? 0)])}>
<Plus className="size-4" />
Add line
</Button>
</div>
{/* Phones: one card per line. */}
<ul className="flex flex-col gap-3 md:hidden">
{lines.map((line, idx) => (
<li key={line.key} className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Line {idx + 1}</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove line ${idx + 1}`} title="Remove line" onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</div>
<Combobox items={itemOptions} value={line.itemId || null} onValueChange={(v) => selectItem(line.key, v ?? 0)} placeholder="Search item…" />
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Qty ({baseUomLabel(items, uoms, line.itemId) || "UOM"})</span>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="font-mono tabular-nums" aria-label="Qty" />
</div>
<label className="flex items-center justify-between text-sm">
<span>Include in bundle</span>
<Switch checked={line.includeInBundle} onCheckedChange={(v) => updateLine(line.key, { includeInBundle: v })} aria-label="Include in bundle" />
</label>
</li>
))}
</ul>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-lg border md:block">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10">#</TableHead>
<TableHead className="min-w-72">Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="w-32 text-right">Qty</TableHead>
<TableHead className="w-24 text-center">Include</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className={cn(!line.includeInBundle && "opacity-60")}>
<TableCell className="text-sm text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell>
<Combobox items={itemOptions} value={line.itemId || null} onValueChange={(v) => selectItem(line.key, v ?? 0)} placeholder="Search item…" />
</TableCell>
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId) || "—"}</TableCell>
<TableCell>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right font-mono tabular-nums" aria-label={`Qty, line ${idx + 1}`} />
</TableCell>
<TableCell className="text-center">
<Switch checked={line.includeInBundle} onCheckedChange={(v) => updateLine(line.key, { includeInBundle: v })} aria-label={`Include line ${idx + 1} in bundle`} />
</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove line ${idx + 1}`} title="Remove line" onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Bundle price</span>
<span className="font-semibold tabular-nums">{bundlePriceNum !== null && !Number.isNaN(bundlePriceNum) ? formatAmount(bundlePriceNum) : "—"}</span>
</div>
</section>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{error}</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/sales/bundles/templates" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-5" />
{saving ? "Saving…" : "Save bundle template"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -1,78 +1,46 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react" import { ArrowLeft, Plus, ShoppingBag } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { bundleApi } from "@/lib/api/bundles" import { bundleApi } from "@/lib/api/bundles"
import { itemsApi } from "@/lib/api/items" import { formatAmount } from "@/lib/format"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { BundleSaleTemplateSummary } from "@/types/bundles"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and // Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below. // re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10 const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({
key,
itemId: 0,
warehouseId,
qty: 1,
unitPrice: 0,
includeInBundle: true,
sortOrder: 0,
})
export default function BundleTemplatesPage() { export default function BundleTemplatesPage() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [templateName, setTemplateName] = useState("")
const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([]) const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
async function refreshTemplates() { async function refreshTemplates() {
const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined }) try {
setTemplates(res.items) const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
setPagination(res.pagination) setTemplates(res.items)
setPagination(res.pagination)
setError(null)
} catch (err) {
setError(errorMessage(err))
}
} }
function handleShowMore() { function handleShowMore() {
@@ -81,26 +49,11 @@ export default function BundleTemplatesPage() {
} }
useEffect(() => { useEffect(() => {
Promise.all([ refreshTemplates().finally(() => {
itemsApi.list({ pageSize: 200 }), setLoading(false)
uomsApi.list({ pageSize: 200 }), setShowMoreLoading(false)
warehousesApi.list({ pageSize: 200 }), })
]) // eslint-disable-next-line react-hooks/exhaustive-deps
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? 0
setWarehouseId(defaultWarehouseId)
setLines([blankLine("line-1", defaultWarehouseId)])
await refreshTemplates()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false))
}, [page, pageSize, query]) }, [page, pageSize, query])
useEffect(() => { useEffect(() => {
@@ -110,73 +63,8 @@ export default function BundleTemplatesPage() {
useEffect(() => setPage(1), [query]) useEffect(() => setPage(1), [query])
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items]
)
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
}
function resetForm() {
setTemplateName("")
setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)])
setError(null)
}
function openCreateForm() {
resetForm()
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) resetForm()
}
const componentSubtotal = useMemo(
() => lines.filter((l) => l.includeInBundle).reduce((sum, l) => sum + Number(l.qty || 0) * Number(l.unitPrice || 0), 0),
[lines]
)
async function submit() {
if (!templateName.trim()) return setError("Enter a template name.")
if (lines.some((line) => !line.itemId)) return setError("Select an item for every component line.")
setSaving(true)
setError(null)
try {
const created = await bundleApi.createTemplate({
templateName: templateName.trim(),
description: description.trim() || null,
lines: lines.map((line, idx) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId || warehouseId || 0),
qty: Number(line.qty),
unitPrice: Number(line.unitPrice),
includeInBundle: line.includeInBundle,
sortOrder: idx,
})),
})
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm()
setFormOpen(false)
await refreshTemplates()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) { if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div> return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading bundle templates...</div>
} }
return ( return (
@@ -191,127 +79,11 @@ export default function BundleTemplatesPage() {
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p> <p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div> </div>
</div> </div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}> <Link href="/dashboard/sales/bundles/templates/new" className={cn(buttonVariants({ size: "lg" }))}>
<DialogTrigger <Plus className="size-5" />
render={ New Bundle Template
<Button size="lg" onClick={openCreateForm}> </Link>
<Plus className="size-5" />
New Bundle Template
</Button>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>New bundle template</DialogTitle>
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="bt-name">Template name</FieldLabel>
<Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
</Field>
<Field>
<FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
<Select
value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => {
const nextId = v ? Number(v) : null
setWarehouseId(nextId)
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}}
>
<SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="bt-description">Description (optional)</FieldLabel>
<Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</Field>
</FieldGroup>
{lines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor={`bt-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`bt-qty-${line.key}`}
type="number"
min="0.0001"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
<Input
id={`bt-price-${line.key}`}
type="number"
min="0"
step="0.01"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel>Include in bundle</FieldLabel>
<button
type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn(
"w-fit rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)}
>
{line.includeInBundle ? "Included" : "Excluded"}
</button>
</Field>
</FieldGroup>
))}
<div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span>
<span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : "Save bundle template"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
@@ -357,7 +129,11 @@ export default function BundleTemplatesPage() {
{t.description && <p className="text-sm text-muted-foreground">{t.description}</p>} {t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bundle price</p>
<p className="font-medium text-foreground tabular-nums">{t.bundlePrice > 0 ? formatAmount(t.bundlePrice) : "—"}</p>
</div>
<div> <div>
<p className="text-muted-foreground">Lines</p> <p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{t.lineCount}</p> <p className="font-medium text-foreground">{t.lineCount}</p>
@@ -393,6 +169,7 @@ export default function BundleTemplatesPage() {
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="w-48">Template</TableHead> <TableHead className="w-48">Template</TableHead>
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="text-right">Bundle price</TableHead>
<TableHead className="text-right">Lines</TableHead> <TableHead className="text-right">Lines</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
@@ -408,6 +185,7 @@ export default function BundleTemplatesPage() {
<div className="text-xs text-muted-foreground">{t.templateCode}</div> <div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="text-right text-sm font-medium tabular-nums">{t.bundlePrice > 0 ? formatAmount(t.bundlePrice) : "—"}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -453,7 +231,7 @@ export default function BundleTemplatesPage() {
</> </>
) : ( ) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground"> <div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No bundle templates yet — create one above. No bundle templates yet — use New Bundle Template to create one.
</div> </div>
)} )}
</section> </section>
@@ -1,14 +1,15 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { import {
ArrowLeft, ArrowLeft,
Banknote, Banknote,
CreditCard, CreditCard,
FileDown, FileDown,
Hash,
Landmark, Landmark,
LineChart,
PiggyBank, PiggyBank,
Receipt, Receipt,
ReceiptText, ReceiptText,
@@ -19,73 +20,26 @@ import {
import { dayEndReportApi } from "@/lib/api/day-end-report" import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { formatAmount, formatReportDate, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf" import { downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination" import { useClientPagination } from "@/hooks/use-client-pagination"
import { maskAccountNumber } from "@/types/bank-deposits" import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales" import { DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart" import { KV, localIso, mobileCard } from "@/components/sales/day-end-report-parts"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
/** Local yyyy-MM-dd, `offsetDays` from today. */
function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null } type Loaded<T> = { key: string; data: T | null; error: string | null }
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */ /** "1 sale" / "12 sales". */
function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) { const salesCountLabel = (n: number) => `${n} sale${n === 1 ? "" : "s"}`
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */ /** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) { function ProfitStatement({ day }: { day: DayEndReportDay }) {
@@ -227,35 +181,33 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
) )
} }
export default function DayEndReportPage() { // useSearchParams needs a Suspense boundary for the static build.
export default function DayEndReportRoute() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<DayEndReportPage />
</Suspense>
)
}
function DayEndReportPage() {
// ?date=yyyy-MM-dd opens a given day (Day-wise Income links here); otherwise today.
const dateParam = useSearchParams().get("date")
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to // Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent. // their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-") const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [date, setDate] = useState(localIso()) // Fixed for the page — not user-editable.
const [from, setFrom] = useState(localIso(-29)) const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : localIso()
const [to, setTo] = useState(localIso()) const [downloading, setDownloading] = useState(false)
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading". // Results are tagged with the request they answer; a stale or pending one reads as "loading".
const dayKey = `${date}|${branchId}` const dayKey = `${date}|${branchId}`
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null) const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
const day = dayResult?.key === dayKey ? dayResult : null const day = dayResult?.key === dayKey ? dayResult : null
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => { useEffect(() => {
if (!date) return if (!date) return
@@ -269,39 +221,15 @@ export default function DayEndReportPage() {
} }
}, [date, branchId, dayKey]) }, [date, branchId, dayKey])
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadDay() { async function downloadDay() {
if (!day?.data) return if (!day?.data) return
setDownloading("day") setDownloading(true)
try { try {
await downloadDayEndReportPdf(day.data, branchLabel) await downloadDayEndReportPdf(day.data, branchLabel)
} catch (err) { } catch (err) {
toast.error("Could not create the PDF", errorMessage(err)) toast.error("Could not create the PDF", errorMessage(err))
} finally { } finally {
setDownloading(null) setDownloading(false)
}
}
async function downloadRange() {
if (!range?.data) return
setDownloading("range")
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
} }
} }
@@ -313,7 +241,6 @@ export default function DayEndReportPage() {
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1. // Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
const salesPage = useClientPagination(sales, date) const salesPage = useClientPagination(sales, date)
const expensePage = useClientPagination(expenses, date) const expensePage = useClientPagination(expenses, date)
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -324,7 +251,7 @@ export default function DayEndReportPage() {
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1> <h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
A day&apos;s sales, how they were paid, its expenses and the resulting income — plus income day by day. A day&apos;s sales, how they were paid, its expenses and the resulting income.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>} {branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p> </p>
</div> </div>
@@ -332,15 +259,23 @@ export default function DayEndReportPage() {
{/* --- One day ------------------------------------------------------------------------- */} {/* --- One day ------------------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]"> <section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> {/* Date on the right, above the Day-wise income / PDF buttons. */}
<div className="flex flex-col gap-2"> <div className="flex flex-col items-end gap-3">
<Label htmlFor="der-date" className="text-base">Business date</Label> {/* Fixed, not editable: today, or the day opened from Day-wise Income (?date=). */}
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" /> <div className="flex flex-col items-end gap-1">
<span className="text-sm text-muted-foreground">Business date</span>
<span className="text-lg font-semibold text-foreground tabular-nums">{formatReportDate(date)}</span>
</div>
<div 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> </div>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
</Button>
</div> </div>
{day?.error && <p className="text-sm text-destructive">{day.error}</p>} {day?.error && <p className="text-sm text-destructive">{day.error}</p>}
@@ -354,29 +289,20 @@ export default function DayEndReportPage() {
{s && ( {s && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Sales</h2> {/* Each amount card carries its sale count as the hint line, instead of a separate count card. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} /> <StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} hint={salesCountLabel(s.salesCount)} />
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<StatCard <StatCard
label="Income" label="Paid amount"
value={formatSignedAmount(s.income)} value={formatAmount(s.paidAmount)}
icon={s.income >= 0 ? TrendingUp : TrendingDown} icon={Wallet}
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"} hint={`${salesCountLabel(s.salesCount - s.unpaidSalesCount)} paid`}
className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
/> />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} hint={`${salesCountLabel(s.cashSalesCount)} by cash`} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} hint={`${salesCountLabel(s.cardSalesCount)} by card`} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} hint={`${salesCountLabel(s.unpaidSalesCount)} not paid`} />
</div> </div>
{/* Expenses and income cards live on Day-wise Income (Sales → Day-wise Income). */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque {s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "} ({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
@@ -566,172 +492,6 @@ export default function DayEndReportPage() {
</> </>
)} )}
</section> </section>
{/* --- Day-wise income ----------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
</div>
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-from">From</Label>
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-to">To</Label>
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} />
</div>
{/* Phones: one card per day (newest first), tap to show that day above. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => d.date && setDate(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div> </div>
) )
} }
@@ -65,12 +65,13 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
type PaymentFilter = "All" | "Unpaid" | "Paid" type PaymentFilter = "All" | "Unpaid" | "Paid" | "Overdue"
const paymentFilters: { value: PaymentFilter; label: string }[] = [ const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "All", label: "All payments" }, { value: "All", label: "All payments" },
{ value: "Unpaid", label: "Balance due" }, { value: "Unpaid", label: "Balance due" },
{ value: "Paid", label: "Fully paid" }, { value: "Paid", label: "Fully paid" },
{ value: "Overdue", label: "Overdue" },
] ]
export default function ApprovedInvoicesPage() { export default function ApprovedInvoicesPage() {
@@ -155,7 +156,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom, from: salesFrom,
to: salesTo, to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid", paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined, q: salesQuery || undefined,
}) })
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null })) .then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
@@ -189,7 +191,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom, from: salesFrom,
to: salesTo, to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid", paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined, q: salesQuery || undefined,
}) })
.then((data) => setTotalsResult({ key: totalsKey, data })) .then((data) => setTotalsResult({ key: totalsKey, data }))
@@ -265,6 +268,8 @@ function ApprovedInvoices() {
<InvoiceTotalsCards <InvoiceTotalsCards
totals={cardTotals} totals={cardTotals}
payment={salesPayment}
onPaymentChange={(next) => changeSalesPayment(salesPayment === next ? "All" : next)}
pageOnly={pageOnly} pageOnly={pageOnly}
scope={[ scope={[
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"), salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
@@ -662,8 +667,15 @@ const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow
* `undefined` = loading. Same StatCard as the other screens' KPI rows. * `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/ */
function InvoiceTotalsCards({ function InvoiceTotalsCards({
totals, scope, pageOnly, totals, scope, pageOnly, payment, onPaymentChange,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) { }: {
totals: SalesInvoiceListTotals | null | undefined
scope: string
pageOnly: boolean
/** The payment filter the cards toggle — clicking a card filters the list (again = clear). */
payment: PaymentFilter
onPaymentChange: (next: PaymentFilter) => void
}) {
if (totals === undefined) { if (totals === undefined) {
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@@ -682,9 +694,9 @@ function InvoiceTotalsCards({
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>} {pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
</p> </p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} /> <StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} onClick={() => onPaymentChange("All")} active={payment === "All"} />
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" /> <StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" /> <StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" onClick={() => onPaymentChange("Paid")} active={payment === "Paid"} />
<StatCard <StatCard
label="Balance due" label="Balance due"
value={formatAmount(totals.balanceTotal)} value={formatAmount(totals.balanceTotal)}
@@ -692,6 +704,8 @@ function InvoiceTotalsCards({
hint="Still to collect" hint="Still to collect"
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined} className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined} iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
onClick={() => onPaymentChange("Unpaid")}
active={payment === "Unpaid"}
/> />
<StatCard <StatCard
label="Overdue" label="Overdue"
@@ -700,6 +714,8 @@ function InvoiceTotalsCards({
hint="Past due date, not fully paid" hint="Past due date, not fully paid"
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")} className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined} iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
onClick={() => onPaymentChange("Overdue")}
active={payment === "Overdue"}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,277 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, FileDown, Receipt, ReceiptText, TrendingDown, TrendingUp } from "lucide-react"
import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { DayEndDailyReport } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { IncomeCell, KV, localIso, mobileCard, ProfitCell } from "@/components/sales/day-end-report-parts"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null }
/**
* Day-wise income (Sales → Day-wise Income): sales, expenses, income and profit/loss per day over a range
* (≤ 92 days), with a chart and a PDF. Clicking a day opens that day in the Day-End Report.
*/
export default function DayWiseIncomePage() {
const router = useRouter()
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [from, setFrom] = useState(localIso(-29))
const [to, setTo] = useState(localIso())
const [downloading, setDownloading] = useState(false)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadRange() {
if (!range?.data) return
setDownloading(true)
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(false)
}
}
function openDay(date: string | null | undefined) {
if (date) router.push(`/dashboard/sales/day-end-report?date=${date}`)
}
// Paged in the browser (the report returns whole lists); a new range starts on page 1.
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day-wise Income</h1>
<p className="text-base text-muted-foreground">
Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days). Click a day to open its Day-End Report.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p>
</div>
</div>
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap sm:justify-end">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dwi-from">From</Label>
<Input id="dwi-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dwi-to">To</Label>
<Input id="dwi-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading}>
<FileDown className="size-4" />
{downloading ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard
label="Sales"
value={formatAmount(range.data.totals.totalSales)}
icon={ReceiptText}
hint={`${range.data.totals.salesCount} sale${range.data.totals.salesCount === 1 ? "" : "s"}`}
/>
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} />
</div>
{/* Phones: one card per day (newest first), tap to open that day's report. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => openDay(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => openDay(d.date)} title="Open this day's Day-End Report">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div>
)
}
@@ -0,0 +1,10 @@
"use client"
import { useParams } from "next/navigation"
import { FreeIssueForm } from "@/components/sales/FreeIssueForm"
export default function EditFreeIssuePage() {
const { id } = useParams<{ id: string }>()
return <FreeIssueForm salesSlipId={Number(id)} />
}
@@ -1,580 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { FreeIssueForm } from "@/components/sales/FreeIssueForm"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateSalesSlipLineRequest & { key: string }
type FreeIssueRow = {
salesSlipId: number
slipNo: string
status: string
etag: string
warehouseName: string
itemName: string
itemSku: string
uomName: string
qty: number
freeQty: number
}
const blankLine = (key: string): Line => ({
key,
itemId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
export default function NewFreeIssuePage() { export default function NewFreeIssuePage() {
const [customers, setCustomers] = useState<Customer[]>([]) return <FreeIssueForm />
const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [editingRowId, setEditingRowId] = useState<number | null>(null)
const [editingLines, setEditingLines] = useState<Line[]>([])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows() {
const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = items.find((x) => x.itemId === firstLine?.itemId)
const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
slipNo: detail.data.slipNo,
status: detail.data.status,
etag: detail.etag ?? "",
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: baseUomLabel(items, uoms, firstLine?.itemId),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
}),
)
setRows(details.filter((row) => row.status !== "Cancelled"))
}
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(async ([cust, itemRes, uomRes, whRes, userRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
// No item pre-selected — the user searches for one.
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
}, [page, pageSize])
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function updateEditingLine(key: string, patch: Partial<Line>) {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
[items, serialNumbersEnabled]
)
function selectItem(key: string, itemId: number) {
updateLine(key, { itemId })
}
function selectEditingItem(key: string, itemId: number) {
updateEditingLine(key, { itemId })
}
async function submit() {
const activeLines = editingRowId ? editingLines : lines
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
setSaving(true)
setError(null)
try {
const payload: CreateSalesSlipRequest = {
customerId,
warehouseId,
cashierUserId,
lines: activeLines.map((line) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId ?? null,
})),
}
if (editingRowId) {
const latest = await salesApi.getFreeIssue(editingRowId)
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
await salesApi.updateFreeIssue(editingRowId, payload, latest.etag)
toast.success("Free issue updated")
setEditingRowId(null)
setEditingLines([])
} else {
const created = await salesApi.createFreeIssue(payload)
toast.success("Free issue created", created.data.slipNo)
}
setLines([blankLine("line-1")])
setFormOpen(false)
await refreshRows()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
async function startEdit(row: FreeIssueRow) {
try {
const detail = await salesApi.getFreeIssue(row.salesSlipId)
const detailLine = detail.data.lines[0]
setEditingRowId(row.salesSlipId)
setCustomerId(detail.data.customerId)
setWarehouseId(detail.data.warehouseId)
setCashierUserId(detail.data.cashierUserId)
setEditingLines([
{
key: "edit-line-1",
itemId: detailLine?.itemId ?? 0,
warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId,
qty: detailLine?.qty ?? 1,
freeQty: detailLine?.freeQty ?? 0,
unitPrice: detailLine?.unitPrice ?? null,
allowManualPriceOverride: true,
discountMode: detailLine?.discountMode ?? "Percentage",
discountPct: detailLine?.discountPct ?? 0,
discountAmount: detailLine?.discountAmount ?? 0,
discountValue: 0,
taxPct: detailLine?.taxPct ?? 0,
isFreeIssue: detailLine?.isFreeIssue ?? false,
parentLineId: detailLine?.parentLineId ?? null,
},
])
setFormOpen(true)
} catch (err) {
setError(errorMessage(err))
}
}
function cancelEdit() {
setEditingRowId(null)
setEditingLines([])
}
function openCreateForm() {
cancelEdit()
setError(null)
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) cancelEdit()
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) {
cancelEdit()
setFormOpen(false)
}
await refreshRows()
} catch (err) {
setError(errorMessage(err))
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
const activeLines = editingRowId ? editingLines : lines
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Free Issues</h1>
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div>
</div>
<Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" />
New Free Issue
</Button>
}
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>{editingRowId ? "Edit free issue" : "New free issue"}</DialogTitle>
<DialogDescription>Pick a customer, warehouse and cashier, then add the item lines.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="fi-customer">Customer</FieldLabel>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger id="fi-customer" className="w-full"><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-warehouse">Warehouse</FieldLabel>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger id="fi-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-cashier">Cashier</FieldLabel>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger id="fi-cashier" className="w-full"><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
{activeLines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
{line.itemId ? (
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
<span className="truncate font-medium">
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
</span>
<button
type="button"
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
Change
</button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
placeholder="Search item by SKU or name…"
emptyText="No items match."
aria-label="Item"
/>
)}
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
<Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor={`fi-qty-${line.key}`}>Qty</FieldLabel>
<Input
id={`fi-qty-${line.key}`}
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { qty: Number(e.target.value) }) : updateLine(line.key, { qty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`fi-free-${line.key}`}>Free</FieldLabel>
<Input
id={`fi-free-${line.key}`}
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => (editingRowId ? updateEditingLine(line.key, { freeQty: Number(e.target.value) }) : updateLine(line.key, { freeQty: Number(e.target.value) }))}
className="font-mono tabular-nums"
/>
</Field>
</FieldGroup>
))}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
Refresh
</Button>
</div>
{rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section>
</div>
)
} }
@@ -1,3 +1,285 @@
"use client" "use client"
export { default } from "./new/page" import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type FreeIssueRow = {
salesSlipId: number
slipNo: string
status: string
etag: string
warehouseName: string
itemName: string
itemSku: string
uomName: string
qty: number
freeQty: number
}
/** Free Issues list (Sales → Free Issues). New and Edit open full pages (components/sales/FreeIssueForm). */
export default function FreeIssuesPage() {
const router = useRouter()
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows(masters?: { items: ItemListItem[]; uoms: Uom[]; warehouses: Warehouse[] }) {
const m = masters ?? { items, uoms, warehouses }
const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all(
list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
const firstLine = detail.data.lines[0]
const item = m.items.find((x) => x.itemId === firstLine?.itemId)
const warehouse = m.warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
return {
salesSlipId: detail.data.salesSlipId,
slipNo: detail.data.slipNo,
status: detail.data.status,
etag: detail.etag ?? "",
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
itemName: item?.name ?? firstLine?.description ?? "—",
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
uomName: baseUomLabel(m.items, m.uoms, firstLine?.itemId),
qty: firstLine?.qty ?? 0,
freeQty: firstLine?.freeQty ?? 0,
} satisfies FreeIssueRow
}),
)
setRows(details.filter((row) => row.status !== "Cancelled"))
}
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(async ([itemRes, uomRes, whRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
await refreshRows({ items: itemRes.items, uoms: uomRes.items, warehouses: whRes.items })
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize])
function startEdit(row: FreeIssueRow) {
router.push(`/dashboard/sales/free-issues/${row.salesSlipId}/edit`)
}
async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return
try {
await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo)
await refreshRows()
} catch (err) {
setError(errorMessage(err))
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading free issues...</div>
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Free Issues</h1>
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div>
</div>
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Free Issue
</Link>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div>
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={() => refreshRows()}>
Refresh
</Button>
</div>
{rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Promotion</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="w-40 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableCell>
<div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section>
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link" import Link from "next/link"
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, Send, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [ const cards = [
{ {
@@ -56,6 +56,12 @@ const cards = [
icon: LayoutTemplate, icon: LayoutTemplate,
description: "Logo, footer, terms, and template style for printed invoices", description: "Logo, footer, terms, and template style for printed invoices",
}, },
{
title: "Day-End Owner Report",
href: "/dashboard/settings/owner-report",
icon: Send,
description: "Send the day's revenue to the owner by SMS and email",
},
] ]
export default function ConfigurationPage() { export default function ConfigurationPage() {
@@ -0,0 +1,524 @@
"use client"
import { useEffect, useState } from "react"
import { Clock, Eye, Mail, MessageSquare, Plus, Send, Trash2, Users } from "lucide-react"
import { ownerReportApi } from "@/lib/api/owner-report"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
import { PaginationMeta } from "@/types/common"
import { OwnerReportConfig, OwnerReportDispatch, OwnerReportPreview, OwnerReportRecipientInput } from "@/types/owner-report"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
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 { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RECIPIENTS = 5
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PANEL = "flex flex-col gap-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
/** Editable copy of the settings — saved together with one PUT (the recipients are a list). */
type Draft = {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: (OwnerReportRecipientInput & { key: number })[]
}
function toDraft(c: OwnerReportConfig): Draft {
return {
enabled: c.enabled,
smsEnabled: c.smsEnabled,
emailEnabled: c.emailEnabled,
sendAtLocalTime: c.sendAtLocalTime,
includeBranchBreakdown: c.includeBranchBreakdown,
recipients: c.recipients.map((r) => ({ ...r, key: r.recipientId })),
}
}
function localToday(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Per-recipient field errors, keyed `${key}-name|mobile|email`. */
function validate(draft: Draft): Record<string, string> {
const errors: Record<string, string> = {}
for (const r of draft.recipients) {
if (!r.name.trim()) errors[`${r.key}-name`] = "Enter a name"
const mobile = r.mobile?.trim() ?? ""
const email = r.email?.trim() ?? ""
if (!mobile && !email) errors[`${r.key}-mobile`] = "Add a mobile number, an email or both"
const pe = mobile ? phoneError(mobile) : null
if (pe) errors[`${r.key}-mobile`] = pe
if (email && !EMAIL_PATTERN.test(email)) errors[`${r.key}-email`] = "Enter a valid email address"
}
if (draft.enabled && !draft.recipients.some((r) => r.isActive)) errors.recipients = "Add at least one active recipient before switching the report on"
if (draft.enabled && !draft.smsEnabled && !draft.emailEnabled) errors.channels = "Switch on SMS, email or both"
return errors
}
/**
* Day-End Owner Report — sends the day's revenue and details to the owner by SMS and email at a
* set time (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through the auth service's own
* SMTP / SMS settings, so nothing here holds mail credentials.
*/
export default function OwnerReportSettingsPage() {
const [config, setConfig] = useState<OwnerReportConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [draft, setDraft] = useState<Draft | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [nextKey, setNextKey] = useState(-1)
const [previewDate, setPreviewDate] = useState(localToday())
const [preview, setPreview] = useState<OwnerReportPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [testing, setTesting] = useState(false)
const [log, setLog] = useState<OwnerReportDispatch[] | null>(null)
const [logPagination, setLogPagination] = useState<PaginationMeta | null>(null)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const [logReload, setLogReload] = useState(0)
function load() {
ownerReportApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
setErrors({})
})
.catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the owner report settings", errorMessage(err))
})
}
useEffect(load, [])
useEffect(() => {
ownerReportApi
.dispatches({ page: logPage, pageSize: logPageSize })
.then((res) => {
setLog(res.items)
setLogPagination(res.pagination)
})
.catch((err) => {
setLog([])
toast.error("Could not load the send log", errorMessage(err))
})
}, [logPage, logPageSize, logReload])
const dirty = config !== null && draft !== null && JSON.stringify(toDraft(config)) !== JSON.stringify(draft)
function patch(next: Partial<Draft>) {
setDraft((d) => (d ? { ...d, ...next } : d))
}
function patchRecipient(key: number, next: Partial<OwnerReportRecipientInput>) {
setDraft((d) => (d ? { ...d, recipients: d.recipients.map((r) => (r.key === key ? { ...r, ...next } : r)) } : d))
setErrors((e) => {
const copy = { ...e }
for (const field of Object.keys(next)) delete copy[`${key}-${field}`]
delete copy.recipients
return copy
})
}
function addRecipient() {
if (!draft || draft.recipients.length >= MAX_RECIPIENTS) return
patch({ recipients: [...draft.recipients, { key: nextKey, recipientId: null, name: draft.recipients.length === 0 ? "Owner" : "", mobile: "", email: "", isActive: true }] })
setNextKey((k) => k - 1)
}
async function save() {
if (!draft) return
const nextErrors = validate(draft)
setErrors(nextErrors)
const messages = [...new Set(Object.values(nextErrors))]
if (messages.length > 0) {
toast.error("Please fix the highlighted fields", messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`)
return
}
setSaving(true)
try {
const res = await ownerReportApi.update(
{
enabled: draft.enabled,
smsEnabled: draft.smsEnabled,
emailEnabled: draft.emailEnabled,
sendAtLocalTime: draft.sendAtLocalTime,
includeBranchBreakdown: draft.includeBranchBreakdown,
recipients: draft.recipients.map((r) => ({
recipientId: r.recipientId,
name: r.name.trim(),
mobile: r.mobile?.trim() || null,
email: r.email?.trim() || null,
isActive: r.isActive,
})),
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
toast.success("Owner report saved", res.data.enabled ? `It will be sent daily at ${res.data.sendAtLocalTime}.` : "The daily report is switched off.")
} catch (err) {
toast.error("Could not save the owner report settings", errorMessage(err))
} finally {
setSaving(false)
}
}
async function loadPreview() {
setPreviewLoading(true)
try {
setPreview(await ownerReportApi.preview(previewDate))
} catch (err) {
toast.error("Could not build the preview", errorMessage(err))
} finally {
setPreviewLoading(false)
}
}
async function sendTest() {
setTesting(true)
try {
const results = await ownerReportApi.sendTest(previewDate)
const failed = results.filter((r) => !r.ok)
if (failed.length === 0) toast.success("Test sent", `${results.length} message${results.length === 1 ? "" : "s"} sent.`)
else toast.error(`${failed.length} of ${results.length} messages failed`, failed.map((f) => `${f.destination}: ${f.error ?? "failed"}`).join(" · "))
setTestOpen(false)
setLogPage(1)
setLogReload((n) => n + 1)
} catch (err) {
toast.error("Could not send the test", errorMessage(err))
} finally {
setTesting(false)
}
}
return (
<div className="flex flex-col gap-4 pb-24 sm:gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Day-End Owner Report</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Send the day&apos;s revenue and details to the owner by SMS and email, automatically every evening.
</p>
</div>
{loadError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{loadError}</div>}
{!loadError && !draft && <Skeleton className="h-64 w-full rounded-2xl" />}
{draft && config && (
<>
{/* Delivery */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Clock className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Delivery</h2>
</div>
<ToggleRow
checked={draft.enabled}
onChange={(v) => patch({ enabled: v })}
label="Send the daily report"
help="Off: nothing is sent automatically (you can still send a test)."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ToggleRow checked={draft.smsEnabled} onChange={(v) => patch({ smsEnabled: v })} label="SMS" help="A short revenue summary." icon={MessageSquare} />
<ToggleRow checked={draft.emailEnabled} onChange={(v) => patch({ emailEnabled: v })} label="Email" help="The full details: summary, cash, top sales and expenses." icon={Mail} />
</div>
{errors.channels && <FieldError errors={[{ message: errors.channels }]} />}
<ToggleRow
checked={draft.includeBranchBreakdown}
onChange={(v) => patch({ includeBranchBreakdown: v })}
label="Include a per-branch breakdown"
help="Adds one row per branch to the email when branches are switched on."
/>
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label htmlFor="or-time" className="text-base">Send time</Label>
<Input
id="or-time"
type="time"
value={draft.sendAtLocalTime}
onChange={(e) => patch({ sendAtLocalTime: e.target.value })}
className="h-11 text-base"
/>
<p className="text-xs text-muted-foreground">Time zone: {config.timeZone}. If the server is down then, it sends as soon as it is back the same day.</p>
</div>
</section>
{/* Recipients */}
<section className={PANEL}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Users className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Recipients</h2>
<span className="text-sm text-muted-foreground">({draft.recipients.length}/{MAX_RECIPIENTS})</span>
</div>
<Button type="button" variant="outline" className="h-10" onClick={addRecipient} disabled={draft.recipients.length >= MAX_RECIPIENTS}>
<Plus className="size-4" />
Add recipient
</Button>
</div>
{errors.recipients && <FieldError errors={[{ message: errors.recipients }]} />}
{draft.recipients.length === 0 ? (
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
No recipients yet — add the owner&apos;s mobile number and email.
</p>
) : (
<ul className="flex flex-col gap-3">
{draft.recipients.map((r) => (
<li key={r.key} className={cn("grid grid-cols-1 gap-3 rounded-xl border p-3 sm:grid-cols-[1fr_1fr_1.4fr_auto] sm:items-start", !r.isActive && "opacity-60")}>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Name</Label>
<Input value={r.name} maxLength={100} onChange={(e) => patchRecipient(r.key, { name: e.target.value })} placeholder="Owner" aria-invalid={!!errors[`${r.key}-name`]} className="h-11 text-base sm:h-10" />
<FieldError errors={[errors[`${r.key}-name`] ? { message: errors[`${r.key}-name`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Mobile (SMS)</Label>
<Input
type="tel"
inputMode="tel"
maxLength={20}
value={r.mobile ?? ""}
onChange={(e) => patchRecipient(r.key, { mobile: limitPhoneInput(e.target.value) })}
placeholder="0771234567"
aria-invalid={!!errors[`${r.key}-mobile`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-mobile`] ? { message: errors[`${r.key}-mobile`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Email</Label>
<Input
type="email"
inputMode="email"
maxLength={200}
value={r.email ?? ""}
onChange={(e) => patchRecipient(r.key, { email: e.target.value })}
placeholder="owner@company.com"
aria-invalid={!!errors[`${r.key}-email`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-email`] ? { message: errors[`${r.key}-email`] } : undefined]} />
</div>
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:pt-5">
<label className="flex items-center gap-2 text-sm">
<Switch checked={r.isActive} onCheckedChange={(v) => patchRecipient(r.key, { isActive: v })} aria-label={`${r.name || "Recipient"} active`} />
Active
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => patch({ recipients: draft.recipients.filter((x) => x.key !== r.key) })}
aria-label={`Remove ${r.name || "recipient"}`}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{/* Preview & test */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Eye className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Preview &amp; test</h2>
</div>
<div className="grid grid-cols-2 items-end gap-2 sm:flex sm:flex-wrap sm:gap-3">
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
<Label htmlFor="or-date" className="text-xs text-muted-foreground">Day</Label>
<Input id="or-date" type="date" value={previewDate} max={localToday()} onChange={(e) => setPreviewDate(e.target.value)} className="h-11 text-base sm:h-10 sm:w-44" />
</div>
<Button type="button" variant="outline" className="h-11 sm:h-10" onClick={loadPreview} disabled={previewLoading}>
<Eye className="size-4" />
{previewLoading ? "Building…" : "Preview"}
</Button>
<Button
type="button"
className="h-11 sm:h-10"
onClick={() => {
if (dirty) {
toast.error("Save your changes first", "The test is sent to the saved recipients.")
return
}
setTestOpen(true)
}}
>
<Send className="size-4" />
Send test now
</Button>
</div>
{preview && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,18rem)_1fr]">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">SMS</p>
<div className="rounded-2xl rounded-tl-sm bg-muted px-4 py-3 font-mono text-sm leading-relaxed whitespace-pre-wrap">{preview.smsText}</div>
<p className="text-xs text-muted-foreground">{preview.smsText.length} characters</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
<p className="text-sm font-medium text-foreground">Email — {preview.emailSubject}</p>
{/* Sandboxed: the HTML is our own template, but it never gets scripts or same-origin access. */}
<iframe title="Email preview" sandbox="" srcDoc={preview.emailHtml} className="h-112 w-full rounded-xl border bg-white" />
</div>
</div>
)}
</section>
{/* Send log */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Send className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Send log</h2>
</div>
{log === null ? (
<Skeleton className="h-24 w-full" />
) : log.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">Nothing sent yet.</p>
) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{log.map((d) => (
<li key={d.dispatchId} className="flex flex-col gap-1 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{d.businessDate} · {d.channel === "Sms" ? "SMS" : "Email"}</span>
<StatusBadge status={d.status} isTest={d.isTest} />
</div>
<span className="truncate text-muted-foreground">{d.recipientName} — {d.destination}</span>
{d.error && <span className="text-xs text-destructive">{d.error}</span>}
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead>Day</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent at</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{log.map((d) => (
<TableRow key={d.dispatchId}>
<TableCell>{d.businessDate}</TableCell>
<TableCell>{d.channel === "Sms" ? "SMS" : "Email"}</TableCell>
<TableCell>
{d.recipientName}
<span className="block text-xs text-muted-foreground">{d.destination}</span>
</TableCell>
<TableCell><StatusBadge status={d.status} isTest={d.isTest} /></TableCell>
<TableCell>{d.sentAt ? new Date(d.sentAt + (d.sentAt.endsWith("Z") ? "" : "Z")).toLocaleString() : "—"}</TableCell>
<TableCell className="max-w-64 truncate text-destructive" title={d.error ?? undefined}>{d.error ?? ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{logPagination && logPagination.totalItems > 10 && (
<PaginationFooter
pagination={logPagination}
onPageChange={setLogPage}
pageSizeOptions={[10, 25, 50]}
onPageSizeChange={(size) => {
setLogPageSize(size)
setLogPage(1)
}}
/>
)}
</>
)}
</section>
{/* Save bar — sticky so it stays reachable on a long page / phone. */}
<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 sm:flex-row sm:items-center sm:justify-end">
{dirty && <p className="text-center text-sm text-warning sm:mr-auto sm:text-left">You have unsaved changes.</p>}
<Button type="button" variant="outline" size="lg" className="w-full sm:w-auto" disabled={!dirty || saving} onClick={() => { setDraft(toDraft(config)); setErrors({}) }}>
Discard
</Button>
<Button type="button" size="lg" className="w-full sm:w-auto sm:min-w-40" disabled={!dirty || saving} onClick={save}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</>
)}
<Dialog open={testOpen} onOpenChange={(open) => !testing && setTestOpen(open)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send a test now?</DialogTitle>
<DialogDescription>
The report for {previewDate} will be sent right away to every active recipient
{config ? ` (${[config.smsEnabled && "SMS", config.emailEnabled && "email"].filter(Boolean).join(" + ")})` : ""}. It is marked as a test in the send log.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => setTestOpen(false)} disabled={testing}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={sendTest} disabled={testing}>
{testing ? "Sending…" : "Send test"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ToggleRow({
checked, onChange, label, help, icon: Icon,
}: { checked: boolean; onChange: (v: boolean) => void; label: string; help: string; icon?: typeof Mail }) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-xl border p-3 hover:bg-muted/30">
<Switch checked={checked} onCheckedChange={onChange} className="mt-0.5 shrink-0" aria-label={label} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{label}
</span>
<span className="text-xs text-muted-foreground">{help}</span>
</span>
</label>
)
}
function StatusBadge({ status, isTest }: { status: OwnerReportDispatch["status"]; isTest: boolean }) {
const tone =
status === "Sent" ? "border-success/30 bg-success/10 text-success" : status === "Failed" ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-warning/30 bg-warning/10 text-warning"
return (
<span className="flex items-center gap-1.5">
<Badge variant="outline" className={cn("text-xs", tone)}>{status}</Badge>
{isTest && <Badge variant="outline" className="text-xs">Test</Badge>}
</span>
)
}
@@ -48,10 +48,23 @@ export default function UsersPage() {
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null) const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by clicking a stat card (click again to clear) — narrows the list to that group.
const [cardFilter, setCardFilter] = useState<"All" | "Active" | "Inactive" | "Admins">("All")
function toggleCardFilter(next: "Active" | "Inactive" | "Admins") {
setCardFilter((current) => (current === next ? "All" : next))
setPage(1)
}
function load() { function load() {
usersApi usersApi
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({
page,
pageSize,
q: debouncedSearch || undefined,
status: cardFilter === "Active" || cardFilter === "Inactive" ? cardFilter : undefined,
isAdmin: cardFilter === "Admins" ? true : undefined,
})
.then((res) => { .then((res) => {
setUsers(res.items) setUsers(res.items)
setPagination(res.pagination) setPagination(res.pagination)
@@ -65,7 +78,7 @@ export default function UsersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) useEffect(load, [page, pageSize, debouncedSearch, cardFilter])
function loadStats() { function loadStats() {
Promise.all([ Promise.all([
@@ -138,10 +151,10 @@ export default function UsersPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total users" value={stats.total} icon={UsersIcon} /> <StatCard label="Total users" value={stats.total} icon={UsersIcon} onClick={() => { setCardFilter("All"); setPage(1) }} active={cardFilter === "All"} />
<StatCard label="Active" value={stats.active} icon={UserCheck} /> <StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => toggleCardFilter("Active")} active={cardFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} onClick={() => toggleCardFilter("Inactive")} active={cardFilter === "Inactive"} />
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} /> <StatCard label="Admins" value={stats.admins} icon={ShieldCheck} onClick={() => toggleCardFilter("Admins")} active={cardFilter === "Admins"} />
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -43,25 +43,27 @@ export default function CountsListPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by the "Pending Counts" tile (click again to clear). "Pending" = Counted, awaiting posting (DashboardService).
const [pendingOnly, setPendingOnly] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query]) useEffect(() => setPage(1), [query, pendingOnly])
useEffect(() => { useEffect(() => {
setError(null) setError(null)
stockCountsApi stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }) .list({ page, pageSize, q: query || undefined, status: pendingOnly ? "Counted" : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => { .then((res) => {
setCounts(res.items) setCounts(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false)) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled]) }, [page, pageSize, query, pendingOnly, activeBranchId, branchingEnabled])
function handleShowMore() { function handleShowMore() {
setShowMoreLoading(true) setShowMoreLoading(true)
@@ -73,7 +75,7 @@ export default function CountsListPage() {
}, []) }, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0 const hasFilters = query.length > 0 || pendingOnly
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -90,7 +92,7 @@ export default function CountsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} /> <KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList, onClick: () => setPendingOnly((v) => !v), active: pendingOnly }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0"> <div className="relative flex-1 basis-0">
+14 -158
View File
@@ -9,7 +9,6 @@ import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data" import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
@@ -19,15 +18,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -35,14 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog" import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
import { useProcurementConfig } from "@/hooks/use-procurement-config" import { useProcurementConfig } from "@/hooks/use-procurement-config"
import { import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
@@ -53,10 +36,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function VendorsPage() { export default function VendorsPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null) const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -67,14 +49,7 @@ export default function VendorsPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig() const { config: procConfig } = useProcurementConfig()
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Separate from the paginated table list above — this needs every existing code (up to the // Separate from the paginated table list above — this needs every existing code (up to the
// server's page-size cap) to de-dupe against, not just the current page's 5 rows. // server's page-size cap) to de-dupe against, not just the current page's 5 rows.
@@ -85,8 +60,6 @@ export default function VendorsPage() {
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
@@ -131,56 +104,6 @@ export default function VendorsPage() {
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setTerms("")
setTaxReg("")
setCurrency("LKR")
setOptional(EMPTY_VENDOR_OPTIONAL)
setBranchFilterId(null)
setErrors({})
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
load()
setAllVendorCodes((codes) => [...codes, result.data.code])
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
// Hard delete. The server refuses (409) a vendor any purchase document or item still // Hard delete. The server refuses (409) a vendor any purchase document or item still
// references — those can only be deactivated, from the detail page's status toggle. // references — those can only be deactivated, from the detail page's status toggle.
async function handleDelete(vendor: Vendor) { async function handleDelete(vendor: Vendor) {
@@ -218,85 +141,18 @@ export default function VendorsPage() {
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p> <p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Button size="lg" onClick={() => setOpen(true)}>
<DialogTrigger <Plus className="size-5" />
render={ New Vendor
<Button size="lg"> </Button>
<Plus className="size-5" /> <CreateVendorDialog
New Vendor open={open}
</Button> onOpenChange={setOpen}
} onCreated={(vendor) => {
/> load()
<DialogContent className="sm:max-w-md"> setAllVendorCodes((codes) => [...codes, vendor.code])
<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>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -33,6 +33,7 @@ import {
LayoutList, LayoutList,
LayoutTemplate, LayoutTemplate,
LineChart, LineChart,
CalendarRange,
ListTree, ListTree,
Lock, Lock,
MapPin, MapPin,
@@ -138,6 +139,8 @@ export const navItems: {
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock }, { title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart }, { title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// Split out of the Day-End Report page; same data and permission code, so no seeded NavItem of its own.
{ title: "Day-wise Income", code: "sales.day-end-report", href: "/dashboard/sales/day-wise-income", icon: CalendarRange },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, // { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
], ],
}, },
@@ -45,6 +45,7 @@ const SEGMENT_LABELS: Record<string, string> = {
runs: "Runs", runs: "Runs",
invoices: "Sales", invoices: "Sales",
"day-end": "Approved Invoices", "day-end": "Approved Invoices",
"owner-report": "Day-End Owner Report",
} }
function labelFor(segment: string): string { function labelFor(segment: string): string {
@@ -109,7 +109,9 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-wise-income") return "Day-wise Income"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices" if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM" if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes" if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
@@ -78,6 +78,8 @@ export function PurchaseOrderListView({
actionsHeader = "Actions", actionsHeader = "Actions",
emptyMessage, emptyMessage,
onTotalChange, onTotalChange,
status: controlledStatus,
onStatusChange,
}: { }: {
/** Pins the list to one status (the approval queue); hides the status filter. */ /** Pins the list to one status (the approval queue); hides the status filter. */
fixedStatus?: PurchaseOrderStatus fixedStatus?: PurchaseOrderStatus
@@ -88,6 +90,9 @@ export function PurchaseOrderListView({
emptyMessage?: ReactNode emptyMessage?: ReactNode
/** Total rows matching the current filters, e.g. for a count badge in the page header. */ /** Total rows matching the current filters, e.g. for a count badge in the page header. */
onTotalChange?: (total: number | null) => void onTotalChange?: (total: number | null) => void
/** Optional controlled status filter — lets the page drive it (e.g. from a stat card). */
status?: PurchaseOrderStatus | "All"
onStatusChange?: (status: PurchaseOrderStatus | "All") => void
}) { }) {
const router = useRouter() const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
@@ -103,7 +108,9 @@ export function PurchaseOrderListView({
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [innerStatus, setInnerStatus] = useState<StatusFilter>("All")
const status = controlledStatus ?? innerStatus
const setStatus = onStatusChange ?? setInnerStatus
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" }) const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
const [sortKey, setSortKey] = useState<SortKey>("created") const [sortKey, setSortKey] = useState<SortKey>("created")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc") const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
@@ -27,6 +27,7 @@ export function WarrantyFields({
onChange, onChange,
error, error,
disabled, disabled,
hideLabel,
}: { }: {
config: ProductConfig config: ProductConfig
/** Active rows of the Warranty Periods master, in months. */ /** Active rows of the Warranty Periods master, in months. */
@@ -36,6 +37,8 @@ export function WarrantyFields({
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
error?: string error?: string
disabled?: boolean disabled?: boolean
/** Leave out the "Warranty" label when the surrounding section already has that heading. */
hideLabel?: boolean
}) { }) {
const groups = warrantyPeriodGroups(config, customMonths) const groups = warrantyPeriodGroups(config, customMonths)
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier). // The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
@@ -71,7 +74,7 @@ export function WarrantyFields({
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label className="text-base">Warranty</Label> {!hideLabel && <Label className="text-base">Warranty</Label>}
<RadioGroup <RadioGroup
value={warranty} value={warranty}
onValueChange={(v) => { onValueChange={(v) => {
@@ -0,0 +1,303 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Save } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { SuggestInput } from "@/components/ui/suggest-input"
import { toast } from "@/components/ui/toast"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
type Line = CreateSalesSlipLineRequest & { key: string }
const blankLine = (warehouseId = 0): Line => ({
key: "line-1",
itemId: 0,
warehouseId,
qty: 1,
freeQty: 0,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
const LIST_HREF = "/dashboard/sales/free-issues"
/** 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"
/**
* New / Edit free issue as a full page (Sales → Free Issues). A free issue is one "buy X get Y free" line for a
* customer, warehouse and cashier. Pass `salesSlipId` to edit an existing one.
*/
export function FreeIssueForm({ salesSlipId }: { salesSlipId?: number }) {
const router = useRouter()
const editing = salesSlipId !== undefined
const { serialNumbersEnabled } = useTrackingSettings()
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [line, setLine] = useState<Line>(blankLine())
const [slipNo, setSlipNo] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
editing ? salesApi.getFreeIssue(salesSlipId) : Promise.resolve(null),
])
.then(([cust, itemRes, uomRes, whRes, userRes, detail]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
if (detail) {
const d = detail.data
const first = d.lines[0]
setSlipNo(d.slipNo)
setCustomerId(d.customerId)
setWarehouseId(d.warehouseId)
setCashierUserId(d.cashierUserId)
setLine({
...blankLine(d.warehouseId),
itemId: first?.itemId ?? 0,
warehouseId: first?.warehouseId ?? d.warehouseId,
qty: first?.qty ?? 1,
freeQty: first?.freeQty ?? 0,
unitPrice: first?.unitPrice ?? null,
discountMode: first?.discountMode ?? "Percentage",
discountPct: first?.discountPct ?? 0,
discountAmount: first?.discountAmount ?? 0,
taxPct: first?.taxPct ?? 0,
isFreeIssue: first?.isFreeIssue ?? false,
parentLineId: first?.parentLineId ?? null,
})
} else {
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
// No item pre-selected — the user searches for one.
setLine(blankLine(whRes.items[0]?.warehouseId ?? 0))
}
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false))
}, [editing, salesSlipId])
const itemOptions = useMemo(
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
[items, serialNumbersEnabled]
)
function updateLine(patch: Partial<Line>) {
setLine((prev) => ({ ...prev, ...patch }))
}
async function submit() {
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
if (!line.itemId) return setError("Select an item.")
if (!(Number(line.qty) > 0)) return setError("Buy qty must be more than 0.")
if (!(Number(line.freeQty) > 0)) return setError("Free qty must be more than 0.")
setSaving(true)
setError(null)
try {
const payload: CreateSalesSlipRequest = {
customerId,
warehouseId,
cashierUserId,
lines: [
{
itemId: Number(line.itemId),
// The line follows the header warehouse — there's no per-line warehouse on this screen.
warehouseId,
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice === null || line.unitPrice === undefined ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId ?? null,
},
],
}
if (editing) {
const latest = await salesApi.getFreeIssue(salesSlipId)
if (!latest.etag) throw new Error("Missing ETag for free issue update.")
await salesApi.updateFreeIssue(salesSlipId, payload, latest.etag)
toast.success("Free issue updated", slipNo ?? undefined)
} else {
const created = await salesApi.createFreeIssue(payload)
toast.success("Free issue created", created.data.slipNo)
}
router.push(LIST_HREF)
} catch (err) {
setError(errorMessage(err))
toast.error(editing ? "Could not update free issue" : "Could not create free issue", errorMessage(err))
} finally {
setSaving(false)
}
}
const selectedLabel = itemOptions.find((o) => o.value === String(line.itemId))?.label
const uom = baseUomLabel(items, uoms, line.itemId)
return (
<div className="flex flex-col gap-5 sm:gap-6">
<div className="flex items-center gap-3">
<Link href={LIST_HREF} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to free issues">
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">{editing ? `Edit Free Issue${slipNo ? ` ${slipNo}` : ""}` : "New Free Issue"}</h1>
<p className="text-sm text-muted-foreground sm:text-base">Pick a customer, warehouse and cashier, then the item and its buy / free quantities.</p>
</div>
</div>
{loading && <Skeleton className="h-64 w-full" />}
{!loading && (
<>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Details</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="fi-customer" className="text-base" required>Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger id="fi-customer" className="h-12! w-full text-base"><SelectValue placeholder="Select customer" /></SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="fi-warehouse" className="text-base" required>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger id="fi-warehouse" className="h-12! w-full text-base"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="fi-cashier" className="text-base" required>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger id="fi-cashier" className="h-12! w-full text-base"><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)} className="text-base">{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Item</h2>
<p className="text-sm text-muted-foreground">Buy this many, get this many free.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base" required>Item</Label>
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
{line.itemId ? (
<div className="flex h-12 items-center justify-between gap-2 rounded-lg border border-input px-3 text-base">
<span className="truncate font-medium">{selectedLabel ?? `Item #${line.itemId}`}</span>
<button type="button" onClick={() => updateLine({ itemId: 0 })} className="shrink-0 text-sm font-medium text-primary hover:underline">
Change
</button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => updateLine({ itemId: Number(v) })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
aria-label="Item"
/>
)}
{uom && <p className="text-sm text-muted-foreground">Unit: {uom}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="fi-qty" className="text-base" required>Buy qty</Label>
<Input id="fi-qty" type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine({ qty: Number(e.target.value) })} className="h-12! font-mono text-base tabular-nums" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="fi-free" className="text-base" required>Free qty</Label>
<Input id="fi-free" type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine({ freeQty: Number(e.target.value) })} className="h-12! font-mono text-base tabular-nums" />
</div>
</div>
{line.itemId > 0 && Number(line.qty) > 0 && Number(line.freeQty) > 0 && (
<p className="rounded-lg border border-dashed px-4 py-3 text-sm">
Promotion: <span className="font-semibold">Buy {line.qty} Get {line.freeQty} free</span>
{selectedLabel && <span className="text-muted-foreground"> — {selectedLabel}</span>}
</p>
)}
</section>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{error}</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={LIST_HREF} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-5" />
{saving ? "Saving…" : editing ? "Update free issue" : "Create free issue"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,48 @@
import { formatSignedAmount, profitOrLoss } from "@/lib/format"
import { cn } from "@/lib/utils"
import { TableCell } from "@/components/ui/table"
/** Shared by the Day-End Report and Day-wise Income pages. */
/** Local yyyy-MM-dd, `offsetDays` from today. */
export function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
export function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
export function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
export function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
export const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
@@ -7,6 +7,9 @@ export interface KpiTileSpec {
label: string label: string
value: number value: number
icon?: LucideIcon icon?: LucideIcon
/** Makes the tile a filter toggle for the list below (see StatCard). */
onClick?: () => void
active?: boolean
} }
/** /**
@@ -20,7 +23,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4"> <div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
{tiles {tiles
? tiles.map((tile) => ( ? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} /> <StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} onClick={tile.onClick} active={tile.active} />
)) ))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)} : Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div> </div>
@@ -58,6 +58,10 @@ export interface StatCardProps {
valueClassName?: string valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */ /** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */
onClick?: () => void
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
} }
/** /**
@@ -76,18 +80,29 @@ export function StatCard({
className, className,
valueClassName, valueClassName,
iconClassName, iconClassName,
onClick,
active = false,
}: StatCardProps) { }: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0 const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0 const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive) const isGood = hasDelta && (invert ? !isPositive : isPositive)
// Clickable cards are real buttons (keyboard + screen reader); plain cards stay divs.
const Root = onClick ? "button" : "div"
return ( return (
// A size container: the card reads its *own* width, so it lays out the same whether a page puts // A size container: the card reads its *own* width, so it lays out the same whether a page puts
// it in a 2-up phone grid or a wide desktop row. // it in a 2-up phone grid or a wide desktop row.
<div <Root
{...(onClick
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})}
className={cn( className={cn(
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30", "@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
)} )}
> >
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the {/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
@@ -122,10 +137,11 @@ export function StatCard({
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>} {deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div> </div>
) : ( ) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p> // Wraps rather than truncating, so a hint like "12 sales by card" is never cut off in a narrow card.
hint && <p className="text-xs leading-snug wrap-break-word text-muted-foreground">{hint}</p>
)} )}
</div> </div>
</div> </div>
</div> </Root>
) )
} }
@@ -0,0 +1,200 @@
"use client"
import { useEffect, useState } from "react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
/** Currency isn't asked for on create; new vendors get this (the server requires one). Editable later on the vendor page. */
const DEFAULT_VENDOR_CURRENCY = "LKR"
/**
* The one "New vendor" modal — used by the Vendors list and by the New Purchase Order vendor picker's "+" button,
* so both create vendors the same way. The code is generated from the name (de-duped against every existing code),
* the optional fields follow Procurement Configuration, and the branch follows the header's branch switcher
* (an admin with no branch picked chooses one here).
*/
export function CreateVendorDialog({
open,
onOpenChange,
onCreated,
description = "Create a supplier record. Its code is generated from the name.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (vendor: Vendor) => void
description?: string
}) {
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const { config: procConfig } = useProcurementConfig()
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [pickedBranchId, setPickedBranchId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every existing code (up to the server's page-size cap), not just one page, so the generated code is unique.
const [existingCodes, setExistingCodes] = useState<string[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? pickedBranchId) : myBranchId) : null
const generatedCode = name.trim() ? generateVendorCode(name, existingCodes) : ""
useEffect(() => {
if (!open) return
vendorsApi.list({ pageSize: 200 }).then((res) => setExistingCodes(res.items.map((v) => v.code))).catch(() => {})
}, [open])
useEffect(() => {
if (open && showBranchPicker) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [open, showBranchPicker])
function reset() {
setName("")
setTerms("")
setTaxReg("")
setOptional(EMPTY_VENDOR_OPTIONAL)
setPickedBranchId(null)
setErrors({})
}
function close(next: boolean) {
onOpenChange(next)
if (!next) reset()
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedCode,
name,
...vendorTermsTaxPayload(procConfig, terms, taxReg),
currency: DEFAULT_VENDOR_CURRENCY,
...vendorOptionalPayload(procConfig, optional),
branchId: effectiveBranchId ?? 0,
})
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
onCreated(result.data)
close(false)
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cv-name" required>Name</FieldLabel>
<Input id="cv-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cv-code">Code (auto-generated)</FieldLabel>
<Input id="cv-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="cv-terms">Payment terms (optional)</FieldLabel>
<Input id="cv-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="cv-taxreg">Tax registration (optional)</FieldLabel>
<Input id="cv-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="cv"
errors={errors}
/>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="cv-branch" required>Branch</FieldLabel>
<Select<number | null> value={pickedBranchId} onValueChange={(v) => { setPickedBranchId(v); clearError("branch") }}>
<SelectTrigger id="cv-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,37 @@
// Day-end owner report settings: recipients, schedule, preview, test send and send log
// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5). Singleton settings — no id; saves need the ETag.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
OwnerReportConfig,
OwnerReportDispatch,
OwnerReportPreview,
OwnerReportSendResult,
UpdateOwnerReportConfigRequest,
} from "@/types/owner-report"
const BASE = "/settings/owner-report"
export const ownerReportApi = {
get(): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE)
},
update(request: UpdateOwnerReportConfigRequest, ifMatch: string): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE, { method: "PUT", body: request, ifMatch })
},
/** The report as it would be sent for `date` (yyyy-MM-dd; default today) — nothing is sent. */
preview(date?: string): Promise<OwnerReportPreview> {
return apiRequest<OwnerReportPreview>(`${BASE}/preview${buildQuery({ date })}`)
},
/** Sends the report now to every active recipient (logged as a test). One per minute. */
sendTest(date?: string): Promise<OwnerReportSendResult[]> {
return apiRequest<OwnerReportSendResult[]>(`${BASE}/test`, { method: "POST", body: { date: date || null } })
},
dispatches(params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<OwnerReportDispatch>> {
return apiRequest<PagedResponse<OwnerReportDispatch>>(`${BASE}/dispatches${buildQuery(params)}`)
},
}
+2
View File
@@ -40,6 +40,8 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
createdBy?: number createdBy?: number
/** true = fully paid, false = balance still due. */ /** true = fully paid, false = balance still due. */
paid?: boolean paid?: boolean
/** true = past its due date with a balance still owed. */
overdue?: boolean
/** Matches invoice number or customer name. */ /** Matches invoice number or customer name. */
q?: string q?: string
} }
+15 -13
View File
@@ -1,17 +1,18 @@
/** /**
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the * Sri Lankan phone number rule shared by master-data forms (warehouses, owner report, …). Mirrors
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would. * the backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
* *
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits * Accepted: 10 digits starting with 0 (0771234567, 011 234 5678), +94 followed by 9 digits
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits. * (+94771234567, +94 77 123 4567), or 94 followed by 9 digits without the "+" (94771234567 — the
* SMS gateway's own format). Spaces and hyphens are allowed between digits.
*/ */
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/ export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$/
/** Digit count allowed after the prefix's own digits, per format. */ /** Digit count allowed after the prefix's own digits, per format. */
const LOCAL_DIGITS = 10 // 0 + 9 const LOCAL_DIGITS = 10 // 0 + 9
const INTL_DIGITS = 11 // 94 + 9 const INTL_DIGITS = 11 // 94 + 9
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567" const PHONE_EXAMPLE = "e.g. 0771234567, 94771234567 or +94 77 123 4567"
/** /**
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting * Input filter for a phone field's onChange: drops disallowed characters and stops accepting
@@ -19,7 +20,7 @@ const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
*/ */
export function limitPhoneInput(raw: string): string { export function limitPhoneInput(raw: string): string {
let out = "" let out = ""
let digits = 0 let digits = ""
for (const ch of raw) { for (const ch of raw) {
if (ch === "+") { if (ch === "+") {
if (out.length === 0) out += ch // "+" only as the very first character if (out.length === 0) out += ch // "+" only as the very first character
@@ -30,10 +31,11 @@ export function limitPhoneInput(raw: string): string {
continue continue
} }
if (!/\d/.test(ch)) continue if (!/\d/.test(ch)) continue
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS // International (+94… or 94…) numbers carry two more digits than a local 0… number.
if (digits >= max) continue const max = out.startsWith("+") || digits.startsWith("94") || (digits === "9" && ch === "4") ? INTL_DIGITS : LOCAL_DIGITS
if (digits.length >= max) continue
out += ch out += ch
digits += 1 digits += ch
} }
return out return out
} }
@@ -44,11 +46,11 @@ export function phoneError(value: string, { required = false }: { required?: boo
if (!phone) return required ? "Enter a phone number" : null if (!phone) return required ? "Enter a phone number" : null
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +" if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
const digits = phone.replace(/\D/g, "") const digits = phone.replace(/\D/g, "")
if (phone.startsWith("+")) { if (phone.startsWith("+") || digits.startsWith("94")) {
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}` if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)` if (digits.length !== INTL_DIGITS) return `94 numbers need exactly 9 digits after 94 (${digits.length - 2} entered)`
} else { } else {
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}` if (!digits.startsWith("0")) return `Phone number must start with 0, 94 or +94 — ${PHONE_EXAMPLE}`
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)` if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
} }
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}` if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
+6
View File
@@ -17,6 +17,8 @@ export interface BundleSaleTemplateSummary {
templateCode: string templateCode: string
templateName: string templateName: string
description: string | null description: string | null
/** The whole bundle's selling price; 0 on templates saved before bundle prices existed. */
bundlePrice: number
status: EntityStatus status: EntityStatus
lineCount: number lineCount: number
createdAt: string createdAt: string
@@ -28,6 +30,8 @@ export interface BundleSaleTemplate {
templateCode: string templateCode: string
templateName: string templateName: string
description: string | null description: string | null
/** The whole bundle's selling price; 0 on templates saved before bundle prices existed. */
bundlePrice: number
status: EntityStatus status: EntityStatus
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
@@ -46,6 +50,8 @@ export interface CreateBundleSaleTemplateLineRequest {
export interface CreateBundleSaleTemplateRequest { export interface CreateBundleSaleTemplateRequest {
templateName: string templateName: string
description?: string | null description?: string | null
/** When set, the server splits it over the included lines (weighted by each line's unitPrice × qty). */
bundlePrice?: number | null
lines: CreateBundleSaleTemplateLineRequest[] lines: CreateBundleSaleTemplateLineRequest[]
} }
+12
View File
@@ -394,6 +394,14 @@ export interface ProductConfig {
itemImagesEnabled: boolean itemImagesEnabled: boolean
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */ /** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
customWarrantyPeriodsEnabled: boolean customWarrantyPeriodsEnabled: boolean
/** Server-enforced on item writes: off = new items are Stocked; existing natures are kept. */
stockNatureEnabled: boolean
/** Server-enforced on item writes: off = new items are untracked (None); existing modes are kept. */
trackingModeEnabled: boolean
/** Advisory: shows the low-stock alert section on item screens. Saved reorder rules keep working. */
lowStockAlertsEnabled: boolean
/** Advisory: lets the low-stock alert watch one warehouse. Needs `lowStockAlertsEnabled`. */
alertWarehouseEnabled: boolean
updatedAt: string | null updatedAt: string | null
updatedBy: number | null updatedBy: number | null
} }
@@ -410,6 +418,10 @@ export interface UpdateProductConfigRequest {
customVariantsEnabled: boolean customVariantsEnabled: boolean
itemImagesEnabled: boolean itemImagesEnabled: boolean
customWarrantyPeriodsEnabled: boolean customWarrantyPeriodsEnabled: boolean
stockNatureEnabled: boolean
trackingModeEnabled: boolean
lowStockAlertsEnabled: boolean
alertWarehouseEnabled: boolean
} }
/** /**
+69
View File
@@ -0,0 +1,69 @@
/** Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). */
export interface OwnerReportRecipient {
recipientId: number
name: string
/** Stored as +94XXXXXXXXX. */
mobile: string | null
email: string | null
isActive: boolean
}
export interface OwnerReportConfig {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
/** "HH:mm" in `timeZone`. */
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipient[]
timeZone: string
updatedAt: string | null
updatedBy: number | null
}
export interface OwnerReportRecipientInput {
/** Null for a new recipient. */
recipientId: number | null
name: string
mobile: string | null
email: string | null
isActive: boolean
}
export interface UpdateOwnerReportConfigRequest {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipientInput[]
}
export interface OwnerReportPreview {
date: string
smsText: string
emailSubject: string
emailHtml: string
}
export interface OwnerReportSendResult {
channel: "Sms" | "Email"
recipientName: string
destination: string
ok: boolean
error: string | null
}
export interface OwnerReportDispatch {
dispatchId: number
businessDate: string
channel: "Sms" | "Email"
recipientName: string
destination: string
status: "Pending" | "Sent" | "Failed"
attempts: number
error: string | null
isTest: boolean
createdAt: string
sentAt: string | null
}
+251
View File
@@ -0,0 +1,251 @@
# Day-End Owner Report — Implementation Plan
At the end of each business day, send the owner a summary of the day's revenue and key details
by **SMS** and **email**. The owner's mobile number(s) and email address(es) are set in
**Settings → Configuration**.
> **Naming:** this builds on the existing read-only **Day-End Sales Report**
> (`docs/DAY_END_SALES_REPORT_PLAN.md`, permission `sales.day-end-report`). It does not change the
> cashier close-out `SalesDayEnd` (shown as "Approved Invoices"). Access: the existing `settings`
> nav permission, like the other configuration screens.
>
> **Status (2026-10-19): implemented** — phases 1–5 (PDF attachment, phase 6, not done). Deviations
> from the design below: mobile numbers are stored as `+94XXXXXXXXX` (AuthHex converts to the gateway's `94…` form), one
> `IMessageSender` replaces separate SMS/email senders, and the API uses the `settings` policy.
---
## 1. Requirements
| # | Requirement |
|---|---|
| R1 | Once per business day, at a configured time (default **21:00 Asia/Colombo**), send the day's summary to the owner. |
| R2 | Owner contacts are configured in Settings: **one or more mobile numbers** and **one or more email addresses**. |
| R3 | Each channel (SMS, email) can be switched on/off independently; the whole feature has a master on/off. |
| R4 | SMS carries a short revenue summary; email carries the full details (summary + breakdowns). |
| R5 | A **"Send test now"** button sends today's report immediately to the configured contacts. |
| R6 | Every send attempt is logged (date, channel, recipient, success/failure, error) and visible in Settings. |
| R7 | A day is sent at most once per channel per recipient, even if the server restarts or runs on several instances. |
| R8 | If the server was down at send time, the missed day is sent when it comes back (same day only — no backfill of older days). |
| R9 | Branch-aware: one company-wide summary, plus a per-branch breakdown when branching is enabled. |
### Message content
**SMS** (≤ 2 SMS segments, ~300 chars, English, plain text):
```
Hexa ERP – Day end 19/10/2026
Sales: LKR 125,400.00 (42)
Paid: 98,200.00 | Due: 27,200.00
Cash: 61,000.00 | Card: 37,200.00
Expenses: 8,500.00
Net profit: 21,300.00
Cash in hand: 54,300.00
```
**Email** (HTML + plain-text alternative), subject `Day-end report — 19 Oct 2026 — Sales LKR 125,400.00`:
| Section | Content (all from `DayEndReportDto`) |
|---|---|
| Summary | Total sales, sales count, paid, due, paid by cash / card / other, expenses, income, COGS, gross profit, net profit (labelled **Loss** when negative) |
| Cash | Opening cash in hand, net cash today, deposited to bank, closing cash in hand |
| Collections | Collected on earlier invoices |
| Per branch | One summary row per branch (when branching is on) |
| Top sales | Largest 10 sales of the day (doc no., customer, total, paid by) |
| Expenses | Every expense of the day (doc no., category · title, amount) |
| Footer | Link to `/dashboard/sales/day-end-report?date=YYYY-MM-DD`; "Sent automatically by Hexa ERP" |
Optional (phase 2): attach the day's PDF (same layout as the Day-End Report's **Download PDF**).
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Day figures | `DayEndReportService.GetDayAsync(date, branchId)` → `DayEndReportDto` (summary, sales, expenses, deposits) | Branch scoping reads `ICurrentBranchContext` (the HTTP user) — **no user in a background job** |
| Business day | `BusinessDayClock` (`DayEndReport:TimeZone`, `Asia/Colombo`) | — |
| Config pattern | Singleton config rows + service + `ETag`/`If-Match` (e.g. `SalesConfig`, `GrnConfig`, `NotificationConfig`) | — |
| Settings UI | `app/dashboard/settings/configuration/page.tsx` (card per config screen) | No owner-report card / page |
| Phone validation | `lib/validations/phone.ts` (Sri Lankan: `0XXXXXXXXX` / `+94XXXXXXXXX`), backend `PhoneRules` in `Dtos/Warehouses/WarehouseDtos.cs` | Move `PhoneRules` to a shared place |
| In-app notifications | `NotificationService`, `NotificationConfig` (per-module in-app alerts) | In-app only — **no SMS or email sending anywhere** |
| AuthHex (`D:\HexDive\ERP_Auth_Service`) | Owns the **SMTP** (`SmtpSettings` — Hostinger, port 587, SSL) and **SMS gateway** (`Sms` — Introps `Sms_common/send_sms`) credentials. `Utility/ThirdPartyService` already sends a custom-subject/HTML-body email (`EmailConfiguration`) and a custom-text SMS (`SendSmsAsync`), but only from its own OTP/recovery flows | **No endpoint** lets another service send a message — needs one (§3.2) |
| ERPCore → AuthHex | `IAuthHexClient` with `AuthHex:BaseUrl`; admin calls send `AuthHex:AdminApiKey` (`AdminHeaders()`) | — |
| Background jobs | None (no `BackgroundService`, Hangfire or Quartz) | **Need a scheduler** |
---
## 3. Design
### 3.1 Data model (migration `AddOwnerReportConfig`)
**`owner_report_config`** — singleton (`ConfigId = 1`, CHECK constraint like the other config rows):
| Column | Type | Notes |
|---|---|---|
| `ConfigId` | int PK | always 1 |
| `Enabled` | bool | master switch, default `false` |
| `SmsEnabled` | bool | default `true` |
| `EmailEnabled` | bool | default `true` |
| `SendAtLocalTime` | time | default `21:00`, in `DayEndReport:TimeZone` |
| `IncludeBranchBreakdown` | bool | default `true` |
| `UpdatedAt`, `UpdatedBy`, `RowVersion` | | same as other configs |
**`owner_report_recipients`**:
| Column | Type | Notes |
|---|---|---|
| `RecipientId` | int PK | |
| `Name` | varchar(100) | e.g. "Owner", "Director" |
| `Mobile` | varchar(20) null | normalised to `+94XXXXXXXXX`; validated with the shared Sri Lankan phone rule |
| `Email` | varchar(200) null | validated email |
| `IsActive` | bool | |
At least one of `Mobile` / `Email` required. Max **5** recipients.
**`owner_report_dispatch`** — the send log *and* the idempotency guard:
| Column | Type | Notes |
|---|---|---|
| `DispatchId` | bigint PK | |
| `BusinessDate` | date | |
| `Channel` | enum `Sms` / `Email` | |
| `RecipientId` | int FK | |
| `Destination` | varchar(200) | number/email at send time (kept if the recipient changes later) |
| `Status` | enum `Pending` / `Sent` / `Failed` | |
| `Attempts` | int | |
| `Error` | varchar(1000) null | provider error, truncated |
| `ProviderMessageId` | varchar(100) null | |
| `IsTest` | bool | "Send test now" sends |
| `CreatedAt`, `SentAt` | datetime | |
Unique index `(BusinessDate, Channel, RecipientId, IsTest)` → R7: a second worker or a restart can't
insert the same automatic send twice (`INSERT` wins once; the loser skips).
### 3.2 Sending channels — through AuthHex
**SMTP and SMS details come from AuthHex.** ERPCore does not hold any mail or SMS credentials; it
asks AuthHex to send, and AuthHex uses the `SmtpSettings` / `Sms` configuration it already has.
One place to rotate a password, one sender identity, nothing new to secure in ERPCore.
```
OwnerReportJob ──► IEmailSender / ISmsSender (ERPCore)
│ AuthHexEmailSender / AuthHexSmsSender
▼ POST {AuthHex:BaseUrl}/api/notify/... + AdminApiKey header
AuthHex NotifyController ──► ThirdPartyService ──► Hostinger SMTP / Introps SMS
```
**AuthHex side (`ERP_Auth_Service`) — new, small:**
| Method | Route | Body | Uses |
|---|---|---|---|
| `POST` | `/api/notify/email` | `{ to: string[], subject, htmlBody, textBody? }` | `ThirdPartyService.EmailConfiguration(to, code: "", subject, htmlBody)` per recipient |
| `POST` | `/api/notify/sms` | `{ to: string[], text }` | `ThirdPartyService.SendSmsAsync(to, otpCode: "-", message: text)` per recipient |
- Protected by the **same admin API key** ERPCore already sends for admin calls (`AuthHex:AdminApiKey`); no user session. Reject anything without it (401).
- Response: per-recipient `{ to, ok, error }` in the usual AuthHex envelope.
- Changes needed in `ThirdPartyService` (keep the OTP flows working):
- `SendSmsAsync` rejects an empty `otpCode` even when a custom `message` is given → allow an empty code when `message` is set (or add a `SendTextAsync(mobile, text)` overload).
- `SendEmailAsync` runs `body.Replace("{code}", code)` — harmless for the report, but add a `SendCustomEmailAsync(to, subject, html)` that skips OTP templating.
- `SmtpSettings:SenderName` is currently **"Gym Os"** — set it to **"Hexa ERP"** so owner emails show the right sender (`From: Hexa ERP <sender address>`).
- Uses `System.Net.Mail.SmtpClient`, 10 s timeout, SSL — fine for a few emails a day.
- Limits: ≤ 10 recipients per call; email body ≤ 200 KB; SMS text ≤ 480 chars.
**ERPCore side (`Infra/Messaging/`):**
```csharp
public interface ISmsSender { Task<IReadOnlyList<SendResult>> SendAsync(IReadOnlyList<string> toE164, string text, CancellationToken ct); }
public interface IEmailSender { Task<IReadOnlyList<SendResult>> SendAsync(EmailMessage message, CancellationToken ct); }
public sealed record SendResult(string To, bool Ok, string? Error);
```
| Implementation | Used when |
|---|---|
| `AuthHexEmailSender`, `AuthHexSmsSender` — two new methods on `IAuthHexClient` (`SendEmailAsync`, `SendSmsAsync`) reusing its HTTP client, base URL and `AdminHeaders()` | `Messaging:Provider = AuthHex` (default in Development/Production) |
| `LogOnlyEmailSender`, `LogOnlySmsSender` — write the message to the log instead of sending | `Messaging:Provider = Log` (local dev, tests, or while AuthHex lacks the endpoint) |
No new secrets in ERPCore. Each call has a 20 s timeout and no retries of its own — the job retries.
If AuthHex is unreachable the dispatch row is marked `Failed` with the error and retried (§3.4).
### 3.3 Building the report (`OwnerReportService`)
- `BuildAsync(DateOnly date)` → `OwnerReportContent { SmsText, EmailSubject, EmailHtml, EmailText }`.
- Calls `DayEndReportService.GetDayAsync(date, branchId: null)` for the company total, and once per active branch when `IncludeBranchBreakdown` and branching is on.
- **Branch scoping fix:** add an explicit "system / all branches" path to `DayEndReportService` (e.g. an overload taking `bool allBranches`) instead of relying on `ICurrentBranchContext`, which has no user inside the job. Must not widen access for HTTP callers.
- Amounts use the same formatting as the UI (`#,##0.00`, `LKR` prefix on the headline); a negative net profit is written as `Loss 1,200.00`.
- Email HTML is a simple inline-styled table template (`Infra/Messaging/Templates/OwnerReport.html`) — no external CSS/images.
### 3.4 Scheduler (`OwnerReportJob : BackgroundService`)
- Wakes every **60 s** (`PeriodicTimer`).
- If `Enabled` and local time (`BusinessDayClock`) ≥ `SendAtLocalTime` and today has no `Sent`/`Pending` dispatch rows → build once, then for each active recipient × enabled channel insert a `Pending` dispatch row (unique index guards duplicates) and send.
- Failures: retry up to **3** times, 5 min apart (`Attempts`, `Status = Failed` after the last).
- Missed send (server down at 21:00): picked up on the next tick the same day (R8). Days before today are never sent automatically.
- Runs in its own DI scope per tick; any exception is logged and the loop continues — it must never crash the host.
- Multi-instance safe through the unique index (no distributed lock needed).
### 3.5 API (`SettingsOwnerReportController`, policy `settings.owner-report`)
| Method | Route | Body / result |
|---|---|---|
| `GET` | `/api/v1/settings/owner-report` | config + recipients (ETag) |
| `PUT` | `/api/v1/settings/owner-report` | config + full recipients list, `If-Match` required |
| `POST` | `/api/v1/settings/owner-report/test` | `{ date?: "YYYY-MM-DD" }` → sends now to all active recipients (`IsTest = true`); returns per-recipient results |
| `GET` | `/api/v1/settings/owner-report/dispatches?from&to&page&pageSize` | send log, newest first |
| `GET` | `/api/v1/settings/owner-report/preview?date=` | `{ smsText, emailSubject, emailHtml }` — renders without sending |
Validation (422): recipient needs mobile or email; mobile must pass the Sri Lankan phone rule; email
must be valid; ≤ 5 recipients; `SendAtLocalTime` between 00:00 and 23:59. `POST /test` is rate-limited
(1 per minute) so the button can't be used to spam the owner.
### 3.6 Frontend — `app/dashboard/settings/owner-report/page.tsx`
- New card on **Settings → Configuration**: *"Day-End Owner Report — Send the day's revenue to the owner by SMS and email"* (icon `Send`).
- Page sections (mobile-first, same `StatCard`/card styles as the rest of the app):
1. **Delivery** — master switch, SMS on/off, Email on/off, send time (time input), "include branch breakdown".
2. **Recipients** — list of name / mobile / email rows with add / remove (max 5). Mobile uses `limitPhoneInput` + `phoneError` from `lib/validations/phone.ts`; email validated client-side too. All errors also shown as toasts.
3. **Preview** — SMS text in a phone-style bubble, email rendered in a sandboxed iframe (`srcdoc`), date picker to preview another day.
4. **Send test now** — confirm dialog, then per-recipient result toast.
5. **Send log** — paginated table (cards on phones): date, channel, recipient, status badge, error tooltip.
- Sidebar: no new entry (reached from Settings → Configuration), permission code `settings.owner-report` added to the role permission tree.
---
## 4. Security & privacy (02-SECURITY checklist items)
- SMTP and SMS credentials stay in AuthHex's configuration only — ERPCore never sees them. The
new AuthHex `/api/notify/*` endpoints accept only the admin API key, so they can't be used as an
open relay; the key itself stays in ERPCore's configuration/secrets, never in git or API responses.
- Owner contact data visible only to `settings.owner-report`; the send log masks numbers (`+94 77 *** 4567`) for other viewers if it is ever exposed elsewhere.
- SMS/email bodies contain financial figures — sent only to configured recipients, never to user-supplied addresses on the test endpoint.
- Audit log entries for config changes (who changed recipients / switched it on).
- The email footer link requires login — no figures are exposed through the link itself.
---
## 5. Phases & tracking
| Phase | Scope | Done when |
|---|---|---|
| 1 | Data model + migration, config API, Settings page (delivery + recipients), `LogOnly` senders | Config saves/loads with validation; test send writes to the log |
| 2 | `OwnerReportService` content + preview endpoint/UI; branch-scoping fix in `DayEndReportService` | Preview matches the Day-End Report figures for the same day |
| 3 | AuthHex: `POST /api/notify/email` + `ThirdPartyService` custom-email method + sender name; ERPCore: `AuthHexEmailSender`; send log UI | Test email arrives from the right sender with correct figures |
| 4 | AuthHex: `POST /api/notify/sms` + empty-OTP fix; ERPCore: `AuthHexSmsSender` | Test SMS arrives, ≤ 2 segments |
| 5 | `OwnerReportJob` scheduler, retries, idempotency | Sends once at the set time; restart and 2-instance tests don't double-send |
| 6 (opt.) | PDF attachment | Email carries the day's PDF |
Record each phase in `Backend/PROGRESS.md` / `Frontend/PROGRESS.md` per `01-DOC-GUIDE.md`.
---
## 6. Open questions
| # | Question | Default if not answered |
|---|---|---|
| Q1 | ~~Which SMS gateway?~~ **Decided:** AuthHex's existing gateway (Introps), via the new AuthHex endpoint. | — |
| Q2 | ~~Which SMTP account?~~ **Decided:** AuthHex's `SmtpSettings` (Hostinger), sender name **"Hexa ERP"**. Still open: the sender *address* — today `tester@hexdive.com`. | Keep the current address until a dedicated one (e.g. `erp@hexdive.com`) exists |
| Q2b | Is the AuthHex repo (`ERP_Auth_Service`) ours to change, or owned by another team who must add `/api/notify/*`? | We add it in the same release |
| Q3 | One company-wide report, or a separate report per branch to each branch's manager? | Company-wide + per-branch breakdown section |
| Q4 | Send time — fixed 21:00, or after the last cashier closes the day (Approved Invoices / `SalesDayEnd`)? | Fixed configurable time |
| Q5 | Should a day with no sales still send ("No sales today")? | Yes — confirms the system ran |
| Q6 | Languages — English only, or Sinhala/Tamil SMS too (Unicode halves SMS length)? | English only |
+196
View File
@@ -0,0 +1,196 @@
# Notification Flow — Current State & Analysis
How notifications are raised, configured, stored and shown in ERPCore today, every event that
raises one, and the gaps found while tracing the code (2026-10-20).
> **Scope:** the in-app notification system (the bell in the header) and its configuration
> screen (**Settings → Notification Config**). The separate **Day-End Owner Report** (SMS + email
> to the owner) is covered in `DAY_END_OWNER_REPORT_PLAN.md`; how it relates is in §8.
---
## 1. Overview
```
Business action (create GRN, post sale, approve PO, stock drops …)
│
▼
Service ──► INotificationService.RaiseAsync(moduleKey, title, message, entityType, entityId)
│ or NotifyUsersAsync(userIds, moduleKey, …) (targeted: approvals)
▼
NotificationService.DeliverAsync
│ 1. recipients = active users (all of them, or the given ids)
│ 2. channels = NotificationConfigService.ResolveEnabledChannelsAsync(moduleKey)
│ 3. In-App off (or module unknown) ──► STOP, nothing stored
▼
notifications (1 row) + notification_recipients (1 row per user, IsRead = false)
│
▼
Frontend Header bell ──► GET /api/v1/notifications?take=20 (on page load + when the bell opens)
│ → latest 20 for the signed-in user + unread count
└──────────────────► POST /api/v1/notifications/mark-all-read
```
Only the **In-App** channel is delivered. **Email** and **SMS** can be switched on per module in
Settings, but nothing sends them (§7, G2).
---
## 2. Components
| Layer | File | Role |
|---|---|---|
| Module registry | `Domain/Notifications/Modules/NotificationModules.cs` | One `[NotificationModule(key, label, description, sortOrder)]` class per notification type |
| | `Domain/Notifications/NotificationModuleAttribute.cs`, `NotificationModuleDefinition.cs` | Attribute + DTO for the registry |
| | `Services/NotificationModuleCatalog.cs` | Finds every `[NotificationModule]` by reflection at startup; throws on duplicate keys |
| Channels | `Domain/Notifications/NotificationChannel.cs` | `Email`, `Sms`, `InApp` |
| Data | `Domain/Entities/Notification.cs` | The event: `ModuleKey`, `Title`, `Message`, `RelatedEntityType`, `RelatedEntityId`, `CreatedAt` |
| | `Domain/Entities/NotificationRecipient.cs` | Per-user copy: `UserId`, `IsRead`, `ReadAt` |
| | `Domain/Entities/NotificationConfig.cs` | Singleton settings row (`ConfigId = 1`, `RowVersion` → ETag) |
| | `Domain/Entities/NotificationConfigModule.cs` | Per module: `EmailEnabled`, `SmsEnabled`, `InAppEnabled` |
| Delivery | `Services/NotificationService.cs` | `RaiseAsync`, `NotifyUsersAsync`, `GetFeedAsync`, `MarkAllReadAsync` |
| Settings | `Services/NotificationConfigService.cs` | Get/update settings, `ResolveEnabledChannelsAsync(moduleKey)` |
| Low stock | `Services/Stock/LowStockTracker.cs`, `LowStockAlertService.cs`, `Infra/Filters/LowStockAlertFilter.cs` | Collects stock issues during a request, raises `stock.low` after it commits |
| API | `Controllers/NotificationsController.cs` | `GET /api/v1/notifications`, `POST /api/v1/notifications/mark-all-read` |
| | `Controllers/NotificationConfigController.cs` | `GET/PUT /api/v1/notification-config` (policy `settings`) |
| Frontend | `components/Layouts/Header.tsx` | Bell icon, unread badge, popover list, "mark all read" |
| | `app/dashboard/settings/notification-config/page.tsx` | Per-module Email / SMS / In-App switches |
| | `lib/api/notifications.ts`, `lib/api/notification-config.ts` | API clients |
---
## 3. Data model
```
notification_config (1 row)
└─< notification_config_modules (ModuleKey, EmailEnabled, SmsEnabled, InAppEnabled)
notifications (NotificationId, ModuleKey, Title, Message, RelatedEntityType, RelatedEntityId, CreatedAt)
└─< notification_recipients (NotificationId, UserId, IsRead, ReadAt)
```
- One `notifications` row per event; one `notification_recipients` row **per user** who should see it.
- Read state is per user (`IsRead`, `ReadAt`).
- Nothing is ever deleted or archived — both tables grow forever (§7, G7).
---
## 4. Configuration flow
1. **Registry.** Each notification type is a class with `[NotificationModule("grn.created", "GRN Created", "…", 30)]`. The catalog is built once by reflection, so **adding a module = adding one class**; it then appears on the settings screen automatically.
2. **Seed.** `DataSeeder.SeedNotificationConfigAsync` creates the singleton `notification_config` row **with no module rows**.
3. **Settings screen** (`/dashboard/settings/notification-config`): lists every catalog module with three switches (Email, SMS, In-App). Saving sends **every** module (`PUT`, `If-Match` ETag); the server rejects unknown keys (422) and missing keys (422), then replaces all module rows.
4. **At send time** `ResolveEnabledChannelsAsync(moduleKey)` reads that module's row. **No row ⇒ no channels ⇒ nothing is delivered.**
> Consequence: on a fresh database **every notification is off** until an admin opens Settings →
> Notification Config and switches modules on (§7, G3).
---
## 5. Delivery flow (backend)
### 5.1 Broadcast — `RaiseAsync`
Used by almost every trigger. Recipients = **every active user** in the system — no role, branch
or permission filter (§7, G4).
### 5.2 Targeted — `NotifyUsersAsync(userIds, …)`
Used by the approval flow only: the approvers of a pending request, or the requester when it is
approved / rejected. Inactive users in the list are dropped.
### 5.3 Shared steps (`DeliverAsync`)
1. Stop if there are no active recipients.
2. Resolve channels for the module; stop unless **In-App** is enabled.
3. Insert one `Notification` + one `NotificationRecipient` per user, `SaveChangesAsync`.
### 5.4 Timing
Most services call `RaiseAsync` **after** their own transaction commits (e.g. `GrnService.CreateAsync`
commits the GRN, then raises `grn.created`). So a notification never exists for a rolled-back
document — but if saving the notification throws, the request fails **after** the document was
already saved (§7, G6).
### 5.5 Low-stock alerts (`stock.low`)
```
FifoCostingService.ConsumeAsync (every stock issue: sale, transfer, adjustment, return, production)
└─► LowStockTracker.Track(itemId, warehouseId, qty) (per request, in memory)
LowStockAlertFilter (MVC action filter, after the action succeeds)
└─► LowStockAlertService: for each tracked item/warehouse with a reorder rule,
if stock crossed from above the reorder point to at/below it in this request
→ RaiseAsync("stock.low", "Low stock: <item>", "<sku> — <item> is down to <qty> …")
```
It fires only on the crossing (not on every later sale while still low). Unlike the other
triggers this path is already **best-effort**: a failed action drains the tracker (nothing alerted),
and any error in the check is caught and logged without failing the request.
---
## 6. Every trigger
| Module key | Registered | Raised by | When | Recipients |
|---|---|---|---|---|
| `sale.create` | ✅ | `SalesSlipService` | Sales slip created | All active users |
| `sale.posted` | ✅ | `SalesSlipService` | Sales slip posted | All |
| `grn.created` | ✅ | `GrnService` | GRN created | All |
| `grn.confirmed` | ✅ | `GrnService` | GRN confirmed | All |
| `purchase.order.created` | ✅ | `PurchaseOrderService` | PO created | All |
| `purchase.order.pending_approval` | ❌ **not in catalog** | `PurchaseOrderService` | PO submitted for approval | **Never delivered** (G1) |
| `purchase.order.approved` | ✅ | `PurchaseOrderService` | PO approved | All |
| `purchase.order.rejected` | ❌ **not in catalog** | `PurchaseOrderService` | PO rejected | **Never delivered** (G1) |
| `purchase.return.created` | ✅ | `PurchaseReturnService` | Purchase return created | All |
| `stock.adjustment.created` | ✅ | `AdjustmentService` | Adjustment created / posted | All |
| `stock.transfer.created` | ✅ | `TransferService` | Transfer created | All |
| `stock.low` | ✅ | `LowStockAlertService` | Stock crosses a reorder point | All |
| `approval.pending` | ✅ | `ApprovalService` | Stock/price adjustment awaits approval | The approvers |
| `approval.approved` | ✅ | `AdjustmentService`, `PriceAdjustmentService` | Request fully approved | The requester |
| `approval.rejected` | ✅ | `AdjustmentService`, `PriceAdjustmentService` | Request rejected | The requester |
| `item.created` | ✅ | `ItemService` | Item created | All |
| `item.updated` | ✅ | `ItemService` | Item updated | All |
**Not covered at all:** sales invoices (create / post / payment), expenses, bank deposits, purchase
invoices & payments, requisitions, RFQs, stock counts, production runs, HRM (leave, payroll),
day-end close, customer credit limit, overdue invoices.
---
## 7. Gaps & risks
| # | Gap | Impact | Suggested fix |
|---|---|---|---|
| **G1** | `purchase.order.pending_approval` and `purchase.order.rejected` are raised but **not registered** as modules. `ResolveEnabledChannelsAsync` finds no row → returns no channels → silently dropped. | Approvers are never told a PO is waiting; requesters never see a rejection. | Add two `[NotificationModule]` classes; ideally send pending-approval only to PO approvers (`NotifyUsersAsync`) like `approval.pending`. Add a startup check that every key passed to `RaiseAsync` exists (constants class instead of string literals). |
| **G2** | **Email / SMS switches do nothing.** `DeliverAsync` only checks In-App; no sender is called. | Admins switch on email/SMS and nothing happens — misleading. | Reuse the owner-report messaging path (`IMessageSender` → AuthHex `/api/notify/email|sms`), using each user's email / mobile; or hide the two columns until implemented. |
| **G3** | Seed creates the config **without module rows** ⇒ everything off on a new install. | New deployments look like notifications are broken. | Seed every catalog module with In-App on (and add rows for modules added later on startup). |
| **G4** | `RaiseAsync` broadcasts to **every active user** — no role, permission or branch filter. | Cashiers get PO/GRN/item noise; users see other branches' documents; the bell fills up and gets ignored. | Per-module recipient rules: by permission code (e.g. `receiving` for GRN), by role, and by branch of the document. |
| **G5** | Bell shows the latest 20 only; **no per-item read**, no link to the record (`RelatedEntityType/Id` are stored but unused), no "view all" page. | Users can't open the GRN/PO from the alert or work through older ones. | Map `RelatedEntityType` → route (as `SEARCH_TYPE_META` in `Header.tsx` already does for search); mark one as read on click; add `/dashboard/notifications` with paging. |
| **G6** | Notification is saved after the business commit, outside it, and exceptions aren't caught (all triggers except `stock.low`, which is already best-effort). | A notification DB error returns 500 for a document that **was** saved → user retries → duplicate. | Wrap `RaiseAsync` calls in try/catch + log (notifications are best-effort), or write an outbox row inside the same transaction and deliver after. |
| **G7** | No retention. One recipient row per active user per event. | 50 users × a few hundred events/day ≈ tens of thousands of rows a day. | Purge read notifications older than N days (background job, like `OwnerReportJob`); index `(UserId, IsRead)`. |
| **G8** | No real-time push — the bell refreshes only on page load and when opened. | Unread badge is stale while a user stays on one page. | Poll every 60 s while the tab is visible, or SignalR later. |
| **G9** | `ResolveEnabledChannelsAsync` queries the DB on every notification. | An extra query per event (minor). | Cache the module map, invalidate on `PUT /notification-config`. |
| **G10** | Titles/messages are hard-coded English strings in each service. | No templates, no Sinhala/Tamil, inconsistent wording. | Optional: per-module templates in the catalog. |
---
## 8. Relation to the Day-End Owner Report
| | In-app notifications (this doc) | Day-End Owner Report |
|---|---|---|
| Trigger | Business events, as they happen | Once a day, at a configured time (`OwnerReportJob`) |
| Recipients | ERP users (all, or targeted ids) | Owner contacts entered in Settings (not ERP users) |
| Channels | In-App only (Email/SMS switches not implemented) | SMS + email through AuthHex |
| Send log | none | `owner_report_dispatches` (status, attempts, error) |
| Config | Settings → Notification Config | Settings → Configuration → Day-End Owner Report |
The owner report already solved what G2 needs — `IMessageSender` + AuthHex `/api/notify/*` with no
credentials in ERPCore — so wiring Email/SMS for notifications can reuse it rather than add a second
mail path. Note the providers themselves currently fail (Hostinger SMTP outbound disabled; SMS
gateway out of credit/limit) — see the owner report send log.
---
## 9. Recommended order of work
1. **G1** register the two missing PO modules (+ key constants) — small, fixes silent loss.
2. **G3** seed every module with In-App on.
3. **G6** make notification delivery best-effort (try/catch + log).
4. **G5** clickable notifications + mark one read + "view all" page.
5. **G4** recipient rules by permission / role / branch.
6. **G2** Email/SMS delivery via `IMessageSender` (after the SMTP/SMS provider accounts work).
7. **G7 / G8 / G9** retention job, polling, config cache.