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