Compare commits

..

5 Commits

Author SHA1 Message Date
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
19 changed files with 8537 additions and 114 deletions
@@ -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)]
+40 -2
View File
@@ -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 }
);
}
}
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)}
@@ -154,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 },
],
},
{
@@ -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}`)
},
@@ -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). */
+42
View File
@@ -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 {