feat: add production navigation permissions and produced items page #50

Merged
ImanThiyanga merged 1 commits from fix/production-costing into Dev 2026-08-21 12:02:27 +05:30
14 changed files with 8113 additions and 3 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)]
@@ -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,
@@ -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);
@@ -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: [
{ 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}`)
},
+40
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