Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b669395e | |||
| 8835ba9500 | |||
| 251b23f4b5 | |||
| 283a56723f | |||
| 9a38cc1131 | |||
| 1c5b05e315 | |||
| efa4ad3770 |
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
|
||||
|
||||
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
|
||||
/// something, plus a summary total over every filtered row.</summary>
|
||||
[HttpGet("produced")]
|
||||
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? templateId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] ProductionRunStatus? status,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
|
||||
|
||||
[HttpGet("{runId:int}")]
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
|
||||
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
|
||||
/// terminal output of a run that has produced something, costed the same way
|
||||
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
|
||||
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
|
||||
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
|
||||
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
|
||||
/// one, so run and produced line coincide 1:1.
|
||||
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
|
||||
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
|
||||
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
|
||||
/// </summary>
|
||||
public sealed record ProducedLineDto(
|
||||
int RunId, string DocNo, ProductionRunStatus Status,
|
||||
int TemplateId, string TemplateName,
|
||||
int WarehouseId, string WarehouseName,
|
||||
int ItemId, string ItemName,
|
||||
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
|
||||
decimal UnitCost, decimal TotalCost,
|
||||
DateTime CreatedAt, DateTime? CompletedAt);
|
||||
|
||||
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
|
||||
/// matched, not just the current page (there is no pagination here, matching the Sales report
|
||||
/// convention of flat, filter-bounded rows).</summary>
|
||||
public sealed record ProducedSummaryDto(
|
||||
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
|
||||
|
||||
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
|
||||
|
||||
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
|
||||
public sealed record RunGraphDto(
|
||||
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
|
||||
/// (<c>409 STAGE_NOT_EDITABLE</c>).
|
||||
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
|
||||
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
|
||||
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
|
||||
/// STAGE_NOT_EDITABLE</c> for every other status).
|
||||
/// </summary>
|
||||
public sealed class UpdateStageQuantitiesRequest
|
||||
{
|
||||
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required only when this line lowers a running Stock input below what it has already
|
||||
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
|
||||
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
|
||||
/// </summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
|
||||
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
|
||||
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
// doesn't silently lose it just because the section it lives under changed.
|
||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
|
||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductionNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 35, "NAV:production", 14, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
|
||||
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
|
||||
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 36, "NAV:production.templates", null, 24 },
|
||||
{ 37, "NAV:production.runs", null, 25 },
|
||||
{ 38, "NAV:production.produced", null, 26 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 35);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 36);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 37);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 38);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 24);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 25);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 26);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2334,6 +2334,15 @@ namespace ERPCore.Migrations
|
||||
Label = "Sales",
|
||||
SortOrder = 13,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 14,
|
||||
Code = "production",
|
||||
Href = "/dashboard/production",
|
||||
Label = "Production",
|
||||
SortOrder = 14,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2880,6 +2889,30 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 34,
|
||||
Code = "NAV:accounts.received-cheques",
|
||||
SubNavItemId = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 35,
|
||||
Code = "NAV:production",
|
||||
NavItemId = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 36,
|
||||
Code = "NAV:production.templates",
|
||||
SubNavItemId = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 37,
|
||||
Code = "NAV:production.runs",
|
||||
SubNavItemId = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 38,
|
||||
Code = "NAV:production.produced",
|
||||
SubNavItemId = 26
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5296,6 +5329,36 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 12,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 24,
|
||||
Code = "production.templates",
|
||||
Href = "/dashboard/production/templates",
|
||||
Label = "Templates",
|
||||
NavItemId = 14,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 25,
|
||||
Code = "production.runs",
|
||||
Href = "/dashboard/production/runs",
|
||||
Label = "Runs",
|
||||
NavItemId = 14,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 26,
|
||||
Code = "production.produced",
|
||||
Href = "/dashboard/production/produced",
|
||||
Label = "Produced",
|
||||
NavItemId = 14,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ public interface IProductionRunService
|
||||
|
||||
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Produced-items report (FR-MFG-13): one row per run that has produced something,
|
||||
/// terminal-output quantities and roll-up cost, plus a summary total over every
|
||||
/// filtered row. No pagination — bounded by the filters like the Sales reports.
|
||||
/// </summary>
|
||||
Task<ProducedListResponseDto> ListProducedAsync(
|
||||
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
||||
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
||||
|
||||
@@ -124,6 +124,75 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ProducedListResponseDto> ListProducedAsync(
|
||||
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _runs.Query().AsNoTracking()
|
||||
// FR-MFG-05 (ProductionGraphValidator) guarantees exactly one item-bearing
|
||||
// output per run, always the terminal one — a row exists once that output has
|
||||
// actually produced something.
|
||||
.Where(r => r.Stages.Any(s => s.Outputs.Any(o => o.ItemId != null && o.ProducedQty > 0)));
|
||||
|
||||
if (from is not null) q = q.Where(r => r.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(r => r.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
if (status is not null) q = q.Where(r => r.Status == status);
|
||||
|
||||
// Projected to an anonymous type first, then mapped client-side — EF Core 10 cannot
|
||||
// translate a record constructor call inside a projection alongside aggregates (same
|
||||
// failure mode noted in ListAsync above).
|
||||
var rows = await q
|
||||
.OrderByDescending(r => r.RunId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.RunId,
|
||||
r.DocNo,
|
||||
r.Status,
|
||||
r.TemplateId,
|
||||
TemplateName = r.Template!.Name,
|
||||
r.WarehouseId,
|
||||
WarehouseName = r.Warehouse!.Name,
|
||||
Output = r.Stages.SelectMany(s => s.Outputs)
|
||||
.Where(o => o.ItemId != null)
|
||||
.Select(o => new { o.ItemId, ItemName = o.Item!.Name, o.ProducedQty, o.ScrappedQty })
|
||||
.First(),
|
||||
ConsumedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue),
|
||||
ReturnedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue),
|
||||
r.CreatedAt,
|
||||
r.CompletedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var items = rows.Select(r =>
|
||||
{
|
||||
var good = r.Output.ProducedQty - r.Output.ScrappedQty;
|
||||
// Same formula PostReceiptAsync uses to cost the finished layer at approve
|
||||
// (FR-MFG-13): pool / good qty, 6dp. TotalCost carries the pool exactly rather
|
||||
// than good * unitCost, which drifts from it past ~100 units (see the ledgerValue
|
||||
// rounding note in PostReceiptAsync).
|
||||
var pool = r.ConsumedValue - r.ReturnedValue;
|
||||
var unitCost = good > 0 ? Math.Round(pool / good, 6, MidpointRounding.AwayFromZero) : 0m;
|
||||
return new ProducedLineDto(
|
||||
r.RunId, r.DocNo, r.Status, r.TemplateId, r.TemplateName,
|
||||
r.WarehouseId, r.WarehouseName,
|
||||
r.Output.ItemId!.Value, r.Output.ItemName,
|
||||
r.Output.ProducedQty, r.Output.ScrappedQty, good,
|
||||
unitCost, pool,
|
||||
r.CreatedAt, r.CompletedAt);
|
||||
}).ToList();
|
||||
|
||||
var summary = new ProducedSummaryDto(
|
||||
items.Count,
|
||||
items.Sum(i => i.ProducedQty),
|
||||
items.Sum(i => i.ScrappedQty),
|
||||
items.Sum(i => i.GoodQty),
|
||||
items.Sum(i => i.TotalCost));
|
||||
|
||||
return new ProducedListResponseDto(items, summary);
|
||||
}
|
||||
|
||||
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
|
||||
{
|
||||
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
|
||||
@@ -305,17 +374,58 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
|
||||
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
|
||||
|
||||
// FR-MFG-08: editable until the stage starts. Once started, consumption has
|
||||
// already happened against the planned figures.
|
||||
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
|
||||
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
|
||||
// input can still move — raising it draws the extra amount via FIFO (mirroring
|
||||
// StartStageAsync's initial draw), lowering it returns the excess to stock
|
||||
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
|
||||
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
|
||||
throw new DomainException(ErrorCodes.StageNotEditable,
|
||||
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
|
||||
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var balances = new Dictionary<int, decimal>();
|
||||
var consumed = new List<ConsumedInputDto>();
|
||||
var returned = new List<object>();
|
||||
|
||||
foreach (var line in request.Inputs)
|
||||
{
|
||||
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
|
||||
|
||||
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
|
||||
|
||||
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
|
||||
var netConsumed = input.ConsumedQty - input.ReturnedQty;
|
||||
var delta = plannedBase - netConsumed;
|
||||
|
||||
if (delta > 0)
|
||||
{
|
||||
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
|
||||
consumed.Add(dto);
|
||||
}
|
||||
else if (delta < 0)
|
||||
{
|
||||
var qty = -delta;
|
||||
if (line.ReasonCodeId is null)
|
||||
throw new DomainException(ErrorCodes.ReasonCodeRequired,
|
||||
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
|
||||
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
|
||||
|
||||
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
|
||||
returned.Add(new
|
||||
{
|
||||
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
|
||||
reasonCodeId = line.ReasonCodeId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
input.PlannedQty = line.PlannedQty;
|
||||
}
|
||||
|
||||
@@ -330,12 +440,15 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
// Raising an upstream input's planned quantity means the deliveries that made
|
||||
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
|
||||
// does not state this; it is the only behaviour consistent with FR-MFG-09.
|
||||
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
|
||||
RecomputeReadiness(stage);
|
||||
|
||||
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
|
||||
{
|
||||
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
|
||||
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
|
||||
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
|
||||
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
|
||||
returned
|
||||
});
|
||||
}, ct);
|
||||
|
||||
@@ -344,6 +457,87 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
|
||||
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
|
||||
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
|
||||
/// (raising planned qty on a running stage) so the two never drift apart.
|
||||
/// </summary>
|
||||
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
|
||||
ProductionRun run, RunStageInput input, Item item, decimal delta,
|
||||
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
var unitCost = delta == 0 ? 0m : value / delta;
|
||||
|
||||
if (!balances.TryGetValue(item.ItemId, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
|
||||
balance -= delta;
|
||||
balances[item.ItemId] = balance;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
|
||||
Direction.Out, delta, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
|
||||
|
||||
input.ConsumedQty += delta;
|
||||
input.ConsumedValue += value;
|
||||
|
||||
var dto = new ConsumedInputDto(
|
||||
input.RunInputId, item.ItemId, delta, value,
|
||||
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
|
||||
return (dto, entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
|
||||
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
|
||||
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
|
||||
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
|
||||
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
|
||||
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
|
||||
/// </summary>
|
||||
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
|
||||
ProductionRun run, RunStageInput input, decimal qty,
|
||||
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
|
||||
{
|
||||
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
|
||||
// so what leaves the pool is exactly what entered it.
|
||||
var weighted = input.ConsumedValue / input.ConsumedQty;
|
||||
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
|
||||
|
||||
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
|
||||
// multiplying lets successive partial returns accumulate drift until the last one
|
||||
// can exceed ConsumedValue.
|
||||
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
|
||||
// Full return: take the exact residual so the input's contribution to the pool
|
||||
// lands on precisely zero rather than a rounding crumb.
|
||||
? input.ConsumedValue - input.ReturnedValue
|
||||
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
|
||||
qty, unitCost, now, ct);
|
||||
|
||||
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
|
||||
balance += qty;
|
||||
balances[input.ItemId.Value] = balance;
|
||||
|
||||
// binId stays null: this is raw material going back to general stock, not into the
|
||||
// run's finished-goods output bin.
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
|
||||
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
|
||||
|
||||
input.ReturnedQty += qty;
|
||||
input.ReturnedValue += returnValue;
|
||||
|
||||
return (unitCost, returnValue, layer, entry);
|
||||
}
|
||||
|
||||
// --- stage actions -------------------------------------------------------
|
||||
|
||||
public async Task<StartStageResultDto> StartStageAsync(
|
||||
@@ -361,7 +555,6 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
// Running balances accumulated per item across the loop: layers written in this
|
||||
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
|
||||
@@ -386,27 +579,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
var delta = plannedBase - input.ConsumedQty;
|
||||
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
|
||||
|
||||
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
var unitCost = delta == 0 ? 0m : value / delta;
|
||||
|
||||
if (!balances.TryGetValue(item.ItemId, out var balance))
|
||||
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
|
||||
balance -= delta;
|
||||
balances[item.ItemId] = balance;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
|
||||
Direction.Out, delta, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
|
||||
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
|
||||
ledgerRefs.Add(entry);
|
||||
|
||||
input.ConsumedQty += delta;
|
||||
input.ConsumedValue += value;
|
||||
|
||||
consumed.Add(new ConsumedInputDto(
|
||||
input.RunInputId, item.ItemId, delta, value,
|
||||
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
|
||||
consumed.Add(dto);
|
||||
}
|
||||
|
||||
stage.Status = ProductionStageStatus.InProgress;
|
||||
@@ -649,36 +824,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
var now = DateTime.UtcNow;
|
||||
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
|
||||
|
||||
// Weighted cost actually consumed for THIS input — cost-preserving by
|
||||
// construction, so what leaves the pool is exactly what entered it.
|
||||
var weighted = input.ConsumedValue / input.ConsumedQty;
|
||||
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
|
||||
returnedQty = request.Qty;
|
||||
|
||||
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
|
||||
// then multiplying lets successive partial returns accumulate drift until the
|
||||
// last one can exceed ConsumedValue.
|
||||
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
|
||||
// Full return: take the exact residual so the input's contribution to the
|
||||
// pool lands on precisely zero rather than a rounding crumb.
|
||||
? input.ConsumedValue - input.ReturnedValue
|
||||
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
layer = await _fifo.CreateInboundLayerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
|
||||
request.Qty, unitCost, now, token);
|
||||
|
||||
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
|
||||
|
||||
// binId stays null: this is raw material going back to general stock, not into the
|
||||
// run's finished-goods output bin.
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
|
||||
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
|
||||
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
|
||||
|
||||
input.ReturnedQty += request.Qty;
|
||||
input.ReturnedValue += returnValue;
|
||||
(unitCost, returnValue, layer, entry) =
|
||||
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
|
||||
|
||||
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
||||
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, BookOpen } from "lucide-react"
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function GeneralLedgerReportPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
|
||||
<p className="text-base text-muted-foreground">Every posted movement on every account, with a running balance per account.</p>
|
||||
<p className="text-base text-muted-foreground">Every posted movement, grouped by transaction so every leg of a GRN, Sale, or other journal entry sits together.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,37 +108,32 @@ export default function GeneralLedgerReportPage() {
|
||||
<Table className="mt-4 text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Journal No.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Narration</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Description</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Running balance</TableHead>
|
||||
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => {
|
||||
const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{isNewAccount && (
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableCell colSpan={6} className="px-3 py-2 font-semibold">
|
||||
{row.accountCode} — {row.accountName}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.journalNo}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right font-medium tabular-nums">{formatAmount(row.runningBalance)}</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
|
||||
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
|
||||
lands on adjacent rows instead of being scattered under separate account blocks. */}
|
||||
{[...rows]
|
||||
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
|
||||
.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PackageCheck } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { productionRunsApi } from "@/lib/api/production-runs"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } 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, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = ProductionRunStatus | "All"
|
||||
|
||||
function runStatusBadgeClass(status: ProductionRunStatus) {
|
||||
if (status === "Completed") return "bg-success/10 text-success"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
|
||||
return "bg-info/10 text-info"
|
||||
}
|
||||
|
||||
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
|
||||
// cost of a fractional-value item is still legible without dragging the whole table wide.
|
||||
function formatCost(value: number) {
|
||||
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
export default function ProducedItemsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// null = still loading (the codebase convention for "no data yet" vs "empty result").
|
||||
const [data, setData] = useState<ProducedListResponse | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
|
||||
const [from, setFrom] = useState("")
|
||||
const [to, setTo] = useState("")
|
||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null)
|
||||
productionRunsApi
|
||||
.listProduced({
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
templateId: templateId ?? undefined,
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err) => {
|
||||
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
||||
setLoadError(errorMessage(err))
|
||||
})
|
||||
}, [from, to, templateId, warehouseId, status])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([templateRes, warehouseRes]) => {
|
||||
setTemplates(templateRes.items)
|
||||
setWarehouses(warehouseRes.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Template</Label>
|
||||
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Status</Label>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setFrom("")
|
||||
setTo("")
|
||||
setTemplateId(null)
|
||||
setWarehouseId(null)
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{data === null ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</div>
|
||||
) : data.items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
RunStage,
|
||||
RunStageInput,
|
||||
RunStageOutput,
|
||||
StageQtyUnit,
|
||||
StageQuantityLine,
|
||||
TransferLine,
|
||||
} from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { OnHand, ReasonCode } from "@/types/stock"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
|
||||
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
|
||||
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
|
||||
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
|
||||
* to ask for a reason up front. The server always recomputes it authoritatively.
|
||||
*/
|
||||
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
|
||||
if (unit === "Pack" || itemId === null) return qty
|
||||
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
|
||||
if (!contentBaseQty) return qty
|
||||
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
|
||||
// stored at that rounded value, so comparing against the raw division here would flag an
|
||||
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
|
||||
// decrease and demand a reason code nothing actually changed.
|
||||
return Math.round((qty / contentBaseQty) * 10000) / 10000
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: StageStatus }) {
|
||||
const color = STAGE_STATUS_COLOR[status]
|
||||
return (
|
||||
@@ -102,9 +120,13 @@ export function StageDrawer({
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Editable planned quantities (Ready only) keyed by run input/output id.
|
||||
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
|
||||
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
|
||||
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
|
||||
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
|
||||
// Reason code for a running Stock input whose planned qty is being lowered below what it
|
||||
// has already consumed — required by the server the same way a leftover return is.
|
||||
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
|
||||
|
||||
// Complete-form state.
|
||||
const [produced, setProduced] = useState<Record<number, string>>({})
|
||||
@@ -135,6 +157,7 @@ export function StageDrawer({
|
||||
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
|
||||
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
|
||||
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
|
||||
setInputReasonCode({})
|
||||
setError(null)
|
||||
idempotencyKey.current = crypto.randomUUID()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -212,7 +235,11 @@ export function StageDrawer({
|
||||
|
||||
function saveQuantities() {
|
||||
const inputs: StageQuantityLine[] = stage!.inputs
|
||||
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
|
||||
.map((i) => ({
|
||||
id: i.runInputId,
|
||||
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
|
||||
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
|
||||
}))
|
||||
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
|
||||
const outputs: StageQuantityLine[] = stage!.outputs
|
||||
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
|
||||
@@ -284,6 +311,21 @@ export function StageDrawer({
|
||||
|
||||
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
|
||||
|
||||
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
|
||||
// lowering it returns the excess and — same rule a leftover return already enforces
|
||||
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
|
||||
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
|
||||
const isInputDecrease = (input: RunStageInput) => {
|
||||
const plannedBase = toPacksClient(
|
||||
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
|
||||
)
|
||||
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
|
||||
}
|
||||
const missingInputReasons =
|
||||
stage.status === "InProgress"
|
||||
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
|
||||
: []
|
||||
|
||||
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
|
||||
const missingScrapReasons = stage.outputs
|
||||
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
|
||||
@@ -339,6 +381,7 @@ export function StageDrawer({
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
unitLabel={inputUnit}
|
||||
warehouseId={run.warehouseId}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -424,6 +467,39 @@ export function StageDrawer({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
|
||||
reason for any decrease.
|
||||
</p>
|
||||
{runningStockInputs.map((input) => (
|
||||
<RunningStockInputRow
|
||||
key={input.runInputId}
|
||||
input={input}
|
||||
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
unitLabel={inputUnit}
|
||||
warehouseId={run.warehouseId}
|
||||
decreasing={isInputDecrease(input)}
|
||||
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
|
||||
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
|
||||
scrapReasons={scrapReasons}
|
||||
/>
|
||||
))}
|
||||
{missingInputReasons.length > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Pick a reason for lowering: {missingInputReasons.join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
|
||||
Save quantities
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage.status === "InProgress" && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Record output</h3>
|
||||
@@ -662,6 +738,56 @@ export function StageDrawer({
|
||||
}
|
||||
|
||||
/** Split out only because the input card has three mutually exclusive quantity presentations. */
|
||||
/**
|
||||
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
|
||||
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
|
||||
*/
|
||||
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
|
||||
const [onHand, setOnHand] = useState<OnHand | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!enabled || itemId === null) {
|
||||
setOnHand(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
stockApi
|
||||
.onHand(itemId, warehouseId)
|
||||
.then(setOnHand)
|
||||
.catch((err) => {
|
||||
setOnHand(null)
|
||||
setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [enabled, itemId, warehouseId])
|
||||
return { onHand, loading, error }
|
||||
}
|
||||
|
||||
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
|
||||
* RunningStockInputRow both show under an editable Planned qty. */
|
||||
function AvailableStockLine({
|
||||
loading, error, onHand,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onHand: OnHand | null
|
||||
}) {
|
||||
return (
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Checking available stock…"
|
||||
: error
|
||||
? "Available stock unknown"
|
||||
: onHand
|
||||
? `Available: ${fmt(onHand.available)}`
|
||||
: null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function InputCard({
|
||||
input,
|
||||
editable,
|
||||
@@ -669,6 +795,7 @@ function InputCard({
|
||||
onValueChange,
|
||||
itemName,
|
||||
unitLabel,
|
||||
warehouseId,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
editable: boolean
|
||||
@@ -676,10 +803,16 @@ function InputCard({
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
unitLabel: (input: RunStageInput) => string
|
||||
warehouseId: number
|
||||
}) {
|
||||
const isUpstream = input.source === "Upstream"
|
||||
const short = isUpstream && input.deliveredQty < input.plannedQty
|
||||
|
||||
// Available stock is only meaningful while the planned qty is still editable (Ready) and
|
||||
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
|
||||
const { onHand, loading: onHandLoading, error: onHandError } =
|
||||
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
@@ -691,17 +824,22 @@ function InputCard({
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{editable ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{!isUpstream && input.itemId !== null && (
|
||||
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Row label="Planned">{fmt(input.plannedQty)}</Row>
|
||||
)}
|
||||
@@ -735,6 +873,69 @@ function InputCard({
|
||||
)
|
||||
}
|
||||
|
||||
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
|
||||
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
|
||||
function RunningStockInputRow({
|
||||
input,
|
||||
value,
|
||||
onValueChange,
|
||||
itemName,
|
||||
unitLabel,
|
||||
warehouseId,
|
||||
decreasing,
|
||||
reasonCodeId,
|
||||
onReasonChange,
|
||||
scrapReasons,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
unitLabel: (input: RunStageInput) => string
|
||||
warehouseId: number
|
||||
decreasing: boolean
|
||||
reasonCodeId: number | null
|
||||
onReasonChange: (id: number) => void
|
||||
scrapReasons: ReasonCode[]
|
||||
}) {
|
||||
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Planned</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
|
||||
{decreasing && (
|
||||
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
|
||||
<SelectTrigger className="h-9! w-full text-sm">
|
||||
<SelectValue placeholder="Reason for lowering (required)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scrapReasons.map((r) => (
|
||||
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
|
||||
{r.code} — {r.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
|
||||
function CustomFields({
|
||||
stage,
|
||||
|
||||
@@ -27,6 +27,10 @@ function slugify(label: string) {
|
||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function fmtStock(available: number | undefined) {
|
||||
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
|
||||
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
|
||||
|
||||
@@ -128,6 +132,7 @@ export function StageEditorPanel({
|
||||
upstreamOptions,
|
||||
items,
|
||||
uoms,
|
||||
stockByItem,
|
||||
readOnly,
|
||||
onChange,
|
||||
onDelete,
|
||||
@@ -138,6 +143,8 @@ export function StageEditorPanel({
|
||||
upstreamOptions: UpstreamOutputOption[]
|
||||
items: ItemListItem[]
|
||||
uoms: Uom[]
|
||||
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
|
||||
stockByItem: Map<number, number>
|
||||
readOnly: boolean
|
||||
onChange: (patch: Partial<StageNodeData>) => void
|
||||
onDelete: () => void
|
||||
@@ -239,18 +246,19 @@ export function StageEditorPanel({
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
<Select<string>
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
onValueChange={(v) => v && onChange({ roleLabel: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@@ -312,6 +320,9 @@ export function StageEditorPanel({
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
<span className="text-muted-foreground">
|
||||
· Stock: {fmtStock(stockByItem.get(i.itemId))}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
|
||||
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
|
||||
}, [isNew, templateId, load])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
|
||||
.then(([itemRes, uomRes]) => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
stockApi.onHandList({ pageSize: 200 }),
|
||||
])
|
||||
.then(([itemRes, uomRes, onHandRes]) => {
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
// Templates aren't tied to a warehouse, so the picker shows availability summed across
|
||||
// every warehouse the item holds stock in.
|
||||
const byItem = new Map<number, number>()
|
||||
for (const row of onHandRes.items) {
|
||||
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
|
||||
}
|
||||
setStockByItem(byItem)
|
||||
})
|
||||
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
|
||||
}, [])
|
||||
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
stockByItem={stockByItem}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
|
||||
@@ -244,8 +244,8 @@ export default function BundleSalesPage() {
|
||||
</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-right font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
@@ -271,11 +271,11 @@ export default function BundleSalesPage() {
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Rows loaded</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{visibleRows.length}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{visibleRows.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="font-mono text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
|
||||
<div className="text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function SalesDayEndPage() {
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{preview.draftSlipsBlockingClose.map((s) => (
|
||||
<li key={`slip-${s.salesSlipId}`}>
|
||||
<Link href={`/dashboard/sales/slips/${s.salesSlipId}`} className="underline">{s.slipNo}</Link> — {s.grandTotal.toFixed(2)}
|
||||
{s.slipNo} — {s.grandTotal.toFixed(2)}
|
||||
</li>
|
||||
))}
|
||||
{preview.draftBundleSalesBlockingClose.map((b) => (
|
||||
|
||||
@@ -431,8 +431,8 @@ export default function NewFreeIssuePage() {
|
||||
<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 text-right tabular-nums">{row.qty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function NewSalesInvoicePage() {
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New invoice</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
|
||||
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function SalesInvoicesPage() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-foreground">Sales</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">
|
||||
@@ -90,7 +90,7 @@ export default function SalesInvoicesPage() {
|
||||
</Link>
|
||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Invoice
|
||||
New Sale
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,10 +172,10 @@ export default function SalesInvoicesPage() {
|
||||
<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 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right 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}
|
||||
@@ -200,15 +200,15 @@ export default function SalesInvoicesPage() {
|
||||
<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 className="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 className="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 className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||
import { FileBarChart, FileText, Lock, PackageX, ReceiptText } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -11,12 +11,6 @@ const sections = [
|
||||
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.",
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, 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 { baseUomLabel } from "@/lib/uom-label"
|
||||
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,
|
||||
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,
|
||||
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 resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.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),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
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">
|
||||
<Link
|
||||
href={`/print/sales/slips/${slip.salesSlipId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Link>
|
||||
<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>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</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="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</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 className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
"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 { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type Line = CreateSalesSlipLineRequest & { key: string }
|
||||
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 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,
|
||||
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,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.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.")
|
||||
|
||||
const payload: CreateSalesSlipRequest = {
|
||||
customerId,
|
||||
warehouseId,
|
||||
cashierUserId,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
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 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</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"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"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)
|
||||
const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||
|
||||
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">
|
||||
<Link
|
||||
href={printHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
<Printer className="size-5" />
|
||||
Print batch
|
||||
</Link>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ function LoginForm() {
|
||||
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<Image src="/images/logo%20(2).png" alt="Hexa ERP" width={300} height={150} />
|
||||
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import { Printer } 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 { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesSlip } from "@/types/sales"
|
||||
|
||||
export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const slipId = Number(resolvedParams.id)
|
||||
const [slip, setSlip] = useState<SalesSlip | 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 [error, setError] = useState<string | 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)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [resolvedParams.id, slipId])
|
||||
|
||||
const subtotal = useMemo(() => slip?.totals.subtotal ?? 0, [slip])
|
||||
|
||||
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 print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
const customer = customers.find((c) => c.customerId === slip.customerId)
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slip</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h1>
|
||||
<div className="mt-2 text-sm text-muted-foreground">Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}</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">{slip.customerSnapshotName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
|
||||
{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 ?? `#${slip.warehouseId}`}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? slip.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">{slip.totals.subtotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{slip.totals.discountTotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(2)}</div>
|
||||
<div className="text-muted-foreground">Net total</div><div className="text-right font-semibold">{slip.totals.grandTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>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">Line total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{slip.lines.map((line) => (
|
||||
<TableRow key={line.salesSlipLineId}>
|
||||
<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>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
|
||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cashier</div>
|
||||
<div className="mt-2 text-sm text-foreground">{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SalesSlipSummary } from "@/types/sales"
|
||||
|
||||
function statusClass(status: SalesSlipSummary["status"]) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
function SalesSlipBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
salesApi
|
||||
.listSlips({ page: 1, pageSize: 200 })
|
||||
.then((res) => setRows(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const visibleRows = rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery =
|
||||
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
})
|
||||
|
||||
if (error && !rows) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!rows) {
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip batch print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slips</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">Slip Batch Print</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current slips.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Slip</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Lines</TableHead>
|
||||
<TableHead className="text-right">Gross</TableHead>
|
||||
<TableHead className="text-right">Discount</TableHead>
|
||||
<TableHead className="text-right">Net</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows?.map((row) => (
|
||||
<TableRow key={row.salesSlipId}>
|
||||
<TableCell className="font-medium">{row.slipNo}</TableCell>
|
||||
<TableCell>{row.customerSnapshotName}</TableCell>
|
||||
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SalesSlipBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<SalesSlipBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
@@ -114,15 +115,14 @@ const navItems: {
|
||||
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: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
|
||||
{ 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: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
@@ -144,7 +144,6 @@ const navItems: {
|
||||
],
|
||||
},
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "Production",
|
||||
code: "production",
|
||||
@@ -155,6 +154,7 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
||||
{ title: "Produced", code: "production.produced", href: "/dashboard/production/produced", icon: PackageCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -275,13 +275,14 @@ function SidebarContent({
|
||||
)}
|
||||
>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!iconOnly && (
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
|
||||
{iconOnly ? (
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={165} height={40} className="h-10 w-auto" priority />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -407,13 +408,11 @@ function SidebarContent({
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-center pt-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
|
||||
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{!iconOnly && (
|
||||
<p className="pt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} Hexa ERP
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
production: "Production",
|
||||
templates: "Templates",
|
||||
runs: "Runs",
|
||||
invoices: "Sales",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
|
||||
@@ -79,6 +79,9 @@ const STOCK_TITLES: Record<string, string> = {
|
||||
}
|
||||
|
||||
function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/sales/invoices") return "Sales"
|
||||
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
|
||||
|
||||
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
|
||||
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
|
||||
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CancelRunResult,
|
||||
CompleteStageRequest,
|
||||
CreateRunRequest,
|
||||
ProducedListResponse,
|
||||
ProductionRunGraph,
|
||||
ProductionRunStatus,
|
||||
ProductionRunSummary,
|
||||
@@ -43,11 +44,24 @@ export interface ListRunsParams {
|
||||
sort?: string
|
||||
}
|
||||
|
||||
export interface ListProducedParams {
|
||||
from?: string
|
||||
to?: string
|
||||
templateId?: number
|
||||
warehouseId?: number
|
||||
status?: ProductionRunStatus
|
||||
}
|
||||
|
||||
export const productionRunsApi = {
|
||||
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
|
||||
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/** Produced-items report (FR-MFG-13): flat rows + a summary total, no pagination. */
|
||||
listProduced(params: ListProducedParams = {}): Promise<ProducedListResponse> {
|
||||
return apiRequest<ProducedListResponse>(`/production-runs/produced${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
|
||||
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 829 KiB |
@@ -112,6 +112,8 @@ export interface GeneralLedgerRow {
|
||||
debitAmount: number
|
||||
creditAmount: number
|
||||
runningBalance: number
|
||||
/** Which module posted this journal (GRN, Sales, ManualLedgerEntry, Acc_Account, ...) — shown as the "Controller" column. */
|
||||
sourceModule: string | null
|
||||
}
|
||||
|
||||
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
|
||||
|
||||
@@ -306,6 +306,46 @@ export interface ProductionRunSummary {
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the produced-items report (`GET /production-runs/produced`) — the terminal
|
||||
* output of a run that has produced something. `unitCost`/`totalCost` use the same
|
||||
* `pool / goodQty` formula the server applies at approve (FR-MFG-13); they are final only
|
||||
* once `status` is `"Completed"` — a leftover return can still shrink the pool between the
|
||||
* terminal stage's Done and its Approve.
|
||||
*/
|
||||
export interface ProducedLine {
|
||||
runId: number
|
||||
docNo: string
|
||||
status: ProductionRunStatus
|
||||
templateId: number
|
||||
templateName: string
|
||||
warehouseId: number
|
||||
warehouseName: string
|
||||
itemId: number
|
||||
itemName: string
|
||||
producedQty: number
|
||||
scrappedQty: number
|
||||
goodQty: number
|
||||
unitCost: number
|
||||
totalCost: number
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
/** Aggregate footer over every row the filter matched (no pagination). */
|
||||
export interface ProducedSummary {
|
||||
runCount: number
|
||||
totalProducedQty: number
|
||||
totalScrappedQty: number
|
||||
totalGoodQty: number
|
||||
totalCost: number
|
||||
}
|
||||
|
||||
export interface ProducedListResponse {
|
||||
items: ProducedLine[]
|
||||
summary: ProducedSummary
|
||||
}
|
||||
|
||||
export interface ProductionRunGraph {
|
||||
runId: number
|
||||
docNo: string
|
||||
@@ -338,6 +378,8 @@ export interface CreateRunRequest {
|
||||
export interface StageQuantityLine {
|
||||
id: number
|
||||
plannedQty: number
|
||||
/** Required only when this lowers a running Stock input below what it has already consumed. */
|
||||
reasonCodeId?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateStageQuantitiesRequest {
|
||||
|
||||
Reference in New Issue
Block a user