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.
This commit is contained in:
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, 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}")]
|
[HttpGet("{runId:int}")]
|
||||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
|
|||||||
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
|
||||||
JsonElement? Payload, int UserId, DateTime CreatedAt);
|
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>
|
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
|
||||||
public sealed record RunGraphDto(
|
public sealed record RunGraphDto(
|
||||||
int RunId, string DocNo, int TemplateId, string TemplateName,
|
int RunId, string DocNo, int TemplateId, string TemplateName,
|
||||||
|
|||||||
@@ -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 = 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 = 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 = 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 = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
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.
|
// 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 = 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 = 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",
|
Label = "Sales",
|
||||||
SortOrder = 13,
|
SortOrder = 13,
|
||||||
Status = "Active"
|
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,
|
PermissionId = 34,
|
||||||
Code = "NAV:accounts.received-cheques",
|
Code = "NAV:accounts.received-cheques",
|
||||||
SubNavItemId = 22
|
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,
|
NavItemId = 12,
|
||||||
SortOrder = 3,
|
SortOrder = 3,
|
||||||
Status = "Active"
|
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);
|
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>
|
/// <summary>
|
||||||
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
|
||||||
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
|
/// 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);
|
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)
|
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
|
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
|
||||||
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
|
{ 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,
|
CancelRunResult,
|
||||||
CompleteStageRequest,
|
CompleteStageRequest,
|
||||||
CreateRunRequest,
|
CreateRunRequest,
|
||||||
|
ProducedListResponse,
|
||||||
ProductionRunGraph,
|
ProductionRunGraph,
|
||||||
ProductionRunStatus,
|
ProductionRunStatus,
|
||||||
ProductionRunSummary,
|
ProductionRunSummary,
|
||||||
@@ -43,11 +44,24 @@ export interface ListRunsParams {
|
|||||||
sort?: string
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListProducedParams {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
templateId?: number
|
||||||
|
warehouseId?: number
|
||||||
|
status?: ProductionRunStatus
|
||||||
|
}
|
||||||
|
|
||||||
export const productionRunsApi = {
|
export const productionRunsApi = {
|
||||||
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
|
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
|
||||||
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
|
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>> {
|
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
|
||||||
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
|
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -306,6 +306,46 @@ export interface ProductionRunSummary {
|
|||||||
completedAt: string | null
|
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 {
|
export interface ProductionRunGraph {
|
||||||
runId: number
|
runId: number
|
||||||
docNo: string
|
docNo: string
|
||||||
|
|||||||
Reference in New Issue
Block a user