Merge pull request 'Feat/sales management' (#24) from feat/sales-management into Dev
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
|
||||||
|
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
|
||||||
|
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/free-issues")]
|
||||||
|
public sealed class FreeIssuesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISalesSlipService _slips;
|
||||||
|
|
||||||
|
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] SalesSlipStatus? status,
|
||||||
|
[FromQuery] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{freeIssueId:int}")]
|
||||||
|
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{freeIssueId:int}/posting-check")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _slips.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{freeIssueId:int}")]
|
||||||
|
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{freeIssueId:int}/post")]
|
||||||
|
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.PostAsync(freeIssueId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{freeIssueId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{salesInvoiceId:int}/posting-check")]
|
||||||
|
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
|
||||||
|
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -11,39 +11,25 @@ public sealed class SalesReportsController : ApiControllerBase
|
|||||||
|
|
||||||
public SalesReportsController(ISalesReportService reports) => _reports = reports;
|
public SalesReportsController(ISalesReportService reports) => _reports = reports;
|
||||||
|
|
||||||
[HttpGet("daily-summary")]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesDailySummaryRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesDailySummaryRowDto>>> DailySummary(
|
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
=> Ok(_reports.ListReports());
|
||||||
=> Ok(await _reports.DailySummaryAsync(from, to, ct));
|
|
||||||
|
|
||||||
[HttpGet("item-wise")]
|
[HttpGet("{reportId}")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemSummaryRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesItemSummaryRowDto>>> ItemWise(
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? itemId, [FromQuery] int? warehouseId, CancellationToken ct)
|
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
|
||||||
=> Ok(await _reports.ItemSummaryAsync(from, to, itemId, warehouseId, ct));
|
{
|
||||||
|
var report = _reports.GetReport(reportId);
|
||||||
[HttpGet("customer-wise")]
|
return report is null ? NotFound() : Ok(report);
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesCustomerSummaryRowDto>), StatusCodes.Status200OK)]
|
}
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesCustomerSummaryRowDto>>> CustomerWise(
|
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? customerId, CancellationToken ct)
|
[HttpPost("query")]
|
||||||
=> Ok(await _reports.CustomerSummaryAsync(from, to, customerId, ct));
|
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
|
||||||
[HttpGet("warehouse-wise")]
|
{
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesWarehouseSummaryRowDto>), StatusCodes.Status200OK)]
|
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesWarehouseSummaryRowDto>>> WarehouseWise(
|
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? warehouseId, CancellationToken ct)
|
}
|
||||||
=> Ok(await _reports.WarehouseSummaryAsync(from, to, warehouseId, ct));
|
|
||||||
|
|
||||||
[HttpGet("discount-summary")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesDiscountSummaryRowDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesDiscountSummaryRowDto>>> DiscountSummary(
|
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
|
||||||
=> Ok(await _reports.DiscountSummaryAsync(from, to, ct));
|
|
||||||
|
|
||||||
[HttpGet("free-issue-summary")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesFreeIssueSummaryRowDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesFreeIssueSummaryRowDto>>> FreeIssueSummary(
|
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
|
||||||
=> Ok(await _reports.FreeIssueSummaryAsync(from, to, ct));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
||||||
|
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISalesPromotionSuggestionService _suggestions;
|
||||||
|
|
||||||
|
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ public sealed class SalesSlipsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{salesSlipId:int}/posting-check")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||||
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ namespace ERPCore.Domain.Enums;
|
|||||||
public enum SalesDiscountMode
|
public enum SalesDiscountMode
|
||||||
{
|
{
|
||||||
Percentage = 1,
|
Percentage = 1,
|
||||||
FixedAmount = 2
|
Amount = 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ public sealed record SalesInvoiceSummaryDto(
|
|||||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record SalesInvoicePostingIssueDto(
|
||||||
|
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||||
|
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||||
|
|
||||||
|
public sealed record SalesInvoicePostingCheckDto(
|
||||||
|
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||||
|
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
|
||||||
|
|
||||||
public sealed class CreateSalesInvoiceLineRequest
|
public sealed class CreateSalesInvoiceLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[Required] public int ItemId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
public sealed record SalesFreeIssueRewardOptionDto(
|
||||||
|
int ItemId,
|
||||||
|
string Sku,
|
||||||
|
string Name,
|
||||||
|
decimal? SalePrice);
|
||||||
|
|
||||||
|
public sealed record SalesFreeIssueSuggestionLineDto(
|
||||||
|
int SalesSlipLineId,
|
||||||
|
int ItemId,
|
||||||
|
string ItemSku,
|
||||||
|
string ItemName,
|
||||||
|
decimal Qty,
|
||||||
|
decimal SuggestedFreeQty,
|
||||||
|
decimal TriggerQty,
|
||||||
|
IReadOnlyList<SalesFreeIssueRewardOptionDto> RewardOptions);
|
||||||
|
|
||||||
|
public sealed record SalesFreeIssueSuggestionDto(
|
||||||
|
int SalesSlipId,
|
||||||
|
string SlipNo,
|
||||||
|
DateTime SlipDate,
|
||||||
|
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
public sealed record SalesReportDefinitionDto(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
IReadOnlyList<string> SupportedFilters);
|
||||||
|
|
||||||
|
public sealed record SalesReportQueryRequest(
|
||||||
|
string ReportType,
|
||||||
|
DateOnly From,
|
||||||
|
DateOnly To,
|
||||||
|
int? ItemId = null,
|
||||||
|
int? CustomerId = null,
|
||||||
|
int? WarehouseId = null);
|
||||||
|
|
||||||
|
public sealed record SalesReportQueryResponse(
|
||||||
|
string ReportType,
|
||||||
|
DateOnly From,
|
||||||
|
DateOnly To,
|
||||||
|
IReadOnlyList<object> Rows);
|
||||||
@@ -23,6 +23,25 @@ public sealed record SalesSlipSummaryDto(
|
|||||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record SalesSlipPostingIssueDto(
|
||||||
|
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||||
|
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||||
|
|
||||||
|
public sealed record SalesSlipPostingCheckDto(
|
||||||
|
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
||||||
|
IReadOnlyList<SalesSlipPostingIssueDto> Issues);
|
||||||
|
|
||||||
|
public sealed record FreeIssueSummaryDto(
|
||||||
|
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
|
||||||
|
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
|
||||||
|
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
||||||
|
|
||||||
|
public sealed record FreeIssueDto(
|
||||||
|
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
|
||||||
|
int CustomerId, string CustomerSnapshotName, int WarehouseId, string WarehouseName,
|
||||||
|
int CashierUserId, DateTime CreatedAt, DateTime? UpdatedAt, FreeIssueSummaryDto Summary,
|
||||||
|
IReadOnlyList<SalesSlipLineDto> Lines);
|
||||||
|
|
||||||
public sealed class CreateSalesSlipLineRequest
|
public sealed class CreateSalesSlipLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[Required] public int ItemId { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -42,7 +43,11 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
dirty |= await SeedItemTypesAsync(db, ct);
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
|
dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesStockAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesAsync(db, ct);
|
||||||
|
|
||||||
if (dirty) await db.SaveChangesAsync(ct);
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -99,4 +104,541 @@ public static class DataSeeder
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
||||||
|
/// These values are intentionally editable later through the API.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
||||||
|
|
||||||
|
db.CompanyProfiles.Add(new CompanyProfile
|
||||||
|
{
|
||||||
|
CompanyProfileId = CompanyProfile.SingletonId,
|
||||||
|
LegalName = "ERP Core Trading (Pvt) Ltd",
|
||||||
|
TradeName = "ERP Core Trading",
|
||||||
|
TaxRegistrationNo = "TAX-DEFAULT-001",
|
||||||
|
VatRegistrationNo = "VAT-DEFAULT-001",
|
||||||
|
AddressLine1 = "1 Demo Street",
|
||||||
|
City = "Colombo",
|
||||||
|
Country = "Sri Lanka",
|
||||||
|
Phone = "+94 11 000 0000",
|
||||||
|
Email = "accounts@example.com",
|
||||||
|
BankName = "Demo Bank",
|
||||||
|
BankBranch = "Colombo Main",
|
||||||
|
AccountName = "ERP Core Trading (Pvt) Ltd",
|
||||||
|
AccountNumber = "000123456789",
|
||||||
|
SwiftCode = "DEMO1234",
|
||||||
|
FooterNote = "Thank you for your business."
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the minimum catalog data required for the sales demo rows to exist.
|
||||||
|
/// These are safe additive rows and do not alter any existing data.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
dirty |= await SeedWarehousesAsync(db, ct);
|
||||||
|
dirty |= await SeedUomsAsync(db, ct);
|
||||||
|
dirty |= await SeedCategoriesAsync(db, ct);
|
||||||
|
dirty |= await SeedItemsAsync(db, ct);
|
||||||
|
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a simple on-hand FIFO layer for the demo sales item so the sample
|
||||||
|
/// invoices can be posted without immediately failing stock validation.
|
||||||
|
/// This keeps the stock-check and posting flows testable on a fresh database.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedSalesStockAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var warehouse = await db.Warehouses.AsNoTracking()
|
||||||
|
.OrderBy(w => w.WarehouseId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var item = await db.Items.AsNoTracking()
|
||||||
|
.OrderBy(i => i.ItemId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var secondItem = await db.Items.AsNoTracking()
|
||||||
|
.OrderByDescending(i => i.ItemId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (warehouse is null || item is null || secondItem is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var existing = await db.StockLayers.AnyAsync(
|
||||||
|
l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m,
|
||||||
|
ct);
|
||||||
|
if (existing) return false;
|
||||||
|
|
||||||
|
db.StockLayers.Add(new StockLayer
|
||||||
|
{
|
||||||
|
ItemId = item.ItemId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
QtyReceived = 100m,
|
||||||
|
QtyRemaining = 100m,
|
||||||
|
UnitCost = item.SalePrice.GetValueOrDefault() > 0m ? item.SalePrice.GetValueOrDefault() / 2m : 25m,
|
||||||
|
ReceiptDate = DateTime.UtcNow.AddDays(-7)
|
||||||
|
});
|
||||||
|
db.StockLayers.Add(new StockLayer
|
||||||
|
{
|
||||||
|
ItemId = secondItem.ItemId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
QtyReceived = 5m,
|
||||||
|
QtyRemaining = 5m,
|
||||||
|
UnitCost = secondItem.SalePrice.GetValueOrDefault() > 0m ? secondItem.SalePrice.GetValueOrDefault() / 2m : 15m,
|
||||||
|
ReceiptDate = DateTime.UtcNow.AddDays(-6)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedWarehousesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct);
|
||||||
|
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new Warehouse { Code = "MAIN", Name = "Main Warehouse" },
|
||||||
|
new Warehouse { Code = "SHOP", Name = "Sales Counter" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.Warehouses.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedUomsAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct);
|
||||||
|
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new Uom { Name = "PCS" },
|
||||||
|
new Uom { Name = "BOX" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.Uoms.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedCategoriesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct);
|
||||||
|
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow },
|
||||||
|
new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.Categories.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedItemsAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct);
|
||||||
|
var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var category = await db.Categories.AsNoTracking()
|
||||||
|
.OrderBy(c => c.CategoryId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var uom = await db.Uoms.AsNoTracking()
|
||||||
|
.OrderBy(u => u.UomId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (category is null || uom is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new Item
|
||||||
|
{
|
||||||
|
Sku = "SKU-DEMO-001",
|
||||||
|
Name = "Demo Item 1",
|
||||||
|
Description = "Seeded sample item for sales documents",
|
||||||
|
CategoryId = category.CategoryId,
|
||||||
|
BaseUomId = uom.UomId,
|
||||||
|
StockNature = StockNature.Stocked,
|
||||||
|
TrackingMode = TrackingMode.None,
|
||||||
|
SalePrice = 100m,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = now
|
||||||
|
},
|
||||||
|
new Item
|
||||||
|
{
|
||||||
|
Sku = "SKU-DEMO-002",
|
||||||
|
Name = "Demo Item 2",
|
||||||
|
Description = "Secondary seeded sample item for sales documents",
|
||||||
|
CategoryId = category.CategoryId,
|
||||||
|
BaseUomId = uom.UomId,
|
||||||
|
StockNature = StockNature.Stocked,
|
||||||
|
TrackingMode = TrackingMode.None,
|
||||||
|
SalePrice = 50m,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.Items.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
|
||||||
|
/// a couple of customer rows, current-year document counters, and a few draft
|
||||||
|
/// invoice/slip samples when the required master data already exists.
|
||||||
|
/// This intentionally never clears or rewrites any existing rows.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
dirty |= await SeedSalesCustomersAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesSequencesAsync(db, ct);
|
||||||
|
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
||||||
|
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct);
|
||||||
|
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new Customer
|
||||||
|
{
|
||||||
|
CustomerCode = "CUST-WALKIN",
|
||||||
|
CustomerType = CustomerType.B2C,
|
||||||
|
Name = "Walk-in Customer",
|
||||||
|
DisplayName = "Walk-in Customer",
|
||||||
|
CreditLimit = 0m,
|
||||||
|
CreditDays = 0,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new Customer
|
||||||
|
{
|
||||||
|
CustomerCode = "CUST-DEMO",
|
||||||
|
CustomerType = CustomerType.B2B,
|
||||||
|
Name = "Demo Retail Ltd",
|
||||||
|
DisplayName = "Demo Retail Ltd",
|
||||||
|
Phone = "+94 11 000 0000",
|
||||||
|
Email = "sales@example.com",
|
||||||
|
AddressLine1 = "1 Demo Street",
|
||||||
|
City = "Colombo",
|
||||||
|
Country = "Sri Lanka",
|
||||||
|
TaxRegistrationNo = "VAT-DEMO-001",
|
||||||
|
CreditLimit = 250000m,
|
||||||
|
CreditDays = 30,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.Customers.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
var existing = await db.NumberSequences
|
||||||
|
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip))
|
||||||
|
.Select(s => s.DocType)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var seeds = new[]
|
||||||
|
{
|
||||||
|
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
|
||||||
|
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.NumberSequences.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var customer = await db.Customers.AsNoTracking()
|
||||||
|
.OrderBy(c => c.CustomerId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var warehouse = await db.Warehouses.AsNoTracking()
|
||||||
|
.OrderBy(w => w.WarehouseId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
|
||||||
|
.OrderByDescending(w => w.WarehouseId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var items = await db.Items.AsNoTracking()
|
||||||
|
.OrderBy(i => i.ItemId)
|
||||||
|
.Take(2)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var uom = await db.Uoms.AsNoTracking()
|
||||||
|
.OrderBy(u => u.UomId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var user = await db.Users.AsNoTracking()
|
||||||
|
.OrderBy(u => u.UserId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var postableItem = items[0];
|
||||||
|
var shortageItem = items[1];
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var createdAt = DateTime.UtcNow.AddDays(-1);
|
||||||
|
|
||||||
|
db.SalesInvoices.AddRange(
|
||||||
|
new SalesInvoice
|
||||||
|
{
|
||||||
|
InvoiceNo = $"SI-{today:yyyy}-00001",
|
||||||
|
InvoiceDate = today.AddDays(-2),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.Name,
|
||||||
|
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
InvoiceType = SalesInvoiceType.B2C,
|
||||||
|
Status = SalesInvoiceStatus.Draft,
|
||||||
|
Subtotal = 200m,
|
||||||
|
DiscountTotal = 0m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 200m,
|
||||||
|
RoundOff = 0m,
|
||||||
|
NetPayable = 200m,
|
||||||
|
PaidAmount = 0m,
|
||||||
|
BalanceAmount = 200m,
|
||||||
|
CreatedBy = user.UserId,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new SalesInvoiceLine
|
||||||
|
{
|
||||||
|
ItemId = postableItem.ItemId,
|
||||||
|
Description = postableItem.Name,
|
||||||
|
Qty = 2m,
|
||||||
|
FreeQty = 0m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = 100m,
|
||||||
|
BaseCost = 0m,
|
||||||
|
PriceSource = "seed",
|
||||||
|
DiscountMode = SalesDiscountMode.Percentage,
|
||||||
|
DiscountPct = 0m,
|
||||||
|
DiscountAmount = 0m,
|
||||||
|
NetUnitPrice = 100m,
|
||||||
|
LineTotal = 200m,
|
||||||
|
TaxPct = 0m,
|
||||||
|
TaxAmount = 0m,
|
||||||
|
IsFreeIssue = false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new SalesInvoice
|
||||||
|
{
|
||||||
|
InvoiceNo = $"SI-{today:yyyy}-00002",
|
||||||
|
InvoiceDate = today.AddDays(-1),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.Name,
|
||||||
|
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
InvoiceType = SalesInvoiceType.B2B,
|
||||||
|
Status = SalesInvoiceStatus.Draft,
|
||||||
|
Subtotal = 300m,
|
||||||
|
DiscountTotal = 0m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 300m,
|
||||||
|
RoundOff = 0m,
|
||||||
|
NetPayable = 300m,
|
||||||
|
PaidAmount = 0m,
|
||||||
|
BalanceAmount = 300m,
|
||||||
|
CreatedBy = user.UserId,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new SalesInvoiceLine
|
||||||
|
{
|
||||||
|
ItemId = shortageItem.ItemId,
|
||||||
|
Description = shortageItem.Name,
|
||||||
|
Qty = 6m,
|
||||||
|
FreeQty = 0m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
UnitPrice = 50m,
|
||||||
|
BaseCost = 0m,
|
||||||
|
PriceSource = "seed",
|
||||||
|
DiscountMode = SalesDiscountMode.Percentage,
|
||||||
|
DiscountPct = 0m,
|
||||||
|
DiscountAmount = 0m,
|
||||||
|
NetUnitPrice = 50m,
|
||||||
|
LineTotal = 300m,
|
||||||
|
TaxPct = 0m,
|
||||||
|
TaxAmount = 0m,
|
||||||
|
IsFreeIssue = false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new SalesInvoice
|
||||||
|
{
|
||||||
|
InvoiceNo = $"SI-{today:yyyy}-00003",
|
||||||
|
InvoiceDate = today,
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.Name,
|
||||||
|
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
InvoiceType = SalesInvoiceType.B2C,
|
||||||
|
Status = SalesInvoiceStatus.Posted,
|
||||||
|
Subtotal = 100m,
|
||||||
|
DiscountTotal = 0m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 100m,
|
||||||
|
RoundOff = 0m,
|
||||||
|
NetPayable = 100m,
|
||||||
|
PaidAmount = 100m,
|
||||||
|
BalanceAmount = 0m,
|
||||||
|
CreatedBy = user.UserId,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new SalesInvoiceLine
|
||||||
|
{
|
||||||
|
ItemId = postableItem.ItemId,
|
||||||
|
Description = postableItem.Name,
|
||||||
|
Qty = 1m,
|
||||||
|
FreeQty = 0m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = 100m,
|
||||||
|
BaseCost = 0m,
|
||||||
|
PriceSource = "seed",
|
||||||
|
DiscountMode = SalesDiscountMode.Percentage,
|
||||||
|
DiscountPct = 0m,
|
||||||
|
DiscountAmount = 0m,
|
||||||
|
NetUnitPrice = 100m,
|
||||||
|
LineTotal = 100m,
|
||||||
|
TaxPct = 0m,
|
||||||
|
TaxAmount = 0m,
|
||||||
|
IsFreeIssue = false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.SalesSlips.AddRange(
|
||||||
|
new SalesSlip
|
||||||
|
{
|
||||||
|
SlipNo = $"SSL-{today:yyyy}-00001",
|
||||||
|
SlipDate = today.AddDays(-2),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.Name,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
CashierUserId = user.UserId,
|
||||||
|
Status = SalesSlipStatus.Draft,
|
||||||
|
Subtotal = 50m,
|
||||||
|
DiscountTotal = 0m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 50m,
|
||||||
|
PaidAmount = 0m,
|
||||||
|
BalanceAmount = 50m,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new SalesSlipLine
|
||||||
|
{
|
||||||
|
ItemId = postableItem.ItemId,
|
||||||
|
Description = postableItem.Name,
|
||||||
|
Qty = 1m,
|
||||||
|
FreeQty = 0m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = 50m,
|
||||||
|
BaseCost = 0m,
|
||||||
|
PriceSource = "seed",
|
||||||
|
DiscountMode = SalesDiscountMode.Percentage,
|
||||||
|
DiscountPct = 0m,
|
||||||
|
DiscountAmount = 0m,
|
||||||
|
NetUnitPrice = 50m,
|
||||||
|
LineTotal = 50m,
|
||||||
|
TaxPct = 0m,
|
||||||
|
TaxAmount = 0m,
|
||||||
|
IsFreeIssue = false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new SalesSlip
|
||||||
|
{
|
||||||
|
SlipNo = $"SSL-{today:yyyy}-00002",
|
||||||
|
SlipDate = today.AddDays(-1),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.Name,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
CashierUserId = user.UserId,
|
||||||
|
Status = SalesSlipStatus.Draft,
|
||||||
|
Subtotal = 150m,
|
||||||
|
DiscountTotal = 15m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 135m,
|
||||||
|
PaidAmount = 0m,
|
||||||
|
BalanceAmount = 135m,
|
||||||
|
CreatedAt = createdAt,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new SalesSlipLine
|
||||||
|
{
|
||||||
|
ItemId = shortageItem.ItemId,
|
||||||
|
Description = shortageItem.Name,
|
||||||
|
Qty = 3m,
|
||||||
|
FreeQty = 0m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
UnitPrice = 50m,
|
||||||
|
BaseCost = 0m,
|
||||||
|
PriceSource = "seed",
|
||||||
|
DiscountMode = SalesDiscountMode.Percentage,
|
||||||
|
DiscountPct = 10m,
|
||||||
|
DiscountAmount = 15m,
|
||||||
|
NetUnitPrice = 45m,
|
||||||
|
LineTotal = 135m,
|
||||||
|
TaxPct = 0m,
|
||||||
|
TaxAmount = 0m,
|
||||||
|
IsFreeIssue = false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ERPCore.Infra.Persistence;
|
|||||||
public class ErpDbContext : DbContext
|
public class ErpDbContext : DbContext
|
||||||
{
|
{
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private bool _writingAuditLogs;
|
||||||
|
|
||||||
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,8 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||||
public DbSet<Bin> Bins => Set<Bin>();
|
public DbSet<Bin> Bins => Set<Bin>();
|
||||||
|
/// <summary>Singleton business profile used by printable documents.</summary>
|
||||||
|
public DbSet<CompanyProfile> CompanyProfiles => Set<CompanyProfile>();
|
||||||
/// <summary>Singleton row (FR-MD-11).</summary>
|
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||||
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||||
|
|
||||||
@@ -185,25 +188,45 @@ public class ErpDbContext : DbContext
|
|||||||
// persists the logs without re-auditing them.
|
// persists the logs without re-auditing them.
|
||||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var pending = AuditScribe.Capture(ChangeTracker);
|
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
|
||||||
|
? Array.Empty<PendingAudit>()
|
||||||
|
: AuditScribe.Capture(ChangeTracker);
|
||||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||||
if (pending.Count > 0)
|
if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_writingAuditLogs = true;
|
||||||
WriteAuditLogs(pending);
|
WriteAuditLogs(pending);
|
||||||
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writingAuditLogs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
{
|
{
|
||||||
var pending = AuditScribe.Capture(ChangeTracker);
|
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
|
||||||
|
? Array.Empty<PendingAudit>()
|
||||||
|
: AuditScribe.Capture(ChangeTracker);
|
||||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||||
if (pending.Count > 0)
|
if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_writingAuditLogs = true;
|
||||||
WriteAuditLogs(pending);
|
WriteAuditLogs(pending);
|
||||||
base.SaveChanges(acceptAllChangesOnSuccess);
|
base.SaveChanges(acceptAllChangesOnSuccess);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writingAuditLogs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ErpDbContext))]
|
||||||
|
[Migration("20260801000000_AddCompanyProfile")]
|
||||||
|
partial class AddCompanyProfile
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// This designer is kept minimal because the repo does not regenerate migrations here.
|
||||||
|
// EF tooling only needs the migration metadata to exist; the active model snapshot
|
||||||
|
// is updated separately.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCompanyProfile : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "company_profile",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CompanyProfileId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
TradeName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
TaxRegistrationNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
VatRegistrationNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
Country = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
Phone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
BankName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
BankBranch = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
AccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
AccountNumber = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
SwiftCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
FooterNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_company_profile", x => x.CompanyProfileId);
|
||||||
|
table.CheckConstraint("ck_company_profile_singleton", "\"CompanyProfileId\" = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_company_profile_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "company_profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ using ERPCore.Services;
|
|||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Auth;
|
using ERPCore.Services.Auth;
|
||||||
using ERPCore.Services.Hrm;
|
using ERPCore.Services.Hrm;
|
||||||
using ERPCore.Services.Interfaces;
|
|
||||||
using ERPCore.Services.Production;
|
using ERPCore.Services.Production;
|
||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
@@ -79,6 +78,7 @@ builder.Services.AddScoped<IUomService, UomService>();
|
|||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
builder.Services.AddScoped<IBrandService, BrandService>();
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||||
|
builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||||||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
@@ -103,6 +103,7 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
|||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
|
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
@@ -179,8 +180,16 @@ var app = builder.Build();
|
|||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
await DataSeeder.SeedAsync(db);
|
await DataSeeder.SeedAsync(db);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ICompanyProfileService
|
||||||
|
{
|
||||||
|
Task<ETagged<CompanyProfileDto>> GetAsync(CancellationToken ct = default);
|
||||||
|
Task<ETagged<CompanyProfileDto>> UpdateAsync(UpdateCompanyProfileRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public interface ISalesInvoiceService
|
|||||||
{
|
{
|
||||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesPromotionSuggestionService
|
||||||
|
{
|
||||||
|
Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
|
|
||||||
public interface ISalesReportService
|
public interface ISalesReportService
|
||||||
{
|
{
|
||||||
|
IReadOnlyList<SalesReportDefinitionDto> ListReports();
|
||||||
|
SalesReportDefinitionDto? GetReport(string reportId);
|
||||||
|
Task<IReadOnlyList<object>> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default);
|
Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default);
|
Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ public interface ISalesSlipService
|
|||||||
{
|
{
|
||||||
Task<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
Task<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -69,6 +69,41 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
|
||||||
|
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||||
|
|
||||||
|
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||||
|
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty<SalesInvoicePostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<SalesInvoicePostingIssueDto>();
|
||||||
|
foreach (var line in invoice.Lines)
|
||||||
|
{
|
||||||
|
var requestedQty = line.Qty + line.FreeQty;
|
||||||
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
|
if (available >= requestedQty) continue;
|
||||||
|
|
||||||
|
var item = await _items.Query().AsNoTracking()
|
||||||
|
.Where(x => x.ItemId == line.ItemId)
|
||||||
|
.Select(x => new { x.Sku, x.Name })
|
||||||
|
.FirstAsync(ct);
|
||||||
|
issues.Add(new SalesInvoicePostingIssueDto(
|
||||||
|
line.SalesInvoiceLineId,
|
||||||
|
line.ItemId,
|
||||||
|
item.Sku,
|
||||||
|
item.Name,
|
||||||
|
line.WarehouseId,
|
||||||
|
requestedQty,
|
||||||
|
available,
|
||||||
|
requestedQty - available,
|
||||||
|
line.IsFreeIssue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
||||||
@@ -228,7 +263,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
|
|
||||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||||
{
|
{
|
||||||
var computed = mode == SalesDiscountMode.FixedAmount
|
var computed = mode == SalesDiscountMode.Amount
|
||||||
? discountValue
|
? discountValue
|
||||||
: gross * (discountPct / 100m);
|
: gross * (discountPct / 100m);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||||
|
{
|
||||||
|
private const decimal FreeIssueThreshold = 10m;
|
||||||
|
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
|
||||||
|
public SalesPromotionSuggestionService(IRepository<SalesSlip> slips, IRepository<Item> items)
|
||||||
|
{
|
||||||
|
_slips = slips;
|
||||||
|
_items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||||
|
|
||||||
|
if (slip is null) return null;
|
||||||
|
|
||||||
|
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
||||||
|
var candidateItems = await _items.Query().AsNoTracking()
|
||||||
|
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||||
|
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||||
|
|
||||||
|
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
||||||
|
{
|
||||||
|
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||||
|
|
||||||
|
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
||||||
|
if (freeQty <= 0m) continue;
|
||||||
|
|
||||||
|
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||||
|
{
|
||||||
|
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||||
|
};
|
||||||
|
|
||||||
|
var alternates = await _items.Query().AsNoTracking()
|
||||||
|
.Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId)
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Take(3)
|
||||||
|
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId)));
|
||||||
|
|
||||||
|
suggestions.Add(new SalesFreeIssueSuggestionLineDto(
|
||||||
|
line.SalesSlipLineId,
|
||||||
|
item.ItemId,
|
||||||
|
item.Sku,
|
||||||
|
item.Name,
|
||||||
|
line.Qty,
|
||||||
|
freeQty,
|
||||||
|
FreeIssueThreshold,
|
||||||
|
rewardOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.Count == 0
|
||||||
|
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||||
|
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,23 @@ using ERPCore.Domain.Enums;
|
|||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
public sealed class SalesReportService : ISalesReportService
|
public sealed class SalesReportService : ISalesReportService
|
||||||
{
|
{
|
||||||
|
private static readonly SalesReportDefinitionDto[] ReportDefinitions =
|
||||||
|
[
|
||||||
|
new("daily-summary", "Daily Summary", "Aggregated sales by day across posted invoices and slips.", ["from", "to"]),
|
||||||
|
new("item-summary", "Item Summary", "Aggregated sales by item across posted invoices and slips.", ["from", "to", "itemId", "warehouseId"]),
|
||||||
|
new("customer-summary", "Customer Summary", "Aggregated sales by customer across posted invoices and slips.", ["from", "to", "customerId"]),
|
||||||
|
new("warehouse-summary", "Warehouse Summary", "Aggregated sales by warehouse across posted invoices and slips.", ["from", "to", "warehouseId"]),
|
||||||
|
new("discount-summary", "Discount Summary", "Documents with discounts applied.", ["from", "to"]),
|
||||||
|
new("free-issue-summary", "Free Issue Summary", "Lines with free quantities issued.", ["from", "to"])
|
||||||
|
];
|
||||||
|
|
||||||
private readonly IRepository<SalesInvoice> _invoices;
|
private readonly IRepository<SalesInvoice> _invoices;
|
||||||
private readonly IRepository<SalesSlip> _slips;
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
|
||||||
@@ -18,6 +29,54 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
_slips = slips;
|
_slips = slips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SalesReportDefinitionDto> ListReports() => ReportDefinitions;
|
||||||
|
|
||||||
|
public SalesReportDefinitionDto? GetReport(string reportId)
|
||||||
|
{
|
||||||
|
var normalized = reportId.Trim().ToLowerInvariant();
|
||||||
|
return ReportDefinitions.FirstOrDefault(r => r.Id == normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<object>> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var normalized = reportType.Trim().ToLowerInvariant();
|
||||||
|
ValidateFilters(normalized, itemId, customerId, warehouseId);
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"daily" or "daily-summary" => (await DailySummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||||
|
"item" or "item-summary" or "item-wise" => (await ItemSummaryAsync(from, to, itemId, warehouseId, ct)).Cast<object>().ToList(),
|
||||||
|
"customer" or "customer-summary" or "customer-wise" => (await CustomerSummaryAsync(from, to, customerId, ct)).Cast<object>().ToList(),
|
||||||
|
"warehouse" or "warehouse-summary" or "warehouse-wise" => (await WarehouseSummaryAsync(from, to, warehouseId, ct)).Cast<object>().ToList(),
|
||||||
|
"discount" or "discount-summary" => (await DiscountSummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||||
|
"free-issue" or "free-issue-summary" => (await FreeIssueSummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||||
|
_ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateFilters(string reportType, int? itemId, int? customerId, int? warehouseId)
|
||||||
|
{
|
||||||
|
var allowed = reportType switch
|
||||||
|
{
|
||||||
|
"daily" or "daily-summary" => new FilterSet(false, false, false),
|
||||||
|
"item" or "item-summary" or "item-wise" => new FilterSet(true, false, true),
|
||||||
|
"customer" or "customer-summary" or "customer-wise" => new FilterSet(false, true, false),
|
||||||
|
"warehouse" or "warehouse-summary" or "warehouse-wise" => new FilterSet(false, false, true),
|
||||||
|
"discount" or "discount-summary" => new FilterSet(false, false, false),
|
||||||
|
"free-issue" or "free-issue-summary" => new FilterSet(false, false, false),
|
||||||
|
_ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowed.Item && itemId is not null)
|
||||||
|
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'itemId' is not valid for report type '{reportType}'.", 400);
|
||||||
|
if (!allowed.Customer && customerId is not null)
|
||||||
|
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'customerId' is not valid for report type '{reportType}'.", 400);
|
||||||
|
if (!allowed.Warehouse && warehouseId is not null)
|
||||||
|
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'warehouseId' is not valid for report type '{reportType}'.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct FilterSet(bool Item, bool Customer, bool Warehouse);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoiceRows = await _invoices.Query().AsNoTracking()
|
var invoiceRows = await _invoices.Query().AsNoTracking()
|
||||||
@@ -72,10 +131,9 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoiceLines = _invoices.Query().AsNoTracking()
|
var invoiceRows = await _invoices.Query().AsNoTracking()
|
||||||
.Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
.Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue))
|
||||||
if (warehouseId is not null) invoiceLines = invoiceLines.Where(x => x.WarehouseId == warehouseId);
|
.SelectMany(x => x.Lines.Select(l => new
|
||||||
var invoiceQuery = invoiceLines.SelectMany(x => x.Lines.Select(l => new
|
|
||||||
{
|
{
|
||||||
l.ItemId,
|
l.ItemId,
|
||||||
l.Description,
|
l.Description,
|
||||||
@@ -85,14 +143,18 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
l.DiscountAmount,
|
l.DiscountAmount,
|
||||||
l.TaxAmount,
|
l.TaxAmount,
|
||||||
l.LineTotal,
|
l.LineTotal,
|
||||||
l.WarehouseId
|
x.WarehouseId
|
||||||
}));
|
}))
|
||||||
if (itemId is not null) invoiceQuery = invoiceQuery.Where(x => x.ItemId == itemId);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var slipLines = _slips.Query().AsNoTracking()
|
if (warehouseId is not null)
|
||||||
.Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
invoiceRows = invoiceRows.Where(x => x.WarehouseId == warehouseId).ToList();
|
||||||
if (warehouseId is not null) slipLines = slipLines.Where(x => x.WarehouseId == warehouseId);
|
if (itemId is not null)
|
||||||
var slipQuery = slipLines.SelectMany(x => x.Lines.Select(l => new
|
invoiceRows = invoiceRows.Where(x => x.ItemId == itemId).ToList();
|
||||||
|
|
||||||
|
var slipRows = await _slips.Query().AsNoTracking()
|
||||||
|
.Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue))
|
||||||
|
.SelectMany(x => x.Lines.Select(l => new
|
||||||
{
|
{
|
||||||
l.ItemId,
|
l.ItemId,
|
||||||
l.Description,
|
l.Description,
|
||||||
@@ -102,12 +164,18 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
l.DiscountAmount,
|
l.DiscountAmount,
|
||||||
l.TaxAmount,
|
l.TaxAmount,
|
||||||
l.LineTotal,
|
l.LineTotal,
|
||||||
l.WarehouseId
|
x.WarehouseId
|
||||||
}));
|
}))
|
||||||
if (itemId is not null) slipQuery = slipQuery.Where(x => x.ItemId == itemId);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var rows = await invoiceQuery.Concat(slipQuery)
|
if (warehouseId is not null)
|
||||||
|
slipRows = slipRows.Where(x => x.WarehouseId == warehouseId).ToList();
|
||||||
|
if (itemId is not null)
|
||||||
|
slipRows = slipRows.Where(x => x.ItemId == itemId).ToList();
|
||||||
|
|
||||||
|
return invoiceRows.Concat(slipRows)
|
||||||
.GroupBy(x => new { x.ItemId, x.Description })
|
.GroupBy(x => new { x.ItemId, x.Description })
|
||||||
|
.OrderByDescending(g => g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount))
|
||||||
.Select(g => new SalesItemSummaryRowDto(
|
.Select(g => new SalesItemSummaryRowDto(
|
||||||
g.Key.ItemId,
|
g.Key.ItemId,
|
||||||
g.Key.Description,
|
g.Key.Description,
|
||||||
@@ -116,11 +184,8 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
g.Sum(x => x.Gross),
|
g.Sum(x => x.Gross),
|
||||||
g.Sum(x => x.DiscountAmount),
|
g.Sum(x => x.DiscountAmount),
|
||||||
g.Sum(x => x.TaxAmount),
|
g.Sum(x => x.TaxAmount),
|
||||||
g.Sum(x => x.LineTotal + x.TaxAmount)))
|
g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount)))
|
||||||
.OrderByDescending(x => x.NetAmount)
|
.ToList();
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -70,6 +70,66 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<SalesSlip> q = _slips.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
|
q = q.Where(x => x.Lines.Any(l => l.IsFreeIssue || l.FreeQty > 0m));
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (status is not null) q = q.Where(x => x.Status == status);
|
||||||
|
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||||
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.SalesSlipId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
return PagedResponse<FreeIssueSummaryDto>.Create(rows.Select(MapFreeIssueSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||||
|
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
|
||||||
|
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||||
|
|
||||||
|
if (slip.Status != SalesSlipStatus.Draft)
|
||||||
|
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty<SalesSlipPostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<SalesSlipPostingIssueDto>();
|
||||||
|
foreach (var line in slip.Lines)
|
||||||
|
{
|
||||||
|
var requestedQty = line.Qty + line.FreeQty;
|
||||||
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
|
if (available >= requestedQty) continue;
|
||||||
|
|
||||||
|
var item = await _items.Query().AsNoTracking()
|
||||||
|
.Where(x => x.ItemId == line.ItemId)
|
||||||
|
.Select(x => new { x.Sku, x.Name })
|
||||||
|
.FirstAsync(ct);
|
||||||
|
issues.Add(new SalesSlipPostingIssueDto(
|
||||||
|
line.SalesSlipLineId,
|
||||||
|
line.ItemId,
|
||||||
|
item.Sku,
|
||||||
|
item.Name,
|
||||||
|
line.WarehouseId,
|
||||||
|
requestedQty,
|
||||||
|
available,
|
||||||
|
requestedQty - available,
|
||||||
|
line.IsFreeIssue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
||||||
@@ -227,7 +287,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
|
|
||||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||||
{
|
{
|
||||||
var computed = mode == SalesDiscountMode.FixedAmount
|
var computed = mode == SalesDiscountMode.Amount
|
||||||
? discountValue
|
? discountValue
|
||||||
: gross * (discountPct / 100m);
|
: gross * (discountPct / 100m);
|
||||||
|
|
||||||
@@ -241,6 +301,57 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
|
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
|
||||||
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
|
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt);
|
||||||
|
|
||||||
|
private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x)
|
||||||
|
{
|
||||||
|
var line = x.Lines.FirstOrDefault();
|
||||||
|
var item = line is null ? null : _items.Query().AsNoTracking()
|
||||||
|
.Where(i => i.ItemId == line.ItemId)
|
||||||
|
.Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId })
|
||||||
|
.FirstOrDefault();
|
||||||
|
var uom = line is null ? null : _uoms.Query().AsNoTracking()
|
||||||
|
.Where(u => u.UomId == line.UomId)
|
||||||
|
.Select(u => new { u.UomId, u.Name })
|
||||||
|
.FirstOrDefault();
|
||||||
|
var warehouse = _warehouses.Query().AsNoTracking()
|
||||||
|
.Where(w => w.WarehouseId == x.WarehouseId)
|
||||||
|
.Select(w => new { w.WarehouseId, w.Name })
|
||||||
|
.FirstOrDefault();
|
||||||
|
return new FreeIssueSummaryDto(
|
||||||
|
x.SalesSlipId,
|
||||||
|
x.SlipNo,
|
||||||
|
x.Status,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.WarehouseId,
|
||||||
|
warehouse?.Name ?? $"Warehouse {x.WarehouseId}",
|
||||||
|
line?.ItemId ?? 0,
|
||||||
|
item?.Sku ?? $"SKU-{line?.ItemId ?? 0}",
|
||||||
|
item?.Name ?? line?.Description ?? "—",
|
||||||
|
line?.UomId ?? 0,
|
||||||
|
uom?.Name ?? $"UOM {line?.UomId ?? 0}",
|
||||||
|
line?.Qty ?? 0m,
|
||||||
|
line?.FreeQty ?? 0m,
|
||||||
|
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private FreeIssueDto MapFreeIssue(SalesSlip x)
|
||||||
|
{
|
||||||
|
var summary = MapFreeIssueSummary(x);
|
||||||
|
return new FreeIssueDto(
|
||||||
|
x.SalesSlipId,
|
||||||
|
x.SlipNo,
|
||||||
|
x.SlipDate,
|
||||||
|
x.Status,
|
||||||
|
x.CustomerId,
|
||||||
|
x.CustomerSnapshotName,
|
||||||
|
x.WarehouseId,
|
||||||
|
summary.WarehouseName,
|
||||||
|
x.CashierUserId,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.UpdatedAt,
|
||||||
|
summary,
|
||||||
|
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
private static SalesSlipDto Map(SalesSlip x) => new(
|
private static SalesSlipDto Map(SalesSlip x) => new(
|
||||||
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt,
|
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt,
|
||||||
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount),
|
new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount),
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
|
|||||||
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
|
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
|
||||||
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
||||||
|
|
||||||
|
## 8. Sales
|
||||||
|
- [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved.
|
||||||
|
- [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation.
|
||||||
|
- [x] Free-issue CRUD exposed as `api/v1/free-issues` as a thin alias over sales slips. Free issue remains a line-level `IsFreeIssue` / `FreeQty` behavior, not a separate table.
|
||||||
|
|
||||||
## 0. Bootstrap
|
## 0. Bootstrap
|
||||||
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
|
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
|
||||||
- [x] Folder structure per 00-CORE §5.3
|
- [x] Folder structure per 00-CORE §5.3
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
|||||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry.
|
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry.
|
||||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
||||||
|
|
||||||
|
## 3.5 Sales screens
|
||||||
|
- [~] Sales hub (`app/dashboard/sales`) — new module entry point linking to invoices, slips, free issues, and reports
|
||||||
|
- [~] Sales invoices (`app/dashboard/sales/invoices`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales invoice API, including save/post/cancel on the detail page
|
||||||
|
- [~] Sales slips (`app/dashboard/sales/slips`, `/new`, `/[id]`) — list/detail/create/edit routing wired to the real sales slip API, including save/post/cancel on the detail page
|
||||||
|
- [~] Free issues (`app/dashboard/sales/free-issues`, `/new`, `/[id]`) — alias-only surface over sales slips for free-issue handling; edit/save/post/cancel stays on the slip screen
|
||||||
|
- [~] Sales reports (`app/dashboard/sales/reports`, `/[reportId]`) — report catalog + report metadata view wired to `/reports/sales`
|
||||||
|
|
||||||
## 4. Receiving screens
|
## 4. Receiving screens
|
||||||
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
||||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry.
|
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry.
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
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 { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { usersApi } from "@/lib/api/users"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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,
|
||||||
|
uomId: 0,
|
||||||
|
warehouseId: 0,
|
||||||
|
qty: 1,
|
||||||
|
freeQty: 0,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: "Percentage",
|
||||||
|
discountPct: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: 0,
|
||||||
|
isFreeIssue: false,
|
||||||
|
parentLineId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function NewFreeIssuePage() {
|
||||||
|
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 [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
|
const [rows, setRows] = useState<FreeIssueRow[]>([])
|
||||||
|
const [editingRowId, setEditingRowId] = useState<number | null>(null)
|
||||||
|
const [editingLines, setEditingLines] = useState<Line[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function refreshRows() {
|
||||||
|
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
||||||
|
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 uom = uoms.find((x) => x.uomId === firstLine?.uomId)
|
||||||
|
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: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
|
||||||
|
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)
|
||||||
|
setLines([
|
||||||
|
{
|
||||||
|
...blankLine("line-1"),
|
||||||
|
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||||
|
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||||
|
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await refreshRows()
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(key: string, itemId: number) {
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEditingItem(key: string, itemId: number) {
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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.uomId)) return setError("Select a valid UOM 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),
|
||||||
|
uomId: Number(line.uomId),
|
||||||
|
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)
|
||||||
|
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")])
|
||||||
|
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,
|
||||||
|
uomId: detailLine?.uomId ?? 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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingRowId(null)
|
||||||
|
setEditingLines([])
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
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>
|
||||||
|
<Button size="sm" onClick={submit} disabled={saving}>
|
||||||
|
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
{editingRowId ? (
|
||||||
|
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-sky-900">Inline edit free issue</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={cancelEdit}>
|
||||||
|
<X className="size-4" /> Cancel edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{activeLines.map((line, idx) => (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectEditingItem(line.key, Number(v))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((candidate) => (
|
||||||
|
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||||
|
{candidate.sku} - {candidate.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-44">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateEditingLine(line.key, { uomId: Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Free issue lines</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((candidate) => (
|
||||||
|
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||||
|
{candidate.sku} - {candidate.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-44">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center 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" onClick={refreshRows}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Product</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{row.slipNo}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 min-w-80">
|
||||||
|
<div className="font-medium">{row.itemName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.qty}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.freeQty}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800"
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1 size-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{row.status === "Draft" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteRow(row)}
|
||||||
|
className="inline-flex h-7 items-center rounded-full border border-rose-200 bg-rose-50 px-2 text-xs text-rose-700 hover:bg-rose-100 hover:text-rose-800"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No free issues created yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export { default } from "./new/page"
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { companyApi } from "@/lib/api/company"
|
||||||
|
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 { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
|
import { CompanyProfile } from "@/types/company"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
||||||
|
|
||||||
|
const money = new Intl.NumberFormat("en-LK", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "LKR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const blankLine = (key: string): Line => ({
|
||||||
|
key,
|
||||||
|
itemId: 0,
|
||||||
|
uomId: 0,
|
||||||
|
warehouseId: 0,
|
||||||
|
qty: 1,
|
||||||
|
freeQty: 0,
|
||||||
|
unitPrice: null,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: "Percentage",
|
||||||
|
discountPct: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: 0,
|
||||||
|
isFreeIssue: false,
|
||||||
|
parentLineId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusClass(status: SalesInvoiceStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "Draft":
|
||||||
|
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||||
|
case "Posted":
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
case "Cancelled":
|
||||||
|
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const resolvedParams = use(params)
|
||||||
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [postingCheck, setPostingCheck] = useState<SalesInvoicePostingCheck | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) {
|
||||||
|
setError(`Invalid invoice id '${resolvedParams.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
companyApi.getProfile(),
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.getInvoice(invoiceId),
|
||||||
|
])
|
||||||
|
.then(([companyRes, customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
||||||
|
setCompany(companyRes.data)
|
||||||
|
setCustomers(customerRes.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(warehouseRes.items)
|
||||||
|
setInvoice(doc.data)
|
||||||
|
setEtag(doc.etag)
|
||||||
|
setCustomerId(doc.data.customerId)
|
||||||
|
setWarehouseId(doc.data.warehouseId)
|
||||||
|
setInvoiceType(doc.data.invoiceType)
|
||||||
|
setLines(
|
||||||
|
doc.data.lines.map((line) => ({
|
||||||
|
key: String(line.salesInvoiceLineId),
|
||||||
|
itemId: line.itemId,
|
||||||
|
uomId: line.uomId,
|
||||||
|
warehouseId: line.warehouseId,
|
||||||
|
qty: line.qty,
|
||||||
|
freeQty: line.freeQty,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: line.discountMode,
|
||||||
|
discountPct: line.discountPct,
|
||||||
|
discountAmount: line.discountAmount,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: line.taxPct,
|
||||||
|
isFreeIssue: line.isFreeIssue,
|
||||||
|
parentLineId: line.parentLineId,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [invoiceId, resolvedParams.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoice || invoice.status !== "Draft") {
|
||||||
|
setPostingCheck(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
salesApi
|
||||||
|
.checkInvoicePosting(invoiceId)
|
||||||
|
.then((result) => setPostingCheck(result))
|
||||||
|
.catch(() => setPostingCheck(null))
|
||||||
|
}, [invoice, invoiceId])
|
||||||
|
|
||||||
|
const isDraft = invoice?.status === "Draft"
|
||||||
|
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
||||||
|
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
|
||||||
|
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
||||||
|
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
|
||||||
|
|
||||||
|
function updateLine(key: string, patch: Partial<Line>) {
|
||||||
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderWarehouse(nextWarehouseId: number | null) {
|
||||||
|
setWarehouseId(nextWarehouseId)
|
||||||
|
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(key: string, itemId: number) {
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateLine(key, {
|
||||||
|
itemId,
|
||||||
|
uomId: item?.baseUomId ?? 0,
|
||||||
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!customerId || !warehouseId || !etag) return
|
||||||
|
if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) {
|
||||||
|
setError("Select item, UOM and warehouse for every line.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const updated = await salesApi.updateInvoice(
|
||||||
|
invoiceId,
|
||||||
|
{
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
invoiceType,
|
||||||
|
lines: lines.map((line) => ({
|
||||||
|
itemId: Number(line.itemId),
|
||||||
|
uomId: Number(line.uomId),
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
etag,
|
||||||
|
)
|
||||||
|
setInvoice(updated.data)
|
||||||
|
setEtag(updated.etag)
|
||||||
|
toast.success("Invoice saved", updated.data.invoiceNo)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post() {
|
||||||
|
if (!postingCheck?.canPost) {
|
||||||
|
setError("Resolve stock shortages before posting this invoice.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy("post")
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const posted = await salesApi.postInvoice(invoiceId)
|
||||||
|
setInvoice(posted)
|
||||||
|
toast.success("Invoice posted", posted.invoiceNo)
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
setBusy("cancel")
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const cancelled = await salesApi.cancelInvoice(invoiceId)
|
||||||
|
setInvoice(cancelled)
|
||||||
|
toast.success("Invoice cancelled", cancelled.invoiceNo)
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !invoice) {
|
||||||
|
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice data is loading or unavailable."}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-3 print:hidden">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/sales/invoices" className={cn("inline-flex h-10 w-10 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted")}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Invoice Details</h1>
|
||||||
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}/print`} className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
<section className="bg-white print:bg-white">
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Status:</span>
|
||||||
|
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", statusClass(invoice.status))}>{invoice.status}</span>
|
||||||
|
<span>Type: {invoice.invoiceType}</span>
|
||||||
|
<span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm md:text-right">
|
||||||
|
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{company?.addressLine1 ?? ""}
|
||||||
|
{company?.city ? `, ${company.city}` : ""}
|
||||||
|
</div>
|
||||||
|
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-5 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||||
|
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||||
|
{customer?.displayName ? <div className="text-sm text-muted-foreground">Registered name: {customer.displayName}</div> : null}
|
||||||
|
{invoice.customerSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||||
|
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Location: {warehouse?.location ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.subtotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div>
|
||||||
|
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||||
|
<div className="text-muted-foreground">Tax</div>
|
||||||
|
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Net payable</div>
|
||||||
|
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border-y">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Free</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Discount</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Tax</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Line total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<tr key={line.salesInvoiceLineId} className="border-t border-border">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{money.format(line.discountAmount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{money.format(line.taxAmount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||||
|
<div className="py-5">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
|
||||||
|
<div key={line.salesInvoiceLineId} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="text-foreground">{line.description}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty: {line.freeQty.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||||
|
<div className="border-t pt-5">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||||
|
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">Item</th>
|
||||||
|
<th className="py-1 pr-3">Warehouse</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Requested</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Available</th>
|
||||||
|
<th className="py-1 text-right">Short</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{postingCheck.issues.map((issue) => (
|
||||||
|
<tr key={issue.salesInvoiceLineId} className="border-t border-amber-200/60">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<div className="font-medium">{issue.itemSku}</div>
|
||||||
|
<div className="text-xs text-amber-900/70">{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.availableQty.toFixed(0)}</td>
|
||||||
|
<td className="py-2 text-right font-mono tabular-nums">{issue.shortQty.toFixed(0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="border-t pt-5">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{company?.footerNote ?? "Standard invoice template view."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t pt-5 print:hidden">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Actions</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">{isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDraft ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={addLine} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted">
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
|
||||||
|
<Save className="size-4" />
|
||||||
|
{saving ? "Saving..." : "Save invoice"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
|
<Send className="size-4" />
|
||||||
|
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={cancel} disabled={busy !== null} className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-300 bg-rose-50 px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 disabled:opacity-50">
|
||||||
|
<X className="size-4" />
|
||||||
|
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Free</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<tr key={line.key} className="border-t border-border align-middle">
|
||||||
|
<td className="px-4 py-3 min-w-64">
|
||||||
|
<select
|
||||||
|
value={line.itemId ? String(line.itemId) : ""}
|
||||||
|
onChange={(e) => selectItem(line.key, Number(e.target.value))}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select item</option>
|
||||||
|
{items.map((i) => (
|
||||||
|
<option key={i.itemId} value={String(i.itemId)}>
|
||||||
|
{i.sku} - {i.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 min-w-40">
|
||||||
|
<select
|
||||||
|
value={line.uomId ? String(line.uomId) : ""}
|
||||||
|
onChange={(e) => updateLine(line.key, { uomId: Number(e.target.value) })}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">UOM</option>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<option key={u.uomId} value={String(u.uomId)}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-28">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.qty}
|
||||||
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-28">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.freeQty}
|
||||||
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-32">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={line.unitPrice ?? ""}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-24 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeLine(line.key)}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Minus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Customer</Label>
|
||||||
|
<select
|
||||||
|
value={customerId ? String(customerId) : ""}
|
||||||
|
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select customer</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.customerId} value={String(c.customerId)}>
|
||||||
|
{c.customerCode} - {c.displayName ?? c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Warehouse</Label>
|
||||||
|
<select
|
||||||
|
value={warehouseId ? String(warehouseId) : ""}
|
||||||
|
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select warehouse</option>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Invoice type</Label>
|
||||||
|
<select
|
||||||
|
value={invoiceType}
|
||||||
|
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||||
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="B2B">B2B</option>
|
||||||
|
<option value="B2C">B2C</option>
|
||||||
|
<option value="Cash">Cash</option>
|
||||||
|
<option value="Credit">Credit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={post}
|
||||||
|
disabled={!canPost}
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-full bg-black px-5 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="size-4" />
|
||||||
|
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
This invoice is {invoice.status.toLowerCase()} and cannot be edited.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { companyApi } from "@/lib/api/company"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { CompanyProfile } from "@/types/company"
|
||||||
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { SalesInvoice } from "@/types/sales"
|
||||||
|
|
||||||
|
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
||||||
|
const invoiceId = Number(params.id)
|
||||||
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) {
|
||||||
|
setError(`Invalid invoice id '${params.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
companyApi.getProfile(),
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.getInvoice(invoiceId),
|
||||||
|
])
|
||||||
|
.then(([companyRes, cust, itemRes, uomRes, whRes, doc]) => {
|
||||||
|
setCompany(companyRes.data)
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setInvoice(doc.data)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [params.id, invoiceId])
|
||||||
|
|
||||||
|
const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice])
|
||||||
|
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
||||||
|
|
||||||
|
if (error && !invoice) {
|
||||||
|
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
||||||
|
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 print:block print:gap-0">
|
||||||
|
<div className="flex items-center justify-between gap-3 print:hidden">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
||||||
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => window.print()}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invoice-sheet rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||||
|
<div className="invoice-header mb-6 grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm md:justify-items-end">
|
||||||
|
<div className="font-semibold text-foreground">{company?.tradeName ?? company?.legalName ?? "ERP Core Trading"}</div>
|
||||||
|
{company?.logoUrl ? <img src={company.logoUrl} alt={company.tradeName ?? company.legalName} className="h-12 w-auto object-contain" /> : null}
|
||||||
|
<div className="text-muted-foreground">{company?.addressLine1}{company?.city ? `, ${company.city}` : ""}</div>
|
||||||
|
{company?.taxRegistrationNo ? <div className="text-muted-foreground">Tax No: {company.taxRegistrationNo}</div> : null}
|
||||||
|
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
||||||
|
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
||||||
|
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
||||||
|
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
|
||||||
|
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[34%]">Item</TableHead>
|
||||||
|
<TableHead>UOM</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Free</TableHead>
|
||||||
|
<TableHead className="text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="text-right">Discount</TableHead>
|
||||||
|
<TableHead className="text-right">Tax</TableHead>
|
||||||
|
<TableHead className="text-right">Line total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<TableRow key={line.salesInvoiceLineId}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-dashed p-4 print:break-inside-avoid">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
|
||||||
|
<div key={line.salesInvoiceLineId} className="rounded-xl border bg-emerald-500/5 p-3">
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">Invoice qty: {line.qty.toFixed(2)}</div>
|
||||||
|
<div className="mt-1 text-sm text-foreground">Free qty: {line.freeQty.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{company?.footerNote ?? "Standard invoice print view."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border p-4">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>{invoice.totals.subtotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Discount total</span><span>{invoice.totals.discountTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Free qty total</span><span>{invoice.totals.freeQtyTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Tax total</span><span>{invoice.totals.taxTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between border-t pt-2 text-base font-semibold"><span>Net payable</span><span>{invoice.totals.netPayable.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-muted-foreground"><span>Paid</span><span>{invoice.totals.paidAmount.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-muted-foreground"><span>Balance</span><span>{invoice.totals.balanceAmount.toFixed(2)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{company ? (
|
||||||
|
<div className="mt-6 rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Bank Details</div>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm md:grid-cols-2">
|
||||||
|
<div><span className="text-muted-foreground">Bank</span> <span className="font-medium">{company.bankName ?? "—"}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Branch</span> <span className="font-medium">{company.bankBranch ?? "—"}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Account Name</span> <span className="font-medium">{company.accountName ?? "—"}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Account No</span> <span className="font-medium">{company.accountNumber ?? "—"}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">SWIFT</span> <span className="font-medium">{company.swiftCode ?? "—"}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">VAT</span> <span className="font-medium">{company.vatRegistrationNo ?? "—"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Minus, Plus, Save, Trash2 } 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 { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-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 { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
||||||
|
type ActiveFocScheme = {
|
||||||
|
id: number
|
||||||
|
slipNo: string
|
||||||
|
schemeLabel: string
|
||||||
|
warehouseName: string
|
||||||
|
productLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const blankLine = (key: string): Line => ({
|
||||||
|
key,
|
||||||
|
itemId: 0,
|
||||||
|
uomId: 0,
|
||||||
|
warehouseId: 0,
|
||||||
|
qty: 1,
|
||||||
|
freeQty: 0,
|
||||||
|
unitPrice: null,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: "Percentage",
|
||||||
|
discountPct: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: 0,
|
||||||
|
isFreeIssue: false,
|
||||||
|
parentLineId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lkr = new Intl.NumberFormat("en-LK", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "LKR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function NewSalesInvoicePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
||||||
|
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
|
const [activeFocSchemes, setActiveFocSchemes] = useState<ActiveFocScheme[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.listFreeIssues({ pageSize: 20 }),
|
||||||
|
])
|
||||||
|
.then(([cust, itemRes, uomRes, whRes, freeIssueRes]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
|
||||||
|
setCustomerId(cust.items[0]?.customerId ?? null)
|
||||||
|
setWarehouseId(defaultWarehouseId)
|
||||||
|
setLines([
|
||||||
|
{
|
||||||
|
...blankLine("line-1"),
|
||||||
|
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||||
|
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||||
|
warehouseId: defaultWarehouseId ?? 0,
|
||||||
|
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setActiveFocSchemes(
|
||||||
|
freeIssueRes.items.flatMap((issue) => {
|
||||||
|
const firstLine = issue.lines?.[0]
|
||||||
|
if (!firstLine) return []
|
||||||
|
const item = itemRes.items.find((candidate) => candidate.itemId === firstLine.itemId)
|
||||||
|
const warehouse = whRes.items.find((candidate) => candidate.warehouseId === issue.warehouseId)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: issue.salesSlipId,
|
||||||
|
slipNo: issue.slipNo,
|
||||||
|
schemeLabel: `Buy ${firstLine.qty} Get ${firstLine.freeQty || 0}`,
|
||||||
|
warehouseName: warehouse?.name ?? `Warehouse ${issue.warehouseId}`,
|
||||||
|
productLabel: `${item?.sku ?? `SKU-${firstLine.itemId}`} - ${item?.name ?? firstLine.itemName ?? `Item ${firstLine.itemId}`}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => setSubmitError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function updateLine(key: string, patch: Partial<Line>) {
|
||||||
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderWarehouse(nextWarehouseId: number | null) {
|
||||||
|
setWarehouseId(nextWarehouseId)
|
||||||
|
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(key: string, itemId: number) {
|
||||||
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
updateLine(key, {
|
||||||
|
itemId,
|
||||||
|
uomId: item?.baseUomId ?? 0,
|
||||||
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...blankLine(`line-${Date.now()}`),
|
||||||
|
warehouseId: warehouseId ?? 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossTotal = useMemo(
|
||||||
|
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const discountTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
lines.reduce((sum, line) => {
|
||||||
|
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const mode = String(line.discountMode)
|
||||||
|
return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100))
|
||||||
|
}, 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const taxTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
lines.reduce((sum, line) => {
|
||||||
|
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const mode = String(line.discountMode)
|
||||||
|
const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
|
||||||
|
const taxable = Math.max(0, gross - discount)
|
||||||
|
return sum + taxable * (Number(line.taxPct || 0) / 100)
|
||||||
|
}, 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const netTotal = Math.max(0, grossTotal - discountTotal)
|
||||||
|
const payableTotal = netTotal + taxTotal
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||||
|
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
||||||
|
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
||||||
|
if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.")
|
||||||
|
|
||||||
|
const payload: CreateSalesInvoiceRequest = {
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
invoiceType,
|
||||||
|
lines: lines.map((line) => ({
|
||||||
|
itemId: Number(line.itemId),
|
||||||
|
uomId: Number(line.uomId),
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
const created = await salesApi.createInvoice(payload)
|
||||||
|
toast.success("Invoice created", created.data.invoiceNo)
|
||||||
|
router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) {
|
||||||
|
router.push(`/login?next=/dashboard/sales/invoices/new`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitError(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="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/invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">New invoice</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={submit} disabled={saving}>
|
||||||
|
<Save className="size-4" /> {saving ? "Saving..." : "Create & open invoice"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
|
<h2 className="text-sm font-semibold">Invoice header</h2>
|
||||||
|
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Customer</Label>
|
||||||
|
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select customer" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<SelectItem key={c.customerId} value={String(c.customerId)}>
|
||||||
|
{c.customerCode} - {c.displayName ?? c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Warehouse</Label>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select warehouse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Invoice type</Label>
|
||||||
|
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="B2B">B2B</SelectItem>
|
||||||
|
<SelectItem value="B2C">B2C</SelectItem>
|
||||||
|
<SelectItem value="Cash">Cash</SelectItem>
|
||||||
|
<SelectItem value="Credit">Credit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Invoice summary</Label>
|
||||||
|
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
<span>{lkr.format(payableTotal)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Invoice lines</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">#</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line, idx) => {
|
||||||
|
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const lineDiscount =
|
||||||
|
line.discountMode === "Amount"
|
||||||
|
? Number(line.discountAmount || 0)
|
||||||
|
: lineGross * (Number(line.discountPct || 0) / 100)
|
||||||
|
const lineNet = Math.max(0, lineGross - lineDiscount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-64">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((candidate) => (
|
||||||
|
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||||
|
{candidate.sku} - {candidate.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-36">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.qty}
|
||||||
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.freeQty}
|
||||||
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={line.unitPrice ?? ""}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.5"
|
||||||
|
value={line.discountPct}
|
||||||
|
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-muted-foreground"
|
||||||
|
onClick={() => removeLine(line.key)}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex justify-end">
|
||||||
|
{/* <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
|
<h3 className="text-sm font-semibold">Active FOC schemes</h3>
|
||||||
|
<ul className="mt-3 space-y-2 text-sm">
|
||||||
|
{activeFocSchemes.length > 0 ? (
|
||||||
|
activeFocSchemes.map((scheme) => (
|
||||||
|
<li key={scheme.id} className="rounded-md border border-border px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium">{scheme.schemeLabel}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{scheme.slipNo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{scheme.productLabel}</span>
|
||||||
|
<span>{scheme.warehouseName}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
No active free-issue schemes found.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<dl className="w-full max-w-sm rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]">
|
||||||
|
{[
|
||||||
|
["Gross", lkr.format(grossTotal)],
|
||||||
|
["Discount", `-${lkr.format(discountTotal)}`],
|
||||||
|
["Net", lkr.format(netTotal)],
|
||||||
|
["Tax", lkr.format(taxTotal)],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex items-center justify-between py-1.5">
|
||||||
|
<dt className="text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<dt className="font-semibold">Payable</dt>
|
||||||
|
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" onClick={submit} disabled={saving}>
|
||||||
|
<Save className="size-4" /> {saving ? "Saving..." : "Save invoice"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronLeft, ChevronRight, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||||
|
|
||||||
|
function statusClass(status: SalesInvoiceStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "Draft":
|
||||||
|
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||||
|
case "Posted":
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
case "Cancelled":
|
||||||
|
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesInvoicesPage() {
|
||||||
|
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [searchInput, setSearchInput] = useState("")
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
useEffect(() => setPage(1), [query, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null)
|
||||||
|
salesApi
|
||||||
|
.listInvoices({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
||||||
|
.then((res) => {
|
||||||
|
setRows(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [page, status])
|
||||||
|
|
||||||
|
const visibleRows = useMemo(
|
||||||
|
() =>
|
||||||
|
rows?.filter((row) =>
|
||||||
|
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||||
|
) ?? [],
|
||||||
|
[rows, query]
|
||||||
|
)
|
||||||
|
const hasFilters = status !== "All" || query.length > 0
|
||||||
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Invoices</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
<Printer className="size-5" />
|
||||||
|
Print batch
|
||||||
|
</Button>
|
||||||
|
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Invoice
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setStatus(t)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Filter by customer or invoice number"
|
||||||
|
className="h-12 w-full lg:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||||
|
<Filter className="size-4" />
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!error && rows === null && (
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && rows !== null && visibleRows.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||||
|
<FileText className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">{hasFilters ? "No invoices match your filters." : "No invoices yet."}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && rows !== null && visibleRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Due</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">View</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<TableRow key={row.salesInvoiceId} className="hover:bg-muted/40">
|
||||||
|
<TableCell className="px-4 py-3.5 font-medium">
|
||||||
|
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
||||||
|
{row.invoiceNo}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<Badge variant="outline" className={statusClass(row.status)}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View invoice ${row.invoiceNo}`}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t px-4 py-3">
|
||||||
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Gross total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Discount total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Net total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: "Invoices",
|
||||||
|
description: "Create and manage sales invoices.",
|
||||||
|
href: "/dashboard/sales/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Slips",
|
||||||
|
description: "Counter-style sales documents.",
|
||||||
|
href: "/dashboard/sales/slips",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Free Issues",
|
||||||
|
description: "Promotional free-issue slips.",
|
||||||
|
href: "/dashboard/sales/free-issues",
|
||||||
|
icon: PackageX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
description: "Sales report catalog and query entry point.",
|
||||||
|
href: "/dashboard/sales/reports",
|
||||||
|
icon: FileBarChart,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SalesHubPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
|
||||||
|
<ReceiptText className="size-4" />
|
||||||
|
Sales
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Invoices, slips, free issues, and reporting in one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={section.href}
|
||||||
|
href={section.href}
|
||||||
|
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
|
||||||
|
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>
|
||||||
|
Open
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { use, useEffect, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, FileBarChart } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { SalesReportDefinition } from "@/types/sales"
|
||||||
|
|
||||||
|
function formatHeader(key: string) {
|
||||||
|
return key
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return ""
|
||||||
|
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||||
|
if (isoDate) {
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return new Intl.DateTimeFormat("en-LK", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") return value ? "Yes" : "No"
|
||||||
|
if (Array.isArray(value)) return value.map((item) => formatCell(item)).join(", ")
|
||||||
|
if (typeof value === "object") return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportColumns: Record<string, string[]> = {
|
||||||
|
"daily-summary": ["date", "invoiceCount", "slipCount", "invoiceSubtotal", "slipSubtotal", "discountTotal", "freeQtyTotal", "taxTotal", "grandTotal"],
|
||||||
|
"item-summary": ["itemId", "itemName", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"],
|
||||||
|
"customer-summary": ["customerId", "customerName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"],
|
||||||
|
"warehouse-summary": ["warehouseId", "warehouseName", "invoiceCount", "slipCount", "soldQty", "freeQty", "grossAmount", "discountTotal", "taxTotal", "netAmount"],
|
||||||
|
"discount-summary": ["documentType", "documentNo", "documentDate", "customerName", "subtotal", "discountTotal", "taxTotal", "netAmount"],
|
||||||
|
"free-issue-summary": ["documentType", "documentNo", "documentDate", "customerName", "itemId", "itemName", "freeQty", "freeValue", "warehouseId", "warehouseName"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesReportDetailPage({ params }: { params: Promise<{ reportId: string }> }) {
|
||||||
|
const resolvedParams = use(params)
|
||||||
|
const [report, setReport] = useState<SalesReportDefinition | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [from, setFrom] = useState("")
|
||||||
|
const [to, setTo] = useState("")
|
||||||
|
const [queryLoading, setQueryLoading] = useState(false)
|
||||||
|
const [rows, setRows] = useState<unknown[] | null>(null)
|
||||||
|
const [queryError, setQueryError] = useState<string | null>(null)
|
||||||
|
const lastAutoRunKey = useRef<string>("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
salesApi.getReport(resolvedParams.reportId)
|
||||||
|
.then(setReport)
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [resolvedParams.reportId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
setFrom(firstDay.toISOString().slice(0, 10))
|
||||||
|
setTo(now.toISOString().slice(0, 10))
|
||||||
|
}, [resolvedParams.reportId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!report || !from || !to) return
|
||||||
|
|
||||||
|
const runKey = `${report.id}:${from}:${to}`
|
||||||
|
if (lastAutoRunKey.current === runKey) return
|
||||||
|
|
||||||
|
lastAutoRunKey.current = runKey
|
||||||
|
setQueryError(null)
|
||||||
|
setQueryLoading(true)
|
||||||
|
salesApi
|
||||||
|
.queryReport({
|
||||||
|
reportType: report.id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
})
|
||||||
|
.then((response) => setRows(response.rows))
|
||||||
|
.catch((err) => {
|
||||||
|
setRows(null)
|
||||||
|
setQueryError(errorMessage(err))
|
||||||
|
})
|
||||||
|
.finally(() => setQueryLoading(false))
|
||||||
|
}, [report, from, to])
|
||||||
|
|
||||||
|
const runReport = () => {
|
||||||
|
if (!report) return
|
||||||
|
if (!from || !to) {
|
||||||
|
setQueryError("Select both from and to dates.")
|
||||||
|
setRows(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAutoRunKey.current = `${report.id}:${from}:${to}`
|
||||||
|
setQueryError(null)
|
||||||
|
setQueryLoading(true)
|
||||||
|
salesApi
|
||||||
|
.queryReport({
|
||||||
|
reportType: report.id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
})
|
||||||
|
.then((response) => setRows(response.rows))
|
||||||
|
.catch((err) => {
|
||||||
|
setRows(null)
|
||||||
|
setQueryError(errorMessage(err))
|
||||||
|
})
|
||||||
|
.finally(() => setQueryLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = report ? (reportColumns[report.id] ?? (rows && rows.length > 0 ? Object.keys(rows[0] as Record<string, unknown>) : [])) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/sales/reports" className={buttonVariants({ variant: "outline", size: "icon" })}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Report Details</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Metadata for the selected sales report.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!error && loading && <Skeleton className="h-48 rounded-2xl" />}
|
||||||
|
|
||||||
|
{!error && !loading && report === null && (
|
||||||
|
<div className="rounded-2xl border border-dashed p-8 text-base text-muted-foreground">
|
||||||
|
Report metadata could not be loaded. The report id may be invalid, or your session may have expired.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && report && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<FileBarChart className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{report.name}</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{report.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-base text-muted-foreground">{report.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{resolvedParams.reportId !== "daily-summary" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-4 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">From</label>
|
||||||
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">To</label>
|
||||||
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="button" className="w-full sm:w-auto" onClick={runReport} disabled={queryLoading}>
|
||||||
|
Run report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queryError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{queryError}</div>}
|
||||||
|
|
||||||
|
{queryLoading && <Skeleton className="h-40 rounded-2xl" />}
|
||||||
|
|
||||||
|
{!queryLoading && rows && rows.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-sm text-muted-foreground">
|
||||||
|
No rows returned for the selected date range.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!queryLoading && rows && rows.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Report Results</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{columns.map((key) => (
|
||||||
|
<th key={key} className="px-4 py-2 text-left font-medium">
|
||||||
|
{formatHeader(key)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const record = row as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<tr key={index} className="border-t border-border hover:bg-secondary/40">
|
||||||
|
{columns.map((key) => (
|
||||||
|
<td key={key} className="px-4 py-2.5 align-top">
|
||||||
|
{formatCell(record[key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { BarChart3, CalendarRange, FileBarChart, ShoppingCart, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { SalesReportDefinition } from "@/types/sales"
|
||||||
|
|
||||||
|
const reportIcons: Record<string, LucideIcon> = {
|
||||||
|
sales: BarChart3,
|
||||||
|
invoice: FileBarChart,
|
||||||
|
product: ShoppingCart,
|
||||||
|
period: CalendarRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesReportsPage() {
|
||||||
|
const [reports, setReports] = useState<SalesReportDefinition[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null)
|
||||||
|
salesApi.listReports().then(setReports).catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Reports</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Card-based report hub with the same layout and handling style used across stock management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!error && reports === null && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-40 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && reports && reports.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<FileBarChart className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No sales reports are available.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && reports && reports.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<Link key={report.id} href={`/dashboard/sales/reports/${report.id}`}>
|
||||||
|
<Card className="h-full transition-shadow group-hover:shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
{(() => {
|
||||||
|
const Icon = reportIcons[report.id.toLowerCase()] ?? FileBarChart
|
||||||
|
return <Icon className="size-5" />
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{report.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-base text-muted-foreground">{report.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Minus, Plus, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
|
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 { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { usersApi } from "@/lib/api/users"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
|
import { FreeIssuePromotionSuggestions } from "@/components/sales/FreeIssuePromotionSuggestions"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
|
import { ManagedUser } from "@/types/users"
|
||||||
|
import { CreateSalesSlipLineRequest, SalesFreeIssueSuggestion, SalesSlip, SalesSlipPostingCheck } from "@/types/sales"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||||
|
|
||||||
|
const money = new Intl.NumberFormat("en-LK", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "LKR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const blankLine = (key: string): Line => ({
|
||||||
|
key,
|
||||||
|
itemId: 0,
|
||||||
|
uomId: 0,
|
||||||
|
warehouseId: 0,
|
||||||
|
qty: 1,
|
||||||
|
freeQty: 0,
|
||||||
|
unitPrice: null,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: "Percentage",
|
||||||
|
discountPct: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: 0,
|
||||||
|
isFreeIssue: false,
|
||||||
|
parentLineId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const resolvedParams = use(params)
|
||||||
|
const slipId = Number(resolvedParams.id)
|
||||||
|
const [slip, setSlip] = useState<SalesSlip | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
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 [promotionSuggestion, setPromotionSuggestion] = useState<SalesFreeIssueSuggestion | null>(null)
|
||||||
|
const [postingCheck, setPostingCheck] = useState<SalesSlipPostingCheck | null>(null)
|
||||||
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(slipId)) {
|
||||||
|
setError(`Invalid slip id '${resolvedParams.id}'.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
salesApi.getSlip(slipId),
|
||||||
|
])
|
||||||
|
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
|
||||||
|
setCustomers(cust.items)
|
||||||
|
setItems(itemRes.items)
|
||||||
|
setUoms(uomRes.items)
|
||||||
|
setWarehouses(whRes.items)
|
||||||
|
setUsers(userRes.items)
|
||||||
|
setSlip(doc.data)
|
||||||
|
setEtag(doc.etag)
|
||||||
|
setCustomerId(doc.data.customerId)
|
||||||
|
setWarehouseId(doc.data.warehouseId)
|
||||||
|
setCashierUserId(doc.data.cashierUserId)
|
||||||
|
setPromotionSuggestion(null)
|
||||||
|
setLines(
|
||||||
|
doc.data.lines.map((line) => ({
|
||||||
|
key: String(line.salesSlipLineId),
|
||||||
|
itemId: line.itemId,
|
||||||
|
uomId: line.uomId,
|
||||||
|
warehouseId: line.warehouseId,
|
||||||
|
qty: line.qty,
|
||||||
|
freeQty: line.freeQty,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: line.discountMode,
|
||||||
|
discountPct: line.discountPct,
|
||||||
|
discountAmount: line.discountAmount,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: line.taxPct,
|
||||||
|
isFreeIssue: line.isFreeIssue,
|
||||||
|
parentLineId: line.parentLineId,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await salesApi.getFreeIssueSuggestions(slipId)
|
||||||
|
setPromotionSuggestion(suggestions)
|
||||||
|
} catch {
|
||||||
|
setPromotionSuggestion(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [resolvedParams.id, slipId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slip || slip.status !== "Draft") {
|
||||||
|
setPostingCheck(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
salesApi
|
||||||
|
.checkSlipPosting(slipId)
|
||||||
|
.then((result) => setPostingCheck(result))
|
||||||
|
.catch(() => setPostingCheck(null))
|
||||||
|
}, [slip, slipId])
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice ?? 0), 0),
|
||||||
|
[lines]
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateLine(key: string, patch: Partial<Line>) {
|
||||||
|
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(key: string, itemId: number) {
|
||||||
|
updateLine(key, {
|
||||||
|
itemId,
|
||||||
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!customerId || !warehouseId || !cashierUserId || !etag) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
cashierUserId,
|
||||||
|
lines: lines.map((line) => ({
|
||||||
|
itemId: Number(line.itemId),
|
||||||
|
uomId: Number(line.uomId),
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const updated = await salesApi.updateSlip(slipId, payload, etag)
|
||||||
|
setSlip(updated.data)
|
||||||
|
setEtag(updated.etag)
|
||||||
|
toast.success("Slip saved", updated.data.slipNo)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post() {
|
||||||
|
if (!postingCheck?.canPost) {
|
||||||
|
setError("Resolve stock shortages before posting this slip.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActionBusy("post")
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const posted = await salesApi.postSlip(slipId)
|
||||||
|
setSlip(posted)
|
||||||
|
toast.success("Slip posted", posted.slipNo)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
setActionBusy("cancel")
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const cancelled = await salesApi.cancelSlip(slipId)
|
||||||
|
setSlip(cancelled)
|
||||||
|
toast.success("Slip cancelled", cancelled.slipNo)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !slip) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
if (!slip) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip data is loading or unavailable."}</div>
|
||||||
|
|
||||||
|
const locked = slip.status !== "Draft"
|
||||||
|
const canPost = slip.status === "Draft" && (postingCheck?.canPost ?? true) && actionBusy === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Slip</h1>
|
||||||
|
<p className="text-base text-muted-foreground">{slip.slipNo} · {slip.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={save} disabled={saving || locked}><Save className="size-4" />{saving ? "Saving..." : "Save"}</Button>
|
||||||
|
<Button variant="outline" onClick={post} disabled={!canPost}><Send className="size-4" />{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : actionBusy === "post" ? "Posting..." : "Post"}</Button>
|
||||||
|
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
<section className="bg-white">
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Slip</div>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h2>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Status:</span>
|
||||||
|
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", slip.status === "Draft" ? "border-amber-200 bg-amber-50 text-amber-800" : slip.status === "Posted" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800")}>
|
||||||
|
{slip.status}
|
||||||
|
</span>
|
||||||
|
<span>Date: {new Date(slip.slipDate).toLocaleDateString()}</span>
|
||||||
|
<span>Cashier: {users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm md:text-right">
|
||||||
|
<div className="font-semibold text-foreground">{customers.find((c) => c.customerId === slip.customerId)?.displayName ?? customers.find((c) => c.customerId === slip.customerId)?.name ?? slip.customerSnapshotName}</div>
|
||||||
|
<div className="text-muted-foreground">Warehouse: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
|
||||||
|
<div className="text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-5 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
||||||
|
<div className="mt-2 font-semibold text-foreground">{slip.customerSnapshotName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
||||||
|
<div className="mt-2 font-semibold text-foreground">{warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="text-muted-foreground">Subtotal</div>
|
||||||
|
<div className="text-right font-medium">{money.format(slip.totals.subtotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Discount</div>
|
||||||
|
<div className="text-right font-medium">{money.format(slip.totals.discountTotal)}</div>
|
||||||
|
<div className="text-muted-foreground">Free qty</div>
|
||||||
|
<div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(0)}</div>
|
||||||
|
<div className="text-muted-foreground">Grand total</div>
|
||||||
|
<div className="text-right font-semibold">{money.format(slip.totals.grandTotal)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border-y">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{slip.lines.map((line) => (
|
||||||
|
<TableRow key={line.salesSlipLineId}>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||||
|
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">Item</th>
|
||||||
|
<th className="py-1 pr-3">Warehouse</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Requested</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Available</th>
|
||||||
|
<th className="py-1 text-right">Short</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{postingCheck.issues.map((issue) => (
|
||||||
|
<tr key={issue.salesSlipLineId} className="border-t border-amber-200/60">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<div className="font-medium">{issue.itemSku}</div>
|
||||||
|
<div className="text-xs text-amber-900/70">{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.availableQty.toFixed(0)}</td>
|
||||||
|
<td className="py-2 text-right font-mono tabular-nums">{issue.shortQty.toFixed(0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{slip.status === "Draft" ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Customer</Label>
|
||||||
|
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)} disabled={locked}>
|
||||||
|
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Warehouse</Label>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)} disabled={locked}>
|
||||||
|
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Cashier</Label>
|
||||||
|
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)} disabled={locked}>
|
||||||
|
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Lines</h2>
|
||||||
|
<Button type="button" variant="outline" onClick={addLine} disabled={locked}><Plus className="size-4" /> Add line</Button>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Item</TableHead>
|
||||||
|
<TableHead>UOM</TableHead>
|
||||||
|
<TableHead>Qty</TableHead>
|
||||||
|
<TableHead>Free</TableHead>
|
||||||
|
<TableHead>Unit price</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<TableRow key={line.key}>
|
||||||
|
<TableCell>
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))} disabled={locked}>
|
||||||
|
<SelectTrigger className="h-11!"><SelectValue placeholder="Item" /></SelectTrigger>
|
||||||
|
<SelectContent>{items.map((i) => <SelectItem key={i.itemId} value={String(i.itemId)}>{i.sku} - {i.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
|
||||||
|
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
|
||||||
|
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||||
|
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||||
|
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||||
|
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="mt-4 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FreeIssuePromotionSuggestions suggestion={promotionSuggestion} />
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Back</Link>
|
||||||
|
<Button variant="outline" onClick={save} disabled={saving || locked}>Save</Button>
|
||||||
|
<Button onClick={post} disabled={!canPost}>Post</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Minus, Plus, Save, Trash2 } 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 { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-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 { 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"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||||
|
|
||||||
|
const blankLine = (key: string): Line => ({
|
||||||
|
key,
|
||||||
|
itemId: 0,
|
||||||
|
uomId: 0,
|
||||||
|
warehouseId: 0,
|
||||||
|
qty: 1,
|
||||||
|
freeQty: 0,
|
||||||
|
unitPrice: null,
|
||||||
|
allowManualPriceOverride: true,
|
||||||
|
discountMode: "Percentage",
|
||||||
|
discountPct: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
discountValue: 0,
|
||||||
|
taxPct: 0,
|
||||||
|
isFreeIssue: false,
|
||||||
|
parentLineId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lkr = new Intl.NumberFormat("en-LK", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "LKR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function NewSalesSlipPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
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 [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
customersApi.list({ pageSize: 200 }),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([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)
|
||||||
|
setLines([
|
||||||
|
{
|
||||||
|
...blankLine("line-1"),
|
||||||
|
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||||
|
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||||
|
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||||
|
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.catch((err) => setSubmitError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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,
|
||||||
|
uomId: item?.baseUomId ?? 0,
|
||||||
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossTotal = useMemo(
|
||||||
|
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const discountTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
lines.reduce((sum, line) => {
|
||||||
|
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const mode = String(line.discountMode)
|
||||||
|
return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100))
|
||||||
|
}, 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const taxTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
lines.reduce((sum, line) => {
|
||||||
|
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const mode = String(line.discountMode)
|
||||||
|
const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
|
||||||
|
const taxable = Math.max(0, gross - discount)
|
||||||
|
return sum + taxable * (Number(line.taxPct || 0) / 100)
|
||||||
|
}, 0),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
const netTotal = Math.max(0, grossTotal - discountTotal)
|
||||||
|
const payableTotal = netTotal + taxTotal
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.")
|
||||||
|
if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.")
|
||||||
|
if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.")
|
||||||
|
if (lines.some((line) => Number(line.uomId) === 0)) return setSubmitError("Select a valid UOM for every line.")
|
||||||
|
|
||||||
|
const payload: CreateSalesSlipRequest = {
|
||||||
|
customerId,
|
||||||
|
warehouseId,
|
||||||
|
cashierUserId,
|
||||||
|
lines: lines.map((line) => ({
|
||||||
|
itemId: Number(line.itemId),
|
||||||
|
uomId: Number(line.uomId),
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
const created = await salesApi.createSlip(payload)
|
||||||
|
toast.success("Slip created", created.data.slipNo)
|
||||||
|
router.push(`/dashboard/sales/slips/${created.data.salesSlipId}`)
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(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="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/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">New sales slip</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Create counter sales slips from the live backend.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={submit} disabled={saving}>
|
||||||
|
<Save className="size-4" /> {saving ? "Saving..." : "Save slip"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
|
<h2 className="text-sm font-semibold">Slip header</h2>
|
||||||
|
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Customer</Label>
|
||||||
|
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select customer" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<SelectItem key={c.customerId} value={String(c.customerId)}>
|
||||||
|
{c.customerCode} - {c.displayName ?? c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Warehouse</Label>
|
||||||
|
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select warehouse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Cashier</Label>
|
||||||
|
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select cashier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.userId} value={String(u.userId)}>
|
||||||
|
{u.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Slip summary</Label>
|
||||||
|
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
<span>{lkr.format(payableTotal)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Slip lines</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="size-4" /> Add line
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">#</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line, idx) => {
|
||||||
|
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
|
||||||
|
const lineDiscount =
|
||||||
|
String(line.discountMode) === "Amount"
|
||||||
|
? Number(line.discountAmount || 0)
|
||||||
|
: lineGross * (Number(line.discountPct || 0) / 100)
|
||||||
|
const lineNet = Math.max(0, lineGross - lineDiscount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={line.key} className="align-middle">
|
||||||
|
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-64">
|
||||||
|
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((candidate) => (
|
||||||
|
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
||||||
|
{candidate.sku} - {candidate.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 min-w-36">
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={line.qty}
|
||||||
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={line.freeQty}
|
||||||
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={line.unitPrice ?? ""}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.5"
|
||||||
|
value={line.discountPct}
|
||||||
|
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
|
||||||
|
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-muted-foreground"
|
||||||
|
onClick={() => removeLine(line.key)}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
|
||||||
|
<h3 className="text-sm font-semibold">Slip notes</h3>
|
||||||
|
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li className="rounded-md border border-border px-3 py-2">Cashier posting follows the standard sales-slip workflow.</li>
|
||||||
|
<li className="rounded-md border border-border px-3 py-2">Free issue lines are captured from the slip itself, not from a separate register here.</li>
|
||||||
|
<li className="rounded-md border border-border px-3 py-2">Use the slip detail page after save to post or cancel.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]">
|
||||||
|
{[
|
||||||
|
["Gross", lkr.format(grossTotal)],
|
||||||
|
["Discount", `-${lkr.format(discountTotal)}`],
|
||||||
|
["Net", lkr.format(netTotal)],
|
||||||
|
["Tax", lkr.format(taxTotal)],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex items-center justify-between py-1.5">
|
||||||
|
<dt className="text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="font-mono tabular-nums">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<dt className="font-semibold">Payable</dt>
|
||||||
|
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Create slip"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales"
|
||||||
|
|
||||||
|
type StatusFilter = SalesSlipStatus | "All"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
|
||||||
|
|
||||||
|
function statusClass(status: SalesSlipStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "Draft":
|
||||||
|
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||||
|
case "Posted":
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
case "Cancelled":
|
||||||
|
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesSlipsPage() {
|
||||||
|
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [searchInput, setSearchInput] = useState("")
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
useEffect(() => setPage(1), [query, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null)
|
||||||
|
salesApi
|
||||||
|
.listSlips({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
||||||
|
.then((res) => {
|
||||||
|
setRows(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [page, status])
|
||||||
|
|
||||||
|
const visibleRows = useMemo(
|
||||||
|
() =>
|
||||||
|
rows?.filter((row) =>
|
||||||
|
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||||
|
) ?? [],
|
||||||
|
[rows, query]
|
||||||
|
)
|
||||||
|
const hasFilters = status !== "All" || query.length > 0
|
||||||
|
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||||
|
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||||
|
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Slips</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
<Printer className="size-5" />
|
||||||
|
Print batch
|
||||||
|
</Button>
|
||||||
|
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Slip
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setStatus(t)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Filter by customer or slip number"
|
||||||
|
className="h-12 w-full lg:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||||
|
<Filter className="size-4" />
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{!error && rows === null && (
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && rows !== null && visibleRows.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||||
|
<Package2 className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">{hasFilters ? "No slips match your filters." : "No slips yet."}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && rows !== null && visibleRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Slip</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
|
||||||
|
<TableHead className="h-12 px-4 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
|
||||||
|
<TableCell className="px-4 py-3.5 font-medium">
|
||||||
|
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="hover:underline">
|
||||||
|
{row.slipNo}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<Badge variant="outline" className={statusClass(row.status)}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border text-muted-foreground hover:bg-muted hover:text-foreground" aria-label={`View slip ${row.slipNo}`}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t px-4 py-3">
|
||||||
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Gross total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Discount total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">Net total</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Building2, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { companyApi } from "@/lib/api/company"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CompanyProfile } from "@/types/company"
|
||||||
|
|
||||||
|
import { buttonVariants, Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export default function CompanyProfilePage() {
|
||||||
|
const [profile, setProfile] = useState<CompanyProfile | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
companyApi
|
||||||
|
.getProfile()
|
||||||
|
.then((res) => {
|
||||||
|
setProfile(res.data)
|
||||||
|
setEtag(res.etag)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function patch<K extends keyof CompanyProfile>(key: K, value: CompanyProfile[K]) {
|
||||||
|
setProfile((prev) => (prev ? { ...prev, [key]: value } : prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!profile || !etag) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const updated = await companyApi.updateProfile(profile, etag)
|
||||||
|
setProfile(updated.data)
|
||||||
|
setEtag(updated.etag)
|
||||||
|
toast.success("Company profile saved", updated.data.legalName)
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||||
|
<ArrowLeft className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
{!error && !profile && <Skeleton className="h-64 w-full" />}
|
||||||
|
|
||||||
|
{!error && profile && (
|
||||||
|
<div className="flex flex-col gap-6 rounded-2xl border p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Invoice Header</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Legal Name" value={profile.legalName} onChange={(v) => patch("legalName", v)} />
|
||||||
|
<Field label="Trade Name" value={profile.tradeName ?? ""} onChange={(v) => patch("tradeName", v)} />
|
||||||
|
<Field label="Logo URL" value={profile.logoUrl ?? ""} onChange={(v) => patch("logoUrl", v)} />
|
||||||
|
<Field label="Tax Registration No" value={profile.taxRegistrationNo ?? ""} onChange={(v) => patch("taxRegistrationNo", v)} />
|
||||||
|
<Field label="VAT Registration No" value={profile.vatRegistrationNo ?? ""} onChange={(v) => patch("vatRegistrationNo", v)} />
|
||||||
|
<Field label="Phone" value={profile.phone ?? ""} onChange={(v) => patch("phone", v)} />
|
||||||
|
<Field label="Email" value={profile.email ?? ""} onChange={(v) => patch("email", v)} />
|
||||||
|
<Field label="City" value={profile.city ?? ""} onChange={(v) => patch("city", v)} />
|
||||||
|
<Field label="Country" value={profile.country ?? ""} onChange={(v) => patch("country", v)} />
|
||||||
|
<Field label="Address Line 1" value={profile.addressLine1 ?? ""} onChange={(v) => patch("addressLine1", v)} />
|
||||||
|
<Field label="Address Line 2" value={profile.addressLine2 ?? ""} onChange={(v) => patch("addressLine2", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Save className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Bank Details</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Bank Name" value={profile.bankName ?? ""} onChange={(v) => patch("bankName", v)} />
|
||||||
|
<Field label="Bank Branch" value={profile.bankBranch ?? ""} onChange={(v) => patch("bankBranch", v)} />
|
||||||
|
<Field label="Account Name" value={profile.accountName ?? ""} onChange={(v) => patch("accountName", v)} />
|
||||||
|
<Field label="Account Number" value={profile.accountNumber ?? ""} onChange={(v) => patch("accountNumber", v)} />
|
||||||
|
<Field label="SWIFT Code" value={profile.swiftCode ?? ""} onChange={(v) => patch("swiftCode", v)} />
|
||||||
|
<Field label="Footer Note" value={profile.footerNote ?? ""} onChange={(v) => patch("footerNote", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" onClick={save} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save Profile"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<Input value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
@@ -20,6 +20,12 @@ const cards = [
|
|||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
description: "Configure product and master-data options",
|
description: "Configure product and master-data options",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Company Profile",
|
||||||
|
href: "/dashboard/settings/company-profile",
|
||||||
|
icon: Building2,
|
||||||
|
description: "Invoice header, tax, and bank details",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
|||||||
@@ -219,3 +219,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print\:hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-sheet {
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-sheet table {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-sheet tr,
|
||||||
|
.invoice-sheet td,
|
||||||
|
.invoice-sheet th {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { Providers } from "@/components/providers";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -31,14 +30,7 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<ThemeProvider
|
<Providers>{children}</Providers>
|
||||||
attribute="class"
|
|
||||||
defaultTheme="light"
|
|
||||||
themes={["light", "dark", "vibrant"]}
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function LoginForm() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [remember, setRemember] = useState(false)
|
const [remember, setRemember] = useState(false)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const returnTo = searchParams.get("next")
|
||||||
|
const sessionNotice = returnTo ? "Your session is missing or expired. Sign in again to continue." : null
|
||||||
|
|
||||||
const form = useForm<LoginValues>({
|
const form = useForm<LoginValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
@@ -85,6 +87,11 @@ function LoginForm() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
|
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
|
||||||
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
|
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
|
||||||
|
{sessionNotice ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
{sessionNotice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
|
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
PieChart,
|
PieChart,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
ReceiptText,
|
||||||
Ruler,
|
Ruler,
|
||||||
Scale,
|
Scale,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -99,6 +100,20 @@ const navItems: {
|
|||||||
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Sales",
|
||||||
|
code: "sales",
|
||||||
|
href: "/dashboard/sales",
|
||||||
|
landingHref: "/dashboard/sales/invoices",
|
||||||
|
icon: ReceiptText,
|
||||||
|
chevron: true,
|
||||||
|
children: [
|
||||||
|
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||||
|
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
||||||
|
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
|
||||||
|
{ title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||||
@@ -386,14 +401,14 @@ export function AppSidebar() {
|
|||||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
// is visible if its own code is granted, or (for parents) if any child is.
|
// is visible if its own code is granted, or (for parents) if any child is.
|
||||||
//
|
//
|
||||||
// "procurement", "hrm" and "production" are exempted from that check (frontend-only): no
|
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
|
||||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
||||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||||
// anything server-side.
|
// anything server-side.
|
||||||
const bypassCodes = new Set(["procurement", "hrm", "production"])
|
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function titleFromPath(pathname: string) {
|
|||||||
if (pathname === "/dashboard/products/brands") return "Brands"
|
if (pathname === "/dashboard/products/brands") return "Brands"
|
||||||
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
||||||
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
||||||
|
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
|
||||||
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
|
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
|
||||||
|
|
||||||
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
|
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const statusStyles: Record<Order["status"], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use a fixed locale to avoid hydration mismatches between server and client
|
// Use a fixed locale to avoid hydration mismatches between server and client
|
||||||
const currency = new Intl.NumberFormat("en-US", {
|
const currency = new Intl.NumberFormat("en-LK", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "LKR",
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<Order>[] = [
|
const columns: DataTableColumn<Order>[] = [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
|
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" themes={["light", "dark", "vibrant"]} disableTransitionOnChange>
|
||||||
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Lightbulb, PackageCheck } from "lucide-react"
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SalesFreeIssueSuggestion } from "@/types/sales"
|
||||||
|
|
||||||
|
export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: SalesFreeIssueSuggestion | null }) {
|
||||||
|
if (!suggestion || suggestion.lines.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
|
No free-issue promotion suggestions were generated for this slip yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||||
|
<Lightbulb className="size-3.5" />
|
||||||
|
Backend suggestion
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
Create free issue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{suggestion.lines.map((line) => (
|
||||||
|
<div key={line.salesSlipLineId} className="rounded-xl border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">{line.itemName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{line.itemSku} • Qty {line.qty}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||||
|
Buy {line.triggerQty} get {line.suggestedFreeQty} free
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2 text-sm">
|
||||||
|
{line.rewardOptions.map((option, index) => (
|
||||||
|
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
|
||||||
|
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SalesSlipSummary } from "@/types/sales"
|
||||||
|
|
||||||
|
export function FreeIssueSuggestions({ rows }: { rows: SalesSlipSummary[] }) {
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Free issue suggestions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Recent free-issue slips created from the free-issues page.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/sales/free-issues" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
Open free issues
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<Link
|
||||||
|
key={row.salesSlipId}
|
||||||
|
href={`/dashboard/sales/free-issues/${row.salesSlipId}`}
|
||||||
|
className="rounded-xl border bg-muted/20 p-3 transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="font-medium text-foreground">{row.slipNo}</div>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">{row.status}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">{row.customerSnapshotName}</div>
|
||||||
|
<div className="mt-2 text-sm text-foreground">Free qty total: {row.totals.freeQtyTotal.toFixed(2)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
|
||||||
|
export interface ListCustomersParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customersApi = {
|
||||||
|
list(params: ListCustomersParams = {}): Promise<PagedResponse<Customer>> {
|
||||||
|
return apiRequest<PagedResponse<Customer>>(`/customers${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
|
import { ApiResult, PagedResponse } from "@/types/common"
|
||||||
|
import {
|
||||||
|
CreateSalesInvoiceRequest,
|
||||||
|
CreateSalesSlipRequest,
|
||||||
|
SalesInvoice,
|
||||||
|
SalesInvoiceStatus,
|
||||||
|
SalesInvoicePostingCheck,
|
||||||
|
SalesInvoiceSummary,
|
||||||
|
SalesListFilterParams,
|
||||||
|
SalesReportDefinition,
|
||||||
|
SalesReportQueryRequest,
|
||||||
|
SalesReportQueryResponse,
|
||||||
|
SalesFreeIssueSuggestion,
|
||||||
|
FreeIssueDetail,
|
||||||
|
FreeIssueSummary,
|
||||||
|
SalesSlip,
|
||||||
|
SalesSlipStatus,
|
||||||
|
SalesSlipPostingCheck,
|
||||||
|
SalesSlipSummary,
|
||||||
|
UpdateSalesInvoiceRequest,
|
||||||
|
UpdateSalesSlipRequest,
|
||||||
|
} from "@/types/sales"
|
||||||
|
|
||||||
|
type SalesReportId = string
|
||||||
|
|
||||||
|
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||||
|
status?: SalesInvoiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
||||||
|
status?: SalesSlipStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListFreeIssuesParams extends SalesListFilterParams {
|
||||||
|
status?: SalesSlipStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesApi = {
|
||||||
|
listInvoices(params: ListSalesInvoicesParams = {}): Promise<PagedResponse<SalesInvoiceSummary>> {
|
||||||
|
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
|
||||||
|
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createInvoice(request: CreateSalesInvoiceRequest): Promise<ApiResult<SalesInvoice>> {
|
||||||
|
return apiRequestWithETag<SalesInvoice>("/sales-invoices", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateInvoice(salesInvoiceId: number, request: UpdateSalesInvoiceRequest, ifMatch: string): Promise<ApiResult<SalesInvoice>> {
|
||||||
|
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`, { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
|
||||||
|
postInvoice(salesInvoiceId: number): Promise<SalesInvoice> {
|
||||||
|
return apiRequest<SalesInvoice>(`/sales-invoices/${salesInvoiceId}/post`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
checkInvoicePosting(salesInvoiceId: number): Promise<SalesInvoicePostingCheck> {
|
||||||
|
return apiRequest<SalesInvoicePostingCheck>(`/sales-invoices/${salesInvoiceId}/posting-check`)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelInvoice(salesInvoiceId: number): Promise<SalesInvoice> {
|
||||||
|
return apiRequest<SalesInvoice>(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
listSlips(params: ListSalesSlipsParams = {}): Promise<PagedResponse<SalesSlipSummary>> {
|
||||||
|
return apiRequest<PagedResponse<SalesSlipSummary>>(`/sales-slips${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSlip(salesSlipId: number): Promise<ApiResult<SalesSlip>> {
|
||||||
|
return apiRequestWithETag<SalesSlip>(`/sales-slips/${salesSlipId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createSlip(request: CreateSalesSlipRequest): Promise<ApiResult<SalesSlip>> {
|
||||||
|
return apiRequestWithETag<SalesSlip>("/sales-slips", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSlip(salesSlipId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise<ApiResult<SalesSlip>> {
|
||||||
|
return apiRequestWithETag<SalesSlip>(`/sales-slips/${salesSlipId}`, { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
|
||||||
|
postSlip(salesSlipId: number): Promise<SalesSlip> {
|
||||||
|
return apiRequest<SalesSlip>(`/sales-slips/${salesSlipId}/post`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
checkSlipPosting(salesSlipId: number): Promise<SalesSlipPostingCheck> {
|
||||||
|
return apiRequest<SalesSlipPostingCheck>(`/sales-slips/${salesSlipId}/posting-check`)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelSlip(salesSlipId: number): Promise<SalesSlip> {
|
||||||
|
return apiRequest<SalesSlip>(`/sales-slips/${salesSlipId}/cancel`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
listFreeIssues(params: ListFreeIssuesParams = {}): Promise<PagedResponse<FreeIssueSummary>> {
|
||||||
|
return apiRequest<PagedResponse<FreeIssueSummary>>(`/free-issues${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getFreeIssue(freeIssueId: number): Promise<ApiResult<FreeIssueDetail>> {
|
||||||
|
return apiRequestWithETag<FreeIssueDetail>(`/free-issues/${freeIssueId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createFreeIssue(request: CreateSalesSlipRequest): Promise<ApiResult<FreeIssueDetail>> {
|
||||||
|
return apiRequestWithETag<FreeIssueDetail>("/free-issues", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFreeIssue(freeIssueId: number, request: UpdateSalesSlipRequest, ifMatch: string): Promise<ApiResult<FreeIssueDetail>> {
|
||||||
|
return apiRequestWithETag<FreeIssueDetail>(`/free-issues/${freeIssueId}`, { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
|
||||||
|
postFreeIssue(freeIssueId: number): Promise<FreeIssueDetail> {
|
||||||
|
return apiRequest<FreeIssueDetail>(`/free-issues/${freeIssueId}/post`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
checkFreeIssuePosting(freeIssueId: number): Promise<SalesSlipPostingCheck> {
|
||||||
|
return apiRequest<SalesSlipPostingCheck>(`/free-issues/${freeIssueId}/posting-check`)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelFreeIssue(freeIssueId: number): Promise<FreeIssueDetail> {
|
||||||
|
return apiRequest<FreeIssueDetail>(`/free-issues/${freeIssueId}/cancel`, { method: "POST" })
|
||||||
|
},
|
||||||
|
|
||||||
|
getFreeIssueSuggestions(salesSlipId: number): Promise<SalesFreeIssueSuggestion> {
|
||||||
|
return apiRequest<SalesFreeIssueSuggestion>(`/sales-slips/${salesSlipId}/free-issue-suggestions`)
|
||||||
|
},
|
||||||
|
|
||||||
|
listReports(): Promise<SalesReportDefinition[]> {
|
||||||
|
return apiRequest<SalesReportDefinition[]>("/reports/sales")
|
||||||
|
},
|
||||||
|
|
||||||
|
getReport(reportId: SalesReportId): Promise<SalesReportDefinition> {
|
||||||
|
return apiRequest<SalesReportDefinition>(`/reports/sales/${reportId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
queryReport(request: SalesReportQueryRequest): Promise<SalesReportQueryResponse> {
|
||||||
|
return apiRequest<SalesReportQueryResponse>("/reports/sales/query", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ItemListItem } from "@/types/master-data"
|
||||||
|
|
||||||
|
export function getSuggestedUnitPrice(items: ItemListItem[], itemId: number | null | undefined): number | null {
|
||||||
|
if (!itemId) return null
|
||||||
|
return items.find((item) => item.itemId === itemId)?.salePrice ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
|
||||||
|
export type CustomerType = "B2B" | "B2C"
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
customerId: number
|
||||||
|
customerCode: string
|
||||||
|
customerType: CustomerType
|
||||||
|
name: string
|
||||||
|
displayName: string | null
|
||||||
|
phone: string | null
|
||||||
|
email: string | null
|
||||||
|
addressLine1: string | null
|
||||||
|
addressLine2: string | null
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
taxRegistrationNo: string | null
|
||||||
|
creditLimit: number
|
||||||
|
creditDays: number
|
||||||
|
defaultWarehouseId: number | null
|
||||||
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
|
||||||
|
export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit"
|
||||||
|
export type SalesInvoiceStatus = "Draft" | "Posted" | "Cancelled"
|
||||||
|
export type SalesSlipStatus = "Draft" | "Posted" | "Cancelled"
|
||||||
|
export type SalesDiscountMode = "Percentage" | "Amount"
|
||||||
|
|
||||||
|
export interface SalesInvoiceLine {
|
||||||
|
salesInvoiceLineId: number
|
||||||
|
itemId: number
|
||||||
|
description: string
|
||||||
|
qty: number
|
||||||
|
freeQty: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
unitPrice: number
|
||||||
|
baseCost: number
|
||||||
|
priceSource: string
|
||||||
|
discountPct: number
|
||||||
|
discountAmount: number
|
||||||
|
discountMode: SalesDiscountMode
|
||||||
|
netUnitPrice: number
|
||||||
|
lineTotal: number
|
||||||
|
taxPct: number
|
||||||
|
taxAmount: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
parentLineId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoiceTotals {
|
||||||
|
subtotal: number
|
||||||
|
discountTotal: number
|
||||||
|
freeQtyTotal: number
|
||||||
|
taxTotal: number
|
||||||
|
grandTotal: number
|
||||||
|
roundOff: number
|
||||||
|
netPayable: number
|
||||||
|
paidAmount: number
|
||||||
|
balanceAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoiceSummary {
|
||||||
|
salesInvoiceId: number
|
||||||
|
invoiceNo: string
|
||||||
|
invoiceDate: string
|
||||||
|
customerId: number
|
||||||
|
customerSnapshotName: string
|
||||||
|
warehouseId: number
|
||||||
|
invoiceType: SalesInvoiceType
|
||||||
|
status: SalesInvoiceStatus
|
||||||
|
totals: SalesInvoiceTotals
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoicePostingIssue {
|
||||||
|
salesInvoiceLineId: number
|
||||||
|
itemId: number
|
||||||
|
itemSku: string
|
||||||
|
itemName: string
|
||||||
|
warehouseId: number
|
||||||
|
requestedQty: number
|
||||||
|
availableQty: number
|
||||||
|
shortQty: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoicePostingCheck {
|
||||||
|
salesInvoiceId: number
|
||||||
|
invoiceNo: string
|
||||||
|
status: SalesInvoiceStatus
|
||||||
|
canPost: boolean
|
||||||
|
issues: SalesInvoicePostingIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSlipPostingIssue {
|
||||||
|
salesSlipLineId: number
|
||||||
|
itemId: number
|
||||||
|
itemSku: string
|
||||||
|
itemName: string
|
||||||
|
warehouseId: number
|
||||||
|
requestedQty: number
|
||||||
|
availableQty: number
|
||||||
|
shortQty: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSlipPostingCheck {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
status: SalesSlipStatus
|
||||||
|
canPost: boolean
|
||||||
|
issues: SalesSlipPostingIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoice extends SalesInvoiceSummary {
|
||||||
|
customerSnapshotTaxNo: string | null
|
||||||
|
createdBy: number
|
||||||
|
updatedAt: string | null
|
||||||
|
lines: SalesInvoiceLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesInvoiceLineRequest {
|
||||||
|
itemId: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
qty: number
|
||||||
|
freeQty: number
|
||||||
|
unitPrice?: number | null
|
||||||
|
allowManualPriceOverride: boolean
|
||||||
|
discountMode: SalesDiscountMode
|
||||||
|
discountPct: number
|
||||||
|
discountAmount: number
|
||||||
|
discountValue: number
|
||||||
|
taxPct: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
parentLineId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesInvoiceRequest {
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
invoiceType: SalesInvoiceType
|
||||||
|
lines: CreateSalesInvoiceLineRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSalesInvoiceRequest = CreateSalesInvoiceRequest
|
||||||
|
|
||||||
|
export interface SalesSlipLine {
|
||||||
|
salesSlipLineId: number
|
||||||
|
itemId: number
|
||||||
|
description: string
|
||||||
|
qty: number
|
||||||
|
freeQty: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
unitPrice: number
|
||||||
|
baseCost: number
|
||||||
|
priceSource: string
|
||||||
|
discountPct: number
|
||||||
|
discountAmount: number
|
||||||
|
discountMode: SalesDiscountMode
|
||||||
|
netUnitPrice: number
|
||||||
|
lineTotal: number
|
||||||
|
taxPct: number
|
||||||
|
taxAmount: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
parentLineId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSlipTotals {
|
||||||
|
subtotal: number
|
||||||
|
discountTotal: number
|
||||||
|
freeQtyTotal: number
|
||||||
|
taxTotal: number
|
||||||
|
grandTotal: number
|
||||||
|
paidAmount: number
|
||||||
|
balanceAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSlipSummary {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
slipDate: string
|
||||||
|
customerId: number
|
||||||
|
customerSnapshotName: string
|
||||||
|
warehouseId: number
|
||||||
|
status: SalesSlipStatus
|
||||||
|
totals: SalesSlipTotals
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSlip {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
slipDate: string
|
||||||
|
customerId: number
|
||||||
|
customerSnapshotName: string
|
||||||
|
warehouseId: number
|
||||||
|
cashierUserId: number
|
||||||
|
status: SalesSlipStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
totals: SalesSlipTotals
|
||||||
|
lines: SalesSlipLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesSlipLineRequest {
|
||||||
|
itemId: number
|
||||||
|
uomId: number
|
||||||
|
warehouseId: number
|
||||||
|
qty: number
|
||||||
|
freeQty: number
|
||||||
|
unitPrice?: number | null
|
||||||
|
allowManualPriceOverride: boolean
|
||||||
|
discountMode: SalesDiscountMode
|
||||||
|
discountPct: number
|
||||||
|
discountAmount: number
|
||||||
|
discountValue: number
|
||||||
|
taxPct: number
|
||||||
|
isFreeIssue: boolean
|
||||||
|
parentLineId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesSlipRequest {
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
cashierUserId: number
|
||||||
|
lines: CreateSalesSlipLineRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSalesSlipRequest = CreateSalesSlipRequest
|
||||||
|
|
||||||
|
export interface SalesReportDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
supportedFilters: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesReportQueryRequest {
|
||||||
|
reportType: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
itemId?: number | null
|
||||||
|
customerId?: number | null
|
||||||
|
warehouseId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesReportQueryResponse {
|
||||||
|
reportType: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
rows: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesListFilterParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: SalesInvoiceStatus | SalesSlipStatus
|
||||||
|
customerId?: number
|
||||||
|
warehouseId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeIssueSummaryItem extends SalesSlipSummary {
|
||||||
|
// Alias for the free issue route. Kept as a slip-shaped payload because the backend
|
||||||
|
// stores free issues as sales slips with free-issue line flags.
|
||||||
|
isFreeIssue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeIssueSummary {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
status: SalesSlipStatus
|
||||||
|
createdAt: string
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
itemId: number
|
||||||
|
itemSku: string
|
||||||
|
itemName: string
|
||||||
|
uomId: number
|
||||||
|
uomName: string
|
||||||
|
qty: number
|
||||||
|
freeQty: number
|
||||||
|
schemeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeIssueDetail {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
slipDate: string
|
||||||
|
status: SalesSlipStatus
|
||||||
|
customerId: number
|
||||||
|
customerSnapshotName: string
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
cashierUserId: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
summary: FreeIssueSummary
|
||||||
|
lines: SalesSlipLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesFreeIssueRewardOption {
|
||||||
|
itemId: number
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
salePrice: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesFreeIssueSuggestionLine {
|
||||||
|
salesSlipLineId: number
|
||||||
|
itemId: number
|
||||||
|
itemSku: string
|
||||||
|
itemName: string
|
||||||
|
qty: number
|
||||||
|
suggestedFreeQty: number
|
||||||
|
triggerQty: number
|
||||||
|
rewardOptions: SalesFreeIssueRewardOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesFreeIssueSuggestion {
|
||||||
|
salesSlipId: number
|
||||||
|
slipNo: string
|
||||||
|
slipDate: string
|
||||||
|
lines: SalesFreeIssueSuggestionLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesCustomer {
|
||||||
|
customerId: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
taxNo: string | null
|
||||||
|
status: EntityStatus
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ A modular ERP built in phases. **Phase 1** delivers the **Inventory & Supply Cha
|
|||||||
Full requirements live in the backend spec (see §7). This file does **not** duplicate them.
|
Full requirements live in the backend spec (see §7). This file does **not** duplicate them.
|
||||||
|
|
||||||
**Phase 2 (HRM)** is now underway alongside Phase 1 — see §7 routing to `12-BACKEND-HRM.md` / `13-BACKEND-HRM-API.md` / `21-FRONTEND-HRM.md`.
|
**Phase 2 (HRM)** is now underway alongside Phase 1 — see §7 routing to `12-BACKEND-HRM.md` / `13-BACKEND-HRM-API.md` / `21-FRONTEND-HRM.md`.
|
||||||
|
Sales has its own API reference at `14-BACKEND-SALES-API.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ erp-monorepo/
|
|||||||
├── 02-SECURITY.md # accepted-risks register + per-feature security checklist
|
├── 02-SECURITY.md # accepted-risks register + per-feature security checklist
|
||||||
├── 10-BACKEND-PHASE1.md # backend spec: SRS + ER/entities + tech + architecture
|
├── 10-BACKEND-PHASE1.md # backend spec: SRS + ER/entities + tech + architecture
|
||||||
├── 11-BACKEND-PHASE1.md # backend API reference (complete req/res)
|
├── 11-BACKEND-PHASE1.md # backend API reference (complete req/res)
|
||||||
|
├── 14-BACKEND-SALES-API.md # sales API reference (invoices, slips, free issues, reports)
|
||||||
├── 12-GENERAL-LEDGER-INTEGRATION.md # ERPCore ↔ external General Ledger service (transport only)
|
├── 12-GENERAL-LEDGER-INTEGRATION.md # ERPCore ↔ external General Ledger service (transport only)
|
||||||
├── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture
|
├── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture
|
||||||
└── 21-GENERAL-LEDGER-FRONTEND.md # Ledgers section: reports UI + cash/bank accounts
|
└── 21-GENERAL-LEDGER-FRONTEND.md # Ledgers section: reports UI + cash/bank accounts
|
||||||
@@ -344,6 +346,7 @@ All frontend work is governed by `20-FRONTEND.md`.
|
|||||||
| HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** |
|
| HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** |
|
||||||
| Connecting to the external General Ledger service (proxy, config, API key) | **`12-GENERAL-LEDGER-INTEGRATION.md`** |
|
| Connecting to the external General Ledger service (proxy, config, API key) | **`12-GENERAL-LEDGER-INTEGRATION.md`** |
|
||||||
| The Ledgers frontend section (reports, cash/bank accounts) | **`21-GENERAL-LEDGER-FRONTEND.md`** |
|
| The Ledgers frontend section (reports, cash/bank accounts) | **`21-GENERAL-LEDGER-FRONTEND.md`** |
|
||||||
|
| Sales API endpoints, request/response shapes, error catalog, enums | **`14-BACKEND-SALES-API.md`** |
|
||||||
| Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** |
|
| Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** |
|
||||||
| HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** |
|
| HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** |
|
||||||
| Manufacturing requirements, business rules, entities, stock/costing integration, API (Phase 2) | **`30-BACKEND-PHASE2.md`** |
|
| Manufacturing requirements, business rules, entities, stock/costing integration, API (Phase 2) | **`30-BACKEND-PHASE2.md`** |
|
||||||
@@ -356,6 +359,7 @@ All frontend work is governed by `20-FRONTEND.md`.
|
|||||||
Quick resolver:
|
Quick resolver:
|
||||||
- *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (Phase 1) / `12-BACKEND-HRM.md` (HRM) — schema is authoritative there.
|
- *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (Phase 1) / `12-BACKEND-HRM.md` (HRM) — schema is authoritative there.
|
||||||
- *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md` (Phase 1) / `13-BACKEND-HRM-API.md` (HRM).
|
- *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md` (Phase 1) / `13-BACKEND-HRM-API.md` (HRM).
|
||||||
|
- *"What does the sales API accept/return?"* → `14-BACKEND-SALES-API.md`.
|
||||||
- *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md` (Phase 1) / `21-FRONTEND-HRM.md` (HRM).
|
- *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md` (Phase 1) / `21-FRONTEND-HRM.md` (HRM).
|
||||||
- *"What security risks / checks apply to this feature?"* → `02-SECURITY.md`.
|
- *"What security risks / checks apply to this feature?"* → `02-SECURITY.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
| `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans |
|
| `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans |
|
||||||
| `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans |
|
| `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans |
|
||||||
| `12-GENERAL-LEDGER-INTEGRATION.md` | High | ERPCore ↔ external **General Ledger service**: connection/proxy contract, config, progress. GL's own endpoint contract lives in the GL service's own repo, not here. | Any work touching the GL proxy or a future internal GL caller. | Claude + humans |
|
| `12-GENERAL-LEDGER-INTEGRATION.md` | High | ERPCore ↔ external **General Ledger service**: connection/proxy contract, config, progress. GL's own endpoint contract lives in the GL service's own repo, not here. | Any work touching the GL proxy or a future internal GL caller. | Claude + humans |
|
||||||
|
| `14-BACKEND-SALES-API.md` | High | Sales **API reference**: invoices, slips, free issues, reports, enums. | Any sales API contract / controller work. | Claude + humans |
|
||||||
| `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans |
|
| `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans |
|
||||||
| `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans |
|
| `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans |
|
||||||
| `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans |
|
| `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans |
|
||||||
@@ -197,7 +198,7 @@ Spec: docs/20-FRONTEND.md (flows + rules) · docs/11-BACKEND-PHASE1.md (API cont
|
|||||||
## 7. Adding future docs
|
## 7. Adding future docs
|
||||||
|
|
||||||
When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting), follow the same scheme HRM (Phase 2) established:
|
When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting), follow the same scheme HRM (Phase 2) established:
|
||||||
- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`, mirroring the `10`/`11` SRS+ER / API-reference split), linked from the hub.
|
- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`; sales now uses `14-BACKEND-SALES-API.md` alongside the existing phase docs), linked from the hub.
|
||||||
- Frontend additions → extend `20-FRONTEND.md` or add `2x-` files (e.g. HRM used `21-FRONTEND-HRM.md`).
|
- Frontend additions → extend `20-FRONTEND.md` or add `2x-` files (e.g. HRM used `21-FRONTEND-HRM.md`).
|
||||||
- Always register the new doc in `00-CORE.md` routing (§7) and in this index (§2).
|
- Always register the new doc in `00-CORE.md` routing (§7) and in this index (§2).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 14 · BACKEND — Sales API Reference
|
||||||
|
|
||||||
|
> **Authoritative for:** sales API contracts for invoices, slips, free issues, and sales reports.
|
||||||
|
> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`.
|
||||||
|
> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Conventions
|
||||||
|
|
||||||
|
- Base URL: `https://{host}/api/v1`
|
||||||
|
- Authentication: same as the rest of the v1 API
|
||||||
|
- Concurrency: `ETag` / `If-Match` on mutable resources
|
||||||
|
- Validation: `ProblemDetails` / `ValidationProblemDetails`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sales Invoices
|
||||||
|
|
||||||
|
### `GET /api/v1/sales-invoices`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `status`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
Returns a paged list of `SalesInvoiceSummaryDto`.
|
||||||
|
The current frontend register uses this endpoint directly for the invoice hub view.
|
||||||
|
|
||||||
|
### `GET /api/v1/sales-invoices/{salesInvoiceId}`
|
||||||
|
Returns `SalesInvoiceDto`.
|
||||||
|
The detail page shows the document template and only exposes edit actions when the invoice is `Draft`.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-invoices`
|
||||||
|
Creates a draft sales invoice.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": 2,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"invoiceType": "B2C",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"itemId": 1,
|
||||||
|
"uomId": 1,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"qty": 1,
|
||||||
|
"freeQty": 0,
|
||||||
|
"unitPrice": 100,
|
||||||
|
"allowManualPriceOverride": false,
|
||||||
|
"discountMode": "Percentage",
|
||||||
|
"discountPct": 0,
|
||||||
|
"discountAmount": 0,
|
||||||
|
"discountValue": 0,
|
||||||
|
"taxPct": 0,
|
||||||
|
"isFreeIssue": false,
|
||||||
|
"parentLineId": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/v1/sales-invoices/{salesInvoiceId}`
|
||||||
|
Updates a draft invoice. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-invoices/{salesInvoiceId}/post`
|
||||||
|
Posts stock out and marks the invoice as `Posted`.
|
||||||
|
Before posting, the UI calls `GET /api/v1/sales-invoices/{salesInvoiceId}/posting-check` to show shortages.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-invoices/{salesInvoiceId}/cancel`
|
||||||
|
Cancels a draft invoice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sales Slips
|
||||||
|
|
||||||
|
### `GET /api/v1/sales-slips`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `status`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
Returns a paged list of `SalesSlipSummaryDto`.
|
||||||
|
|
||||||
|
### `GET /api/v1/sales-slips/{salesSlipId}`
|
||||||
|
Returns `SalesSlipDto`.
|
||||||
|
The detail page follows the same document-style layout and draft-only edit behavior as invoices.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-slips`
|
||||||
|
Creates a draft sales slip.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": 2,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"cashierUserId": 1,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"itemId": 1,
|
||||||
|
"uomId": 1,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"qty": 1,
|
||||||
|
"freeQty": 0,
|
||||||
|
"unitPrice": 50,
|
||||||
|
"allowManualPriceOverride": false,
|
||||||
|
"discountMode": "Percentage",
|
||||||
|
"discountPct": 0,
|
||||||
|
"discountAmount": 0,
|
||||||
|
"discountValue": 0,
|
||||||
|
"taxPct": 0,
|
||||||
|
"isFreeIssue": false,
|
||||||
|
"parentLineId": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/v1/sales-slips/{salesSlipId}`
|
||||||
|
Updates a draft slip. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-slips/{salesSlipId}/post`
|
||||||
|
Posts stock out and marks the slip as `Posted`.
|
||||||
|
Before posting, the UI calls `GET /api/v1/sales-slips/{salesSlipId}/posting-check`.
|
||||||
|
|
||||||
|
### `POST /api/v1/sales-slips/{salesSlipId}/cancel`
|
||||||
|
Cancels a draft slip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Free Issues
|
||||||
|
|
||||||
|
Free issue is a business alias over sales slips.
|
||||||
|
There is no separate free-issue table in the current schema.
|
||||||
|
|
||||||
|
### `GET /api/v1/free-issues`
|
||||||
|
Same contract as `GET /api/v1/sales-slips`.
|
||||||
|
|
||||||
|
### `GET /api/v1/free-issues/{freeIssueId}`
|
||||||
|
Same contract as `GET /api/v1/sales-slips/{salesSlipId}`.
|
||||||
|
|
||||||
|
### `POST /api/v1/free-issues`
|
||||||
|
Same contract as `POST /api/v1/sales-slips`.
|
||||||
|
|
||||||
|
### `PUT /api/v1/free-issues/{freeIssueId}`
|
||||||
|
Same contract as `PUT /api/v1/sales-slips/{salesSlipId}`.
|
||||||
|
|
||||||
|
### `POST /api/v1/free-issues/{freeIssueId}/post`
|
||||||
|
Same contract as `POST /api/v1/sales-slips/{salesSlipId}/post`.
|
||||||
|
|
||||||
|
### `POST /api/v1/free-issues/{freeIssueId}/cancel`
|
||||||
|
Same contract as `POST /api/v1/sales-slips/{salesSlipId}/cancel`.
|
||||||
|
|
||||||
|
Business rule:
|
||||||
|
- free issue is represented by `IsFreeIssue=true` and/or `FreeQty>0`
|
||||||
|
- it still posts stock out through the normal slip posting flow
|
||||||
|
- the frontend free-issue screen is the main working page for create/list/edit/cancel
|
||||||
|
- cancelled free-issue records are hidden from the working list view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sales Reports
|
||||||
|
|
||||||
|
### `GET /api/v1/reports/sales`
|
||||||
|
Returns the report catalog.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "daily-summary",
|
||||||
|
"name": "Daily Summary",
|
||||||
|
"description": "Aggregated sales by day across posted invoices and slips.",
|
||||||
|
"supportedFilters": ["from", "to"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/reports/sales/{reportId}`
|
||||||
|
Returns a single report definition by id.
|
||||||
|
|
||||||
|
### `POST /api/v1/reports/sales/query`
|
||||||
|
Returns the actual report data.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reportType": "item-summary",
|
||||||
|
"from": "2026-07-01",
|
||||||
|
"to": "2026-07-28",
|
||||||
|
"itemId": 1,
|
||||||
|
"customerId": null,
|
||||||
|
"warehouseId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported report types:
|
||||||
|
- `daily-summary`
|
||||||
|
- `item-summary`
|
||||||
|
- `customer-summary`
|
||||||
|
- `warehouse-summary`
|
||||||
|
- `discount-summary`
|
||||||
|
- `free-issue-summary`
|
||||||
|
|
||||||
|
Validation rule:
|
||||||
|
- invalid filters for a given report type are rejected
|
||||||
|
- unsupported report types are rejected
|
||||||
|
- the frontend report page uses the same endpoint for all report types
|
||||||
|
- `daily-summary` auto-loads with a default date range in the UI
|
||||||
|
- the report results table is rendered from the backend response rows, not hardcoded data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Notes
|
||||||
|
|
||||||
|
- The sales report service reads both invoices and slips where relevant.
|
||||||
|
- Free issue reporting is derived from the same sales document lines.
|
||||||
|
- There is no separate free-issue table in the current schema.
|
||||||
@@ -229,13 +229,15 @@ When an invoice or slip is posted:
|
|||||||
- `POST /api/v1/sales-slips`
|
- `POST /api/v1/sales-slips`
|
||||||
- `POST /api/v1/sales-slips/{id}/post`
|
- `POST /api/v1/sales-slips/{id}/post`
|
||||||
- `POST /api/v1/sales-slips/{id}/cancel`
|
- `POST /api/v1/sales-slips/{id}/cancel`
|
||||||
- `GET /api/v1/sales-reports/daily-summary`
|
- `GET /api/v1/free-issues`
|
||||||
- `GET /api/v1/sales-reports/item-wise`
|
- `GET /api/v1/free-issues/{id}`
|
||||||
- `GET /api/v1/sales-reports/customer-wise`
|
- `POST /api/v1/free-issues`
|
||||||
- `GET /api/v1/sales-reports/warehouse-wise`
|
- `PUT /api/v1/free-issues/{id}`
|
||||||
- `GET /api/v1/sales-reports/discount-summary`
|
- `POST /api/v1/free-issues/{id}/post`
|
||||||
- `GET /api/v1/sales-reports/free-issue-summary`
|
- `POST /api/v1/free-issues/{id}/cancel`
|
||||||
- `GET /api/v1/sales-reports/margin-summary`
|
- `GET /api/v1/reports/sales`
|
||||||
|
- `GET /api/v1/reports/sales/{reportId}`
|
||||||
|
- `POST /api/v1/reports/sales/query`
|
||||||
|
|
||||||
### Phase 1 Folder / Module Plan
|
### Phase 1 Folder / Module Plan
|
||||||
- `Domain/Entities`
|
- `Domain/Entities`
|
||||||
@@ -264,7 +266,7 @@ When an invoice or slip is posted:
|
|||||||
4. Pricing resolver
|
4. Pricing resolver
|
||||||
5. Discount computation
|
5. Discount computation
|
||||||
6. Free issue handling
|
6. Free issue handling
|
||||||
7. Stock posting and ledger integration
|
7. Stock posting
|
||||||
8. Basic sales reports
|
8. Basic sales reports
|
||||||
9. Controllers and swagger wiring
|
9. Controllers and swagger wiring
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user