ui fixers in 10/16

This commit is contained in:
2026-10-16 14:34:28 +05:30
parent 379ad204bd
commit e289b8430f
47 changed files with 1904 additions and 1046 deletions
@@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
[FromQuery] string? q,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto(
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
/// <summary>Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page.</summary>
public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
@@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
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>
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 sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -8,6 +8,8 @@ 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);
/// <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<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);
+27 -3
View File
@@ -41,13 +41,37 @@ public sealed class SalesConfigService : ISalesConfigService
public async Task<ETagged<SalesConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
var config = await LoadAsync(ct);
// "Allow cash customer" can be on with no customer linked — the flag was set outside
// UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer
// because there's no id to offer, so link (or create) CUST-CASH here instead.
if (config.AllowCashCustomer && config.CashCustomerId is null)
{
var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct);
if (tracked is not null && tracked.CashCustomerId is null)
{
tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// A concurrent save got there first — whatever it stored is re-read below.
}
}
config = await LoadAsync(ct);
}
return new ETagged<SalesConfigDto>(Map(config), config.RowVersion);
}
private async Task<SalesConfig> LoadAsync(CancellationToken ct) =>
await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse)
.FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct)
?? throw new NotFoundException("Sales configuration has not been seeded.");
public async Task<ETagged<SalesConfigDto>> UpdateAsync(
UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
@@ -53,10 +53,44 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
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)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
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)
{
var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid);
// Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule.
var today = DateTime.UtcNow.Date;
var totals = await filtered
.GroupBy(_ => 1)
.Select(g => new
{
Count = g.Count(),
Net = g.Sum(x => x.GrandTotal),
Paid = g.Sum(x => x.PaidAmount),
Balance = g.Sum(x => x.BalanceAmount > 0 ? x.BalanceAmount : 0),
Unpaid = g.Count(x => x.BalanceAmount > 0),
Overdue = g.Count(x => x.BalanceAmount > 0 && x.InvoiceDate < today),
})
.FirstOrDefaultAsync(ct);
return totals is null
? new SalesInvoiceListTotalsDto(0, 0m, 0m, 0m, 0, 0)
: new SalesInvoiceListTotalsDto(totals.Count, totals.Net, totals.Paid, totals.Balance, totals.Unpaid, totals.Overdue);
}
/// <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)
{
if (!string.IsNullOrWhiteSpace(search))
{
var term = query.Q.Trim();
var term = search.Trim();
q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
@@ -72,11 +106,7 @@ 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);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
return q;
}
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)