From b12adebaa0e789d775b01b189586202fedcd2f04 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Tue, 28 Jul 2026 14:59:09 +0530 Subject: [PATCH 1/2] feat: add dashboard overview stats endpoint and UI integration - Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement. - Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data. - Developed `DashboardService` to fetch and compute necessary statistics from the database. - Added `IDashboardService` interface for service abstraction. - Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint. - Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend. - Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons. --- .../Controllers/DashboardController.cs | 20 + .../ERPCore/Dtos/Dashboard/DashboardDtos.cs | 24 + Backend/ERPCore/Program.cs | 3 + Backend/ERPCore/Services/DashboardService.cs | 79 +++ .../Services/Interfaces/IDashboardService.cs | 9 + Backend/PROGRESS.md | 7 + Frontend/PROGRESS.md | 10 + Frontend/erp-system/app/dashboard/page.tsx | 553 ++++++++---------- .../app/dashboard/products/brands/page.tsx | 31 +- .../dashboard/products/categories/page.tsx | 31 +- Frontend/erp-system/components/ui/select.tsx | 4 +- .../erp-system/components/ui/stat-card.tsx | 14 +- Frontend/erp-system/components/ui/table.tsx | 4 +- Frontend/erp-system/lib/api/dashboard.ts | 9 + Frontend/erp-system/types/dashboard.ts | 20 + docs/11-BACKEND-PHASE1.md | 22 + 16 files changed, 496 insertions(+), 344 deletions(-) create mode 100644 Backend/ERPCore/Controllers/DashboardController.cs create mode 100644 Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs create mode 100644 Backend/ERPCore/Services/DashboardService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IDashboardService.cs create mode 100644 Frontend/erp-system/lib/api/dashboard.ts create mode 100644 Frontend/erp-system/types/dashboard.ts diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs new file mode 100644 index 0000000..b10062f --- /dev/null +++ b/Backend/ERPCore/Controllers/DashboardController.cs @@ -0,0 +1,20 @@ +using ERPCore.Dtos.Dashboard; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Dashboard overview stats — cross-domain counts, not a stored entity. +[Route("api/v1/dashboard")] +public sealed class DashboardController : ApiControllerBase +{ + private readonly IDashboardService _dashboard; + + public DashboardController(IDashboardService dashboard) => _dashboard = dashboard; + + /// Aggregate counts for stock, GRN, and procurement. + [HttpGet("stats")] + [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)] + public async Task> GetStats(CancellationToken ct) + => Ok(await _dashboard.GetStatsAsync(ct)); +} diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs new file mode 100644 index 0000000..857ba9a --- /dev/null +++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs @@ -0,0 +1,24 @@ +namespace ERPCore.Dtos.Dashboard; + +/// +/// Aggregate counts for the dashboard overview — mirrors the widget set in +/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation, +/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting, +/// and open RFQs. Recent movements isn't here — it's just GET /stock/ledger with a small +/// pageSize, no aggregation needed. A single computed-on-read object, not a stored +/// entity — same as reorder alerts (docs/11 §5.7). +/// +public sealed record DashboardStatsDto( + int LowStockAlerts, + decimal OnHandTotal, + int OnHandWarehouses, + decimal StockValuationTotal, + IReadOnlyList StockValuationByWarehouse, + int PendingApprovalPurchaseOrders, + int PendingGrns, + int OpenRequisitions, + int PendingCounts, + int OpenRfqs); + +/// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse. +public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 8b028cf..4b7ba3e 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -97,6 +97,9 @@ builder.Services.AddScoped(); // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) builder.Services.AddScoped(); +// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement) +builder.Services.AddScoped(); + // HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/DashboardService.cs b/Backend/ERPCore/Services/DashboardService.cs new file mode 100644 index 0000000..1951559 --- /dev/null +++ b/Backend/ERPCore/Services/DashboardService.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Dashboard; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Aggregate counts pulled straight from each domain's repository — no PagedResponse +/// overhead, since the dashboard only needs totals. Low-stock reuses +/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7). +/// On-hand summary and stock valuation sum (and +/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts, +/// because they need no per-item live lookup. +/// +public sealed class DashboardService : IDashboardService +{ + private readonly IRepository _layers; + private readonly IRepository _grns; + private readonly IRepository _pos; + private readonly IRepository _requisitions; + private readonly IRepository _counts; + private readonly IRepository _rfqs; + private readonly IReorderService _reorder; + + public DashboardService( + IRepository layers, IRepository grns, IRepository pos, + IRepository requisitions, IRepository counts, IRepository rfqs, + IReorderService reorder) + { + _layers = layers; + _grns = grns; + _pos = pos; + _requisitions = requisitions; + _counts = counts; + _rfqs = rfqs; + _reorder = reorder; + } + + public async Task GetStatsAsync(CancellationToken ct = default) + { + // Sequential, not Task.WhenAll: these repositories share one scoped DbContext, + // which cannot run concurrent operations. + var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + var onHandWarehouses = await _layers.Query().AsNoTracking() + .Select(l => l.WarehouseId).Distinct().CountAsync(ct); + var stockValuationTotal = await _layers.Query().AsNoTracking() + .SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m; + // EF can't translate constructing WarehouseValuationDto directly inside the GroupBy + // Select — project to an anonymous type first, then materialize into the record. + var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking() + .GroupBy(l => l.WarehouseId) + .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) + .OrderByDescending(w => w.Total) + .ToListAsync(ct); + var stockValuationByWarehouse = stockValuationByWarehouseRaw + .Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) + .ToList(); + var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct); + var pendingApprovalPOs = await _pos.Query().AsNoTracking() + .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); + var openRequisitions = await _requisitions.Query().AsNoTracking() + .CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); + var pendingCounts = await _counts.Query().AsNoTracking() + .CountAsync(c => c.Status == CountStatus.Counted, ct); + var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); + + // PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging. + var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct)) + .Pagination.TotalItems; + + return new DashboardStatsDto( + lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, + pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs); + } +} diff --git a/Backend/ERPCore/Services/Interfaces/IDashboardService.cs b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs new file mode 100644 index 0000000..3d90a88 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs @@ -0,0 +1,9 @@ +using ERPCore.Dtos.Dashboard; + +namespace ERPCore.Services.Interfaces; + +/// Cross-domain aggregate stats for the dashboard overview. +public interface IDashboardService +{ + Task GetStatsAsync(CancellationToken ct = default); +} diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index debc2a8..49b419b 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -181,6 +181,13 @@ Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` ( ## Done +### 2026-07-28 — Dashboard overview endpoint (`GET /dashboard/stats`) +- **New cross-domain aggregate for the frontend dashboard** — `Dtos/Dashboard/DashboardDtos.cs`, `IDashboardService`/`DashboardService`, `DashboardController` (`GET /api/v1/dashboard/stats`). Mirrors `docs/dashboard-implementation.pdf`'s widget list: low-stock alerts (reuses `IReorderService.GetAlertsAsync`, `PageSize:1` since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-side `SUM`/`GROUP BY` over `StockLayer`, cheap unlike reorder alerts since they need no per-item live lookup), pending-approval POs, pending (Draft) GRNs, open (Submitted) requisitions, pending (Counted) stock counts, open RFQs. Registered in `Program.cs`. docs/11-BACKEND-PHASE1.md §5.8. +- **Not covered:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend calls `GET /stock/ledger` directly — no aggregation needed for a small `pageSize`). +- **Bug found + fixed during this work — `WarehouseValuationDto` construction inside `GroupBy().Select()` doesn't translate.** EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (`InvalidOperationException`, confirmed live via `logs/erpcore-20260728.log`). Fixed by projecting to an anonymous type first (`Select(g => new { g.Key, Total = ... })`), materializing with `ToListAsync`, then mapping to the DTO record client-side. +- **Unrelated bug found while testing this — `GET /items` 500s on every call: `column i.SalePrice does not exist`.** `ItemConfiguration.cs` maps `Item.SalePrice`, but the `AddItemSalePrice` migration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed via `logs/erpcore-20260727.log`; `dotnet ef migrations add` against the current model produces an **empty** migration (no `Up`/`Down` ops), meaning the model snapshot already believes `SalePrice` exists even though the column doesn't — the snapshot and the real schema have drifted. **Not yet fixed** — needs a hand-written `AddColumn` migration (the auto-diff can't see the gap) run against this specific database; blocks the dashboard's on-hand/valuation widgets from ever showing item names, and blocks the entire Products page and every item picker (GRN/PO/ledger/valuation). +- **Verified:** `dotnet build` clean (isolated output directory, to avoid the Visual-Studio-debugger file lock that repeatedly blocked rebuilding the live dev instance this session). Runtime-verified against the live log after a VS restart — confirmed reaching real code (not 404), the `GroupBy` bug above was caught this way. Full 200-response verification still pending the next VS restart. + ### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`) - **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1. - **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change** — `GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index d0f8725..817c9c1 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -118,6 +118,16 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md` ## Done +### 2026-07-28 — Dashboard overview (`app/dashboard/page.tsx`) +- **Replaced the component-showcase placeholder with a real stats dashboard.** 7 `StatCard` tiles (Low Stock Alerts, Stock On-Hand, Pending Approval POs, Pending GRNs, Open Requisitions, Open Counts, Active RFQs), all wired to the new `GET /dashboard/stats` (`lib/api/dashboard.ts`, `types/dashboard.ts`) — see `Backend/PROGRESS.md`'s matching 2026-07-28 entry for the endpoint itself. Each tile links to its source list page. +- **Stock Valuation by Warehouse** — `BarChart` over `stats.stockValuationByWarehouse`, warehouse codes resolved via `warehousesApi.list()`. +- **Stock Movement Trend** — `LineChart`, 14-day In/Out totals bucketed client-side from `GET /stock/ledger?from=...&pageSize=200`. **Falls back to a hardcoded sample series (`SAMPLE_TREND_IN`/`OUT`) when the real ledger has no activity in that window**, so the chart isn't a flat zero line on a fresh/demo database — real data always wins when present. (An equivalent fallback was added to the Recent Stock Movements table during this pass and then explicitly removed at the user's request — that table shows only real data + an empty state.) +- **Recent Stock Movements** — table of the latest 5 ledger entries; shows `#itemId` rather than the item SKU, deliberately, to avoid a hard dependency on `GET /items` (see the bug below). +- **`StatCard` (`components/ui/stat-card.tsx`) fixed to use theme tokens** — it previously hardcoded `bg-white`/`text-slate-900`/`text-indigo-600`/`ring-black/5`, which was invisible-on-dark once the Dark/Vibrant themes existed. Now `bg-card`/`text-foreground`/`text-primary`/`ring-foreground/10`. +- **Bug found — `GET /items` 500s on every call** (`column i.SalePrice does not exist`) — this is why the dashboard and the movements table avoid `itemsApi` entirely. Root cause + fix status tracked in `Backend/PROGRESS.md`'s 2026-07-28 entry; **not yet fixed** as of this entry. +- **Chart color gotcha (found and fixed twice this session):** passing a CSS custom property or `color-mix()` string (e.g. `"var(--color-primary)"`) as a Chart.js `borderColor`/`backgroundColor` silently renders **black**, because a `` 2D context cannot resolve CSS variables — it's not a themeable value, it's an invalid string that falls back to the default. Every chart on this page uses real static hex colors instead (`#6366f1`, `#22c55e`, `#ef4444`). +- **Verified:** `tsc --noEmit` clean throughout. Runtime verification blocked for most of this session by the dev backend running under an active Visual Studio debug session — killing the process externally just triggers VS's own auto-relaunch of the **stale** build (observed repeatedly; confirmed via process start-time checks), so `dotnet build`/`dotnet ef` against the live `bin/`/`obj/` failed on file locks. Worked around by building to an isolated `-o` output directory to verify compilation without touching the locked live build; **actually deploying a rebuild still requires stopping debugging inside Visual Studio itself** (not just closing a console window) — this blocked full end-to-end verification of `/dashboard/stats` until the user did that. + ### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create - **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`). - **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged. diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 45b9480..7379d48 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -1,325 +1,284 @@ "use client" -import * as React from "react" -import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + AlertTriangle, + BadgeDollarSign, + Boxes, + Clock, + ClipboardList, + ListChecks, + PackageCheck, + ScrollText, + Send, +} from "lucide-react" +import { dashboardApi } from "@/lib/api/dashboard" +import { stockApi } from "@/lib/api/stock" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { RecentOrdersTable } from "@/components/dashboard/recent-orders-table" -import { Button } from "@/components/ui/button" -import { DatePicker, DateRangePicker } from "@/components/ui/date-picker" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogContent, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { - Breadcrumb, - BreadcrumbEllipsis, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" +import { DashboardStats } from "@/types/dashboard" +import { LedgerEntry } from "@/types/stock" +import { Warehouse } from "@/types/master-data" import { StatCard } from "@/components/ui/stat-card" -import { toast } from "@/components/ui/toast" -import LineChart from "@/components/ui/line-chart" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import BarChart from "@/components/ui/bar-chart" -import PieChart from "@/components/ui/pie-chart" +import LineChart from "@/components/ui/line-chart" -const indigoButton = - "bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40" +const TREND_DAYS = 14 + +function isoDate(d: Date) { + return d.toISOString().slice(0, 10) +} + +// Shown only when the last TREND_DAYS days have no real ledger activity, so the +// chart isn't a flat zero line before there's any real movement to plot. +const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72] +const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58] export default function DashboardPage() { - const [date, setDate] = React.useState() - const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>() + const [stats, setStats] = useState(null) + const [movements, setMovements] = useState(null) + const [trend, setTrend] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + const from = new Date() + from.setDate(from.getDate() - (TREND_DAYS - 1)) + + Promise.all([ + dashboardApi.stats(), + stockApi.ledger({ page: 1, pageSize: 5 }), + stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }), + warehousesApi.list(), + ]) + .then(([statsRes, ledger, trendRes, warehousesRes]) => { + if (cancelled) return + setStats(statsRes) + setMovements(ledger.items) + setTrend(trendRes.items) + setWarehouses(warehousesRes.items) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + + return () => { + cancelled = true + } + }, []) + + const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) + + // Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date. + const movementTrend = useMemo(() => { + const days: string[] = [] + const cursor = new Date() + cursor.setDate(cursor.getDate() - (TREND_DAYS - 1)) + for (let i = 0; i < TREND_DAYS; i++) { + days.push(isoDate(cursor)) + cursor.setDate(cursor.getDate() + 1) + } + + const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" })) + + if (!trend || trend.length === 0) { + return { labels, inData: SAMPLE_TREND_IN, outData: SAMPLE_TREND_OUT } + } + + const inByDay = new Map(days.map((d) => [d, 0])) + const outByDay = new Map(days.map((d) => [d, 0])) + for (const entry of trend) { + const day = entry.createdAt.slice(0, 10) + const bucket = entry.direction === "In" ? inByDay : outByDay + if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase) + } + + return { + labels, + inData: days.map((d) => inByDay.get(d) ?? 0), + outData: days.map((d) => outByDay.get(d) ?? 0), + } + }, [trend]) + + const loaded = stats && movements && trend && warehouses + return (
-
-
-

Breadcrumb

-
- {/* Basic */} - - - - Home - - - - Products - - - - Product Detail - - - +
+

Dashboard

+

Overview of stock, receiving and procurement.

+
- {/* With ellipsis */} - - - - Home - - - - - - - - Products - - - - Edit - - - -
+ {error && ( +
+ {error} +
+ )} + +
+ {loaded ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) : ( + !error && Array.from({ length: 7 }).map((_, i) => ) + )} +
+ +
+
+

+ + Stock Valuation by Warehouse +

+ + View details +
-
-

Variants

-
- - - - - - - - - -
-
- -
-

Sizes

-
- - - - - - - - - -
-
- -
-

Date Picker

-
-
- Single date - 0 ? ( +
+ warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` + )} + datasets={[ + { + label: "Stock Value (LKR)", + data: stats.stockValuationByWarehouse.map((w) => w.total), + backgroundColor: "#6366f1", + }, + ]} />
-
- Date range - -
-
- {(date || range?.from) && ( -

- {date && <>Selected: {date.toLocaleDateString()}} - {range?.from && ( - <> - {date && " · "} - Range: {range.from.toLocaleDateString()} - {range.to && <> – {range.to.toLocaleDateString()}} - - )} -

- )} -
- -
-

Modal

- - Open Modal} /> - - -
- -
- Order confirmed - - Your order has been placed successfully and is now being processed. - -
- -
- -
-
-
-
- -
-

Toast

-
- - - - - - -
-
+ ) : ( +

No stock on hand yet.

+ ) + ) : ( + !error && + )}
-
-
-

Alert Dialogs

-
- - Info} /> - toast.info("Reloading...", "Applying the latest update.")} - /> - - - - Success} /> - toast.success("Done!", "Redirecting to dashboard.")} - /> - - - - Warning} /> - toast.warning("Changes discarded")} - /> - - - - Delete} /> - toast.error("Deleted", "The record has been permanently removed.")} - /> - -
+
+
+ +

+ Stock Movement Trend (last {TREND_DAYS} days) +

-
-
- - - - -
- - - -
-
-

Sales (Line)

-
+ {loaded ? ( +
+ ) : ( + !error && + )} +
+ +
+
+

+ + Recent Stock Movements +

+ + View all +
-
-

Revenue (Bar)

-
- -
-
- -
-

Product Mix (Pie)

-
- -
-
+ {loaded ? ( + movements.length > 0 ? ( + + + + Item + Warehouse + Direction + Qty + Source + Date + + + + {movements.map((entry) => ( + + #{entry.itemId} + + {warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} + + + + {entry.direction} + + + {entry.qtyBase} + + {entry.sourceDocType} #{entry.sourceDocId} + + + {new Date(entry.createdAt).toLocaleDateString()} + + + ))} + +
+ ) : ( +

No stock movements yet.

+ ) + ) : ( + !error && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + )}
) diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index f977cef..1d4e739 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" +import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" @@ -156,14 +156,9 @@ export default function BrandsPage() { return (
-
- - - -
-

Brands

-

Manage product brands.

-
+
+

Brands

+

Manage product brands.

@@ -239,21 +234,21 @@ export default function BrandsPage() { {!error && brands !== null && brands.length > 0 && ( <> - - - + + + toggleSort("brandId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -364,7 +359,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 566f376..f13f273 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" +import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" @@ -155,14 +155,9 @@ export default function CategoriesPage() { return (
-
- - - -
-

Categories

-

Item category master (FR-MD-04).

-
+
+

Categories

+

Item category master (FR-MD-04).

@@ -238,21 +233,21 @@ export default function CategoriesPage() { {!error && categories !== null && categories.length > 0 && ( <>
- - - + + + toggleSort("categoryId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -371,7 +366,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx index 852c14f..4b598ed 100644 --- a/Frontend/erp-system/components/ui/select.tsx +++ b/Frontend/erp-system/components/ui/select.tsx @@ -150,7 +150,7 @@ function SelectItem({ } > - + ) diff --git a/Frontend/erp-system/components/ui/stat-card.tsx b/Frontend/erp-system/components/ui/stat-card.tsx index e4371a9..4aebb88 100644 --- a/Frontend/erp-system/components/ui/stat-card.tsx +++ b/Frontend/erp-system/components/ui/stat-card.tsx @@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) { className="h-4.5 w-12 shrink-0 overflow-visible" aria-hidden="true" > - - + + ) } @@ -71,21 +71,21 @@ export function StatCard({ return (

{label}

{Icon && ( -
- +
+
)}
-

{formatValue(value)}

+

{formatValue(value)}

{trend && trend.length > 1 && }
@@ -94,7 +94,7 @@ export function StatCard({ {isPositive ? "+" : "-"} diff --git a/Frontend/erp-system/components/ui/table.tsx b/Frontend/erp-system/components/ui/table.tsx index c7eb05c..6e3126c 100644 --- a/Frontend/erp-system/components/ui/table.tsx +++ b/Frontend/erp-system/components/ui/table.tsx @@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { return (
) @@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
{ + return apiRequest("/dashboard/stats") + }, +} diff --git a/Frontend/erp-system/types/dashboard.ts b/Frontend/erp-system/types/dashboard.ts new file mode 100644 index 0000000..7e9857a --- /dev/null +++ b/Frontend/erp-system/types/dashboard.ts @@ -0,0 +1,20 @@ +// Dashboard overview types. Mirrors Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs — +// a single computed-on-read aggregate object, not a paged list. + +export interface WarehouseValuation { + warehouseId: number + total: number +} + +export interface DashboardStats { + lowStockAlerts: number + onHandTotal: number + onHandWarehouses: number + stockValuationTotal: number + stockValuationByWarehouse: WarehouseValuation[] + pendingApprovalPurchaseOrders: number + pendingGrns: number + openRequisitions: number + pendingCounts: number + openRfqs: number +} diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index fbcf01b..5c04b97 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -713,6 +713,28 @@ Items at/below ROP (FR-STK-10); computed on read, no stored entity. ``` `POST /stock/reorder-alerts/{itemId}/requisition?warehouseId=1` → creates a draft requisition for the suggested qty. +### 5.8 Dashboard overview (added 2026-07-28) +#### `GET /dashboard/stats` +Cross-domain aggregate counts for the dashboard UI — a single computed-on-read object, not a stored entity or a `PagedResponse` list (same posture as reorder alerts, §5.7). `lowStockAlerts` reuses `IReorderService.GetAlertsAsync` rather than re-deriving the FIFO-available-vs-reorder-point comparison; `onHandTotal`/`onHandWarehouses`/`stockValuationTotal`/`stockValuationByWarehouse` are SQL-side `SUM`/`GROUP BY` over `StockLayer` (cheap — unlike reorder alerts, they need no per-item live lookup). +```json +{ + "lowStockAlerts": 6, + "onHandTotal": 15420, + "onHandWarehouses": 3, + "stockValuationTotal": 4820500.00, + "stockValuationByWarehouse": [ + { "warehouseId": 1, "total": 3120000.00 }, + { "warehouseId": 2, "total": 1700500.00 } + ], + "pendingApprovalPurchaseOrders": 2, + "pendingGrns": 4, + "openRequisitions": 5, + "pendingCounts": 1, + "openRfqs": 3 +} +``` +`pendingApprovalPurchaseOrders` = PO status `PendingApproval`; `pendingGrns` = GRN status `Draft`; `openRequisitions` = Requisition status `Submitted`; `pendingCounts` = StockCount status `Counted` (counted but not yet posted); `openRfqs` = RFQ status `Open`. **Not covered here:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it yet) and recent stock movements (just call `GET /stock/ledger` directly with a small `pageSize` — no aggregation needed). + --- ## 6. Reference Data From 4561ef7ba81e83a2434756a087fd8a3b8c642354 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Tue, 28 Jul 2026 22:55:56 +0530 Subject: [PATCH 2/2] feat: implement frontend-only mock for production lines and runs - Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow. - Create LineHeaderNode and LineStageNode components for rendering production line nodes. - Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers. - Implement StageProgressStrip for visualizing stage progress in runs. - Create mock data for production runs and templates to simulate backend functionality. - Define types for production templates and runs to structure mock data. - Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture. --- .../dashboard/production/runs/[id]/page.tsx | 248 ++++++++++ .../app/dashboard/production/runs/page.tsx | 317 +++++++++++++ .../templates/[id]/AnnotationNodes.tsx | 137 ++++++ .../templates/[id]/StageEditorPanel.tsx | 384 ++++++++++++++++ .../production/templates/[id]/StageNode.tsx | 66 +++ .../production/templates/[id]/page.tsx | 429 ++++++++++++++++++ .../production/templates/[id]/types.ts | 73 +++ .../dashboard/production/templates/page.tsx | 254 +++++++++++ .../components/Layouts/AppSidebar.tsx | 17 +- .../components/Layouts/Breadcrumbs.tsx | 3 + .../production/ProductionLineNodes.tsx | 57 +++ .../components/production/RunStageNode.tsx | 75 +++ .../production/stage-progress-strip.tsx | 85 ++++ .../erp-system/lib/production-mock-runs.ts | 105 +++++ .../lib/production-mock-templates.ts | 16 + .../lib/production-status-colors.ts | 27 ++ Frontend/erp-system/package.json | 1 + Frontend/erp-system/types/production.ts | 42 ++ docs/21-FRONTEND-PHASE2.md | 122 +++++ 19 files changed, 2457 insertions(+), 1 deletion(-) create mode 100644 Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/runs/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts create mode 100644 Frontend/erp-system/app/dashboard/production/templates/page.tsx create mode 100644 Frontend/erp-system/components/production/ProductionLineNodes.tsx create mode 100644 Frontend/erp-system/components/production/RunStageNode.tsx create mode 100644 Frontend/erp-system/components/production/stage-progress-strip.tsx create mode 100644 Frontend/erp-system/lib/production-mock-runs.ts create mode 100644 Frontend/erp-system/lib/production-mock-templates.ts create mode 100644 Frontend/erp-system/lib/production-status-colors.ts create mode 100644 Frontend/erp-system/types/production.ts create mode 100644 docs/21-FRONTEND-PHASE2.md diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx new file mode 100644 index 0000000..3a72224 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx @@ -0,0 +1,248 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { ReactFlow, Background, Controls, type Edge, type Node } from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { ArrowLeft, ChevronRight, RotateCcw } from "lucide-react" + +import { cn } from "@/lib/utils" +import { RunStatus, StageSummary } from "@/types/production" +import { INITIAL_RUNS, buildStagePlan, type RunStagePlanItem } from "@/lib/production-mock-runs" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors" +import { + RunHeaderNodeComponent, + RunStageNodeComponent, + type RunHeaderData, + type RunStageData, +} from "@/components/production/RunStageNode" +import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +function todayIso() { + return new Date().toISOString().slice(0, 10) +} + +function runStatusBadgeClass(status: RunStatus) { + if (status === "Completed") return "bg-success/10 text-success" + if (status === "Cancelled") return "bg-destructive/10 text-destructive" + return "bg-info/10 text-info" +} + +const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent } + +const STAGE_START_X = 260 +const STAGE_GAP_X = 220 + +export default function ProductionRunDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const { resolvedTheme } = useTheme() + const runId = Number(params.id) + const run = useMemo(() => INITIAL_RUNS.find((r) => r.runId === runId) ?? null, [runId]) + + // Same hydration-mismatch guard as the other canvas pages (templates/page.tsx, + // templates/[id]/page.tsx): colorMode depends on resolvedTheme, unknown on first paint. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const [status, setStatus] = useState(run?.status ?? "InProgress") + const [completedAt, setCompletedAt] = useState(run?.completedAt ?? null) + const [stages, setStages] = useState(() => + run ? buildStagePlan(run.templateName, run.stageSummary) : [] + ) + + const activeIndex = stages.findIndex((s) => s.state !== "Approved") + + function advanceStage(index: number) { + setStages((prev) => { + const curIdx = STAGE_STATUS_ORDER.indexOf(prev[index].state) + if (curIdx >= STAGE_STATUS_ORDER.length - 1) return prev + const next = [...prev] + next[index] = { ...next[index], state: STAGE_STATUS_ORDER[curIdx + 1] } + return next + }) + } + + // No real backend to push this to — advancing every stage to Approved locally completes + // the run on this page only (the Runs board keeps its own separate seed state). + useEffect(() => { + if (stages.length > 0 && stages.every((s) => s.state === "Approved") && status === "InProgress") { + setStatus("Completed") + setCompletedAt(todayIso()) + toast.success("Run completed", run ? `${run.docNo} — all stages approved` : undefined) + } + }, [stages, status, run]) + + const progressPercent = stages.length > 0 ? Math.round((stages.filter((s) => s.state === "Approved").length / stages.length) * 100) : 0 + const activeStage = activeIndex >= 0 ? stages[activeIndex] : null + const canGiveProgress = status === "InProgress" && activeStage !== null + + function giveProgress() { + if (activeIndex < 0) return + const stage = stages[activeIndex] + const nextState = STAGE_STATUS_ORDER[STAGE_STATUS_ORDER.indexOf(stage.state) + 1] + advanceStage(activeIndex) + toast.success(`${stage.name} → ${STAGE_STATUS_LABEL[nextState]}`, run?.docNo) + } + + const stageSummary: StageSummary = useMemo( + () => ({ + waiting: stages.filter((s) => s.state === "Waiting").length, + ready: stages.filter((s) => s.state === "Ready").length, + inProgress: stages.filter((s) => s.state === "InProgress").length, + done: stages.filter((s) => s.state === "Done").length, + approved: stages.filter((s) => s.state === "Approved").length, + }), + [stages] + ) + + const { nodes, edges } = useMemo(() => { + if (!run) return { nodes: [] as Node[], edges: [] as Edge[] } + + const nodes: Node[] = [ + { + id: "header", + type: "runHeader", + position: { x: 0, y: 0 }, + data: { docNo: run.docNo, templateName: run.templateName, status } satisfies RunHeaderData, + draggable: false, + }, + ] + const edges: Edge[] = [] + + stages.forEach((s, i) => { + const id = `stage-${i}` + const isActive = i === activeIndex && status === "InProgress" + nodes.push({ + id, + type: "runStage", + position: { x: STAGE_START_X + i * STAGE_GAP_X, y: -8 }, + data: { + name: s.name, + state: s.state, + isActive, + onAdvance: isActive ? () => advanceStage(i) : undefined, + } satisfies RunStageData, + draggable: false, + }) + edges.push({ + id: `e-${id}`, + source: i === 0 ? "header" : `stage-${i - 1}`, + target: id, + animated: s.state === "InProgress", + }) + }) + + return { nodes, edges } + }, [run, stages, activeIndex, status]) + + if (!run) { + return ( +
+

Run not found.

+ +
+ ) + } + + return ( +
+ + +
+
+
+
+ {run.docNo} + + {status === "InProgress" ? "In Progress" : status} + + {run.reworkCount > 0 && ( + + + Rework #{run.reworkCount} + + )} +
+

{run.templateName} · {run.warehouseName}

+
+
+ + {run.targetQty.toLocaleString()} {run.uom} · {run.finishedItemName} + + + Created {new Date(run.createdAt).toLocaleDateString()} + {completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}} + +
+
+ + + +
+
+
+ + {progressPercent}% complete + {activeStage && · Current: {activeStage.name}} + +
+
+
+
+
+ +
+
+ +
+ +
+ +
+ {mounted ? ( + + + + + ) : ( + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/runs/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/page.tsx new file mode 100644 index 0000000..3d85b90 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/runs/page.tsx @@ -0,0 +1,317 @@ +"use client" + +import { useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { ChevronRight, PlayCircle, RotateCcw, Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ProductionRun, RunStatus } from "@/types/production" +import { INITIAL_RUNS, STARTABLE_TEMPLATES, buildStagePlan } from "@/lib/production-mock-runs" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL } from "@/lib/production-status-colors" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "@/components/ui/toast" +import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" + +const TEMPLATE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.templateName))) +const WAREHOUSE_NAMES = Array.from(new Set(INITIAL_RUNS.map((r) => r.warehouseName))) + +function todayIso() { + return new Date().toISOString().slice(0, 10) +} + +type StatusFilter = RunStatus | "All" +type NameFilter = string | "All" + +function runStatusBadgeClass(status: RunStatus) { + if (status === "Completed") return "bg-success/10 text-success" + if (status === "Cancelled") return "bg-destructive/10 text-destructive" + return "bg-info/10 text-info" +} + +export default function ProductionRunsPage() { + const router = useRouter() + const [runs, setRuns] = useState(INITIAL_RUNS) + const [searchInput, setSearchInput] = useState("") + const [status, setStatus] = useState("All") + const [template, setTemplate] = useState("All") + const [warehouse, setWarehouse] = useState("All") + + const [open, setOpen] = useState(false) + const [startTemplateId, setStartTemplateId] = useState(null) + const [targetQty, setTargetQty] = useState("") + const [startWarehouse, setStartWarehouse] = useState(null) + const [outputBin, setOutputBin] = useState("") + const [formError, setFormError] = useState("") + const [submitting, setSubmitting] = useState(false) + + const startTemplate = STARTABLE_TEMPLATES.find((t) => t.templateId === startTemplateId) ?? null + const targetQtyNum = Number(targetQty) + const scaleFactor = startTemplate && targetQtyNum > 0 ? targetQtyNum / startTemplate.nominalBatchQty : null + + function openStartDialog() { + setStartTemplateId(null) + setTargetQty("") + setStartWarehouse(null) + setOutputBin("") + setFormError("") + setOpen(true) + } + + function handleStartRun() { + if (!startTemplate) { + setFormError("Pick a template.") + return + } + if (!(targetQtyNum > 0)) { + setFormError("Target quantity must be greater than 0.") + return + } + if (!startWarehouse) { + setFormError("Pick a warehouse.") + return + } + setSubmitting(true) + const nextId = runs.reduce((max, r) => Math.max(max, r.runId), 0) + 1 + const created: ProductionRun = { + runId: nextId, + docNo: `PRD-2026-${String(nextId).padStart(5, "0")}`, + templateName: startTemplate.name, + targetQty: targetQtyNum, + finishedItemName: startTemplate.finishedItemName, + uom: startTemplate.uom, + warehouseName: startWarehouse, + status: "InProgress", + reworkCount: 0, + createdAt: todayIso(), + completedAt: null, + // Freshly started: nothing done yet, first stage ready, the rest waiting. + stageSummary: { waiting: Math.max(startTemplate.stageCount - 1, 0), ready: 1, inProgress: 0, done: 0, approved: 0 }, + } + setRuns((prev) => [...prev, created]) + toast.success("Run started", `${created.docNo} — ${created.templateName}${outputBin ? ` → bin ${outputBin}` : ""}`) + setSubmitting(false) + setOpen(false) + } + + const filtered = useMemo(() => { + const q = searchInput.trim().toLowerCase() + return runs + .filter((r) => (status === "All" ? true : r.status === status)) + .filter((r) => (template === "All" ? true : r.templateName === template)) + .filter((r) => (warehouse === "All" ? true : r.warehouseName === warehouse)) + .filter((r) => (q ? r.docNo.toLowerCase().includes(q) : true)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + }, [runs, searchInput, status, template, warehouse]) + + const hasFilters = searchInput.trim().length > 0 || status !== "All" || template !== "All" || warehouse !== "All" + + return ( +
+
+
+

Production Runs

+

All manufacturing runs with per-stage progress at a glance.

+
+ + Start Run} /> + + + Start a run + Fine-tune per-stage quantities afterward on the run itself. + + + + Template + value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}> + + + + + {STARTABLE_TEMPLATES.map((t) => ( + {t.name} + ))} + + + + + 0)}> + + Target quantity{startTemplate && ({startTemplate.uom}, {startTemplate.finishedItemName})} + + setTargetQty(e.target.value)} + placeholder="e.g. 200" + /> + + + + Warehouse + value={startWarehouse} onValueChange={setStartWarehouse}> + + + + + {WAREHOUSE_NAMES.map((n) => ( + {n} + ))} + + + + + + Output bin (optional) + setOutputBin(e.target.value)} placeholder="e.g. BIN-04" /> + + + {scaleFactor !== null && ( +
+ Scale factor {scaleFactor.toFixed(2)}× — target {targetQtyNum.toLocaleString()} {startTemplate!.uom} vs + {" "}a nominal batch of {startTemplate!.nominalBatchQty.toLocaleString()} {startTemplate!.uom}. Every stage's inputs/outputs scale by this factor; the authoritative + figures come back once the run is created. +
+ )} + + +
+
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search doc no…" + className="h-14 w-full pl-11 text-base" + aria-label="Search runs" + /> +
+ value={template} onValueChange={(v) => setTemplate(v ?? "All")}> + + + + + All templates + {TEMPLATE_NAMES.map((n) => ( + {n} + ))} + + + value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}> + + + + + All warehouses + {WAREHOUSE_NAMES.map((n) => ( + {n} + ))} + + + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + In Progress + Completed + Cancelled + + +
+ +
+ +
+ + {filtered.length === 0 ? ( +
+ +

+ {hasFilters ? "No runs match your search/filter." : "No runs yet."} +

+
+ ) : ( +
+ {filtered.map((r) => ( + + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx new file mode 100644 index 0000000..f212906 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx @@ -0,0 +1,137 @@ +import { memo, useRef } from "react" +import { NodeResizer, type NodeProps } from "@xyflow/react" +import { RotateCw, X } from "lucide-react" + +import { AnnotationData } from "./types" + +type AnnotationNodeData = AnnotationData & { + onLabelChange?: (label: string) => void + onRotationChange?: (rotation: number) => void + onDelete?: () => void +} + +// `className` supplies its own position utility (e.g. "absolute -top-2.5 -right-2.5" or +// "static") — not baked in here, so callers that already sit inside a positioned flex +// row (LineNode's rotate/delete pair) aren't fighting a hardcoded `absolute`. +function DeleteHandle({ onDelete, className }: { onDelete?: () => void; className?: string }) { + return ( + + ) +} + +/** + * Free-floating group/label box. Purely visual — no Handles, so it can never be an edge + * endpoint, and it's excluded from every graph check (see StageNode for the real stage card). + * Rendered behind stage nodes: the page prepends new boxes to the nodes array, and React + * Flow paints later array entries on top. + */ +function BoxNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) { + return ( +
+ + {selected && data.onDelete && } + data.onLabelChange?.(e.target.value)} + placeholder="Group label…" + className="nodrag m-2 w-[calc(100%-1rem)] rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:bg-card" + /> +
+ ) +} + +/** + * Thin resizable divider bar, optionally labeled (e.g. "Phase 1"), and rotatable by dragging + * the small handle that appears above it once selected. The resize outline/handles and the + * rotate handle itself rotate together with the bar — they all live in one rotated wrapper — + * so the selection box always matches the bar's visual angle. Note: NodeResizer computes its + * drag deltas in unrotated screen space, so resizing while significantly rotated will feel a + * little off; acceptable here since this is a lightweight annotation, not precision CAD. + * `wrapperRef` (the outer, unrotated element) is what the rotate math measures from, so the + * center point stays stable regardless of the current angle. + */ +function LineNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) { + const wrapperRef = useRef(null) + const rotation = data.rotation ?? 0 + + return ( +
+
+ + + {selected && (data.onRotationChange || data.onDelete) && ( +
+ {data.onRotationChange && ( + + )} + {data.onDelete && } +
+ )} + +
+
+
+
+ + data.onLabelChange?.(e.target.value)} + placeholder="Label (optional)" + className="nodrag absolute -bottom-6 left-1/2 w-24 -translate-x-1/2 rounded-md bg-transparent px-1 text-center text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50 focus:bg-card" + /> +
+ ) +} + +export default memo(BoxNode) +export const LineNodeComponent = memo(LineNode) diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx new file mode 100644 index 0000000..60dc214 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -0,0 +1,384 @@ +"use client" + +import { Plus, Trash2, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { FieldDef, FieldType, FormulaInput, FormulaOutput, InputSource, MockItem, StageNodeData } from "./types" + +import { Button } from "@/components/ui/button" +import { Field, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +function slugify(label: string) { + return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") +} + +function newId() { + return Math.random().toString(36).slice(2, 10) +} + +const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"] +const FIELD_TYPES: FieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"] + +export interface UpstreamOutputOption { + stageId: string + stageName: string + outputId: string + outputName: string +} + +export function StageEditorPanel({ + nodeId, + data, + isTerminal, + upstreamOptions, + items, + readOnly, + onChange, + onDelete, + onClose, +}: { + nodeId: string + data: StageNodeData + isTerminal: boolean + upstreamOptions: UpstreamOutputOption[] + items: MockItem[] + readOnly: boolean + onChange: (patch: Partial) => void + onDelete: () => void + onClose: () => void +}) { + function updateInput(inputId: string, patch: Partial) { + onChange({ inputs: data.inputs.map((i) => (i.inputId === inputId ? { ...i, ...patch } : i)) }) + } + function addInput() { + onChange({ inputs: [...data.inputs, { inputId: newId(), source: "Stock" as InputSource, qty: 1 }] }) + } + function removeInput(inputId: string) { + onChange({ inputs: data.inputs.filter((i) => i.inputId !== inputId) }) + } + + function updateOutput(outputId: string, patch: Partial) { + onChange({ outputs: data.outputs.map((o) => (o.outputId === outputId ? { ...o, ...patch } : o)) }) + } + function addOutput() { + onChange({ outputs: [...data.outputs, { outputId: newId(), name: "", uom: "PCS", qty: 1 }] }) + } + function removeOutput(outputId: string) { + onChange({ outputs: data.outputs.filter((o) => o.outputId !== outputId) }) + } + + function updateField(fieldId: string, patch: Partial) { + onChange({ + fieldDefs: data.fieldDefs.map((f) => { + if (f.fieldId !== fieldId) return f + const next = { ...f, ...patch } + if (patch.label !== undefined) next.key = slugify(patch.label) || f.key + return next + }), + }) + } + function addField() { + onChange({ + fieldDefs: [...data.fieldDefs, { fieldId: newId(), key: "", label: "", type: "Text", options: [], required: false }], + }) + } + function removeField(fieldId: string) { + onChange({ fieldDefs: data.fieldDefs.filter((f) => f.fieldId !== fieldId) }) + } + + return ( +
+
+

Stage editor

+ +
+ +
+ + Name + onChange({ name: e.target.value })} placeholder="e.g. Welding" /> + + + + Role label + onChange({ roleLabel: e.target.value })} + placeholder="e.g. QA" + list="role-suggestions" + /> + + {ROLE_SUGGESTIONS.map((r) => ( + + + + + Estimated minutes + onChange({ estimatedMinutes: Number(e.target.value) || 0 })} + /> + + + {/* Inputs */} +
+
+

Inputs

+ {!readOnly && ( + + )} +
+
+ {data.inputs.length === 0 &&

No inputs yet.

} + {data.inputs.map((input) => ( +
+
+ + value={input.source} + onValueChange={(v) => v && updateInput(input.inputId, { source: v })} + > + + + + + Stock + Upstream + + + {!readOnly && ( + + )} +
+ + {input.source === "Stock" ? ( +
+ + value={input.itemId ?? null} + onValueChange={(v) => { + const item = items.find((i) => i.itemId === v) + updateInput(input.inputId, { itemId: v ?? undefined, itemName: item?.name, uom: item?.uom }) + }} + > + + + + + {items.map((i) => ( + {i.name} + ))} + + +
+ updateInput(input.inputId, { qty: Number(e.target.value) || 0 })} + className="h-8 text-sm" + placeholder="Qty" + /> + {input.uom ?? "—"} +
+ updateInput(input.inputId, { batch: e.target.value })} + className="h-8 text-sm" + placeholder="Batch (optional)" + /> +
+ ) : ( + + value={input.upstreamOutputId ?? null} + onValueChange={(v) => { + const opt = upstreamOptions.find((o) => o.outputId === v) + updateInput(input.inputId, { upstreamOutputId: v ?? undefined, upstreamStageId: opt?.stageId }) + }} + > + + + + + {upstreamOptions.map((o) => ( + + {o.stageName} — {o.outputName} + + ))} + + + )} +
+ ))} +
+
+ + {/* Outputs */} +
+
+

+ Outputs{isTerminal && (terminal — finished good)} +

+ {!readOnly && ( + + )} +
+
+ {data.outputs.length === 0 &&

No outputs yet.

} + {data.outputs.map((output) => ( +
+
+ {isTerminal ? ( + + value={output.itemId ?? null} + onValueChange={(v) => { + const item = items.find((i) => i.itemId === v) + updateOutput(output.outputId, { itemId: v ?? undefined, name: item?.name ?? "", uom: item?.uom ?? output.uom }) + }} + > + + + + + {items.map((i) => ( + {i.name} + ))} + + + ) : ( + updateOutput(output.outputId, { name: e.target.value })} + placeholder="Output name" + className="h-8 flex-1 text-sm" + /> + )} + {!readOnly && ( + + )} +
+
+ updateOutput(output.outputId, { qty: Number(e.target.value) || 0 })} + className="h-8 text-sm" + placeholder="Qty" + /> + updateOutput(output.outputId, { uom: e.target.value })} + className="h-8 w-20 text-sm" + placeholder="UOM" + /> +
+
+ ))} +
+
+ + {/* Custom fields */} +
+
+

Custom fields

+ {!readOnly && ( + + )} +
+
+ {data.fieldDefs.length === 0 &&

No custom fields.

} + {data.fieldDefs.map((field) => ( +
+
+ updateField(field.fieldId, { label: e.target.value })} + placeholder="Label" + className="h-8 flex-1 text-sm" + /> + {!readOnly && ( + + )} +
+ {field.key &&

key: {field.key}

} +
+ + value={field.type} + onValueChange={(v) => v && updateField(field.fieldId, { type: v })} + > + + + + + {FIELD_TYPES.map((t) => ( + {t} + ))} + + +
+ updateField(field.fieldId, { required: checked })} + /> + Required +
+
+ {field.type === "Select" && ( + updateField(field.fieldId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} + placeholder="Options, comma separated" + className="mt-2 h-8 text-sm" + /> + )} +
+ ))} +
+
+ + {!readOnly && ( + + )} +
+
+ ) +} + +export { newId } diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx new file mode 100644 index 0000000..459defc --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx @@ -0,0 +1,66 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" +import { ArrowRight, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { StageNodeData } from "./types" + +/** + * Stage card (docs/21-FRONTEND-PHASE2.md §2): name, role label chip, estimated minutes, + * input count → output count. Selecting it opens the stage editor panel (handled by the + * parent page via onNodeClick, not here) — the same delete affordance also lives there + * ("Delete stage" button); this inline × is a faster path once a stage is already selected. + */ +function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) { + const disconnected = data.disconnected + + return ( +
+ + + {selected && data.onDelete && ( + + )} + +
+

{data.name || "Untitled stage"}

+ {data.roleLabel && ( + + {data.roleLabel} + + )} +
+ +

{data.estimatedMinutes} min

+ +
+ {data.inputs.length} in + + {data.outputs.length} out +
+ + {disconnected &&

Disconnected

} + + +
+ ) +} + +export default memo(StageNode) diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx new file mode 100644 index 0000000..80aee4f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -0,0 +1,429 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useParams, useSearchParams } from "next/navigation" +import Link from "next/link" +import { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Background, + Controls, + MiniMap, + ReactFlow, + type Connection, + type Edge, + type Node, + type NodeChange, + type EdgeChange, + type NodeMouseHandler, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react" + +import { cn } from "@/lib/utils" +import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates" +import { Button, buttonVariants } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" +import StageNode from "./StageNode" +import AnnotationBoxNode, { LineNodeComponent } from "./AnnotationNodes" +import { StageEditorPanel, type UpstreamOutputOption, newId } from "./StageEditorPanel" +import { AnnotationData, MockItem, StageNodeData } from "./types" + +const MOCK_ITEMS: MockItem[] = [ + { itemId: 101, name: "Steel Sheet 2mm", uom: "KG" }, + { itemId: 102, name: "Screws M4", uom: "PCS" }, + { itemId: 103, name: "Steel Bracket A", uom: "PCS" }, + { itemId: 104, name: "PCB Board X", uom: "PCS" }, + { itemId: 105, name: "Solder Wire", uom: "M" }, + { itemId: 106, name: "Electronic Component Kit", uom: "SET" }, + { itemId: 107, name: "Wood Plank", uom: "PCS" }, + { itemId: 108, name: "Pallet Standard", uom: "PCS" }, + { itemId: 109, name: "Cable Wire", uom: "M" }, + { itemId: 110, name: "Harness Kit B", uom: "SET" }, +] + +const nodeTypes = { stage: StageNode, box: AnnotationBoxNode, line: LineNodeComponent } + +function buildInitialGraph(stageNames: string[]): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = stageNames.map((name, i) => ({ + id: `n${i + 1}`, + type: "stage", + position: { x: i * 280 + 40, y: 120 }, + data: { name, roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData, + })) + const edges: Edge[] = stageNames.slice(1).map((_, i) => ({ + id: `e${i + 1}`, + source: `n${i + 1}`, + target: `n${i + 2}`, + })) + return { nodes, edges } +} + +/** Kahn's algorithm — returns the ids left over (unprocessable) once no more in-degree-0 nodes exist, i.e. the cycle. */ +function detectCycle(nodes: Node[], edges: Edge[]): boolean { + const inDegree = new Map(nodes.map((n) => [n.id, 0])) + for (const e of edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1) + const queue = nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id) + let visited = 0 + while (queue.length > 0) { + const id = queue.shift()! + visited++ + for (const e of edges.filter((e) => e.source === id)) { + const next = (inDegree.get(e.target) ?? 0) - 1 + inDegree.set(e.target, next) + if (next === 0) queue.push(e.target) + } + } + return visited !== nodes.length +} + +export default function TemplateBuilderPage() { + const params = useParams<{ id: string }>() + const searchParams = useSearchParams() + const { resolvedTheme } = useTheme() + + // A template just created on the list page (see app/dashboard/production/templates/page.tsx + // handleCreate) — no backend exists to look it up by id, so its name/blank graph arrive via + // the URL instead. Every other id falls back to the 5 seeded mock templates. + const isFresh = searchParams.get("fresh") === "1" + const freshName = searchParams.get("name") + const template = isFresh && freshName + ? { name: freshName, activeRunCount: 0, stages: [] as string[] } + : (MOCK_TEMPLATE_INFO[params.id] ?? { name: `Template #${params.id}`, activeRunCount: 0, stages: ["Stage 1"] }) + const locked = template.activeRunCount > 0 + + // Deliberately only keyed on the id, not `template.stages` — this is the seed for + // uncontrolled node/edge state below, meant to run once per template, not on every + // in-place edit (which also changes what buildInitialGraph would return via stageNodes). + const initial = useMemo(() => buildInitialGraph(template.stages), [params.id]) // eslint-disable-line react-hooks/exhaustive-deps + const [nodes, setNodes] = useState(initial.nodes) + const [edges, setEdges] = useState(initial.edges) + const [selectedNodeId, setSelectedNodeId] = useState(null) + + // `resolvedTheme` is unknown on the server (and on the client's first paint, before + // next-themes reads localStorage), so `colorMode` below would differ between the SSR + // markup and the client's first render — same hydration-mismatch class theme-toggle.tsx + // already guards against. Render the canvas only once mounted. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const onNodesChange = useCallback( + (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), + [] + ) + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), + [] + ) + const onConnect = useCallback( + (connection: Connection) => { + if (locked) return + if (connection.source === connection.target) { + toast.error("Can't connect a stage to itself") + return + } + const duplicate = edges.some((e) => e.source === connection.source && e.target === connection.target) + if (duplicate) { + toast.error("These stages are already connected") + return + } + setEdges((eds) => addEdge(connection, eds)) + }, + [edges, locked] + ) + + const onNodeClick: NodeMouseHandler = useCallback((_, node) => setSelectedNodeId(node.id), []) + const onPaneClick = useCallback(() => setSelectedNodeId(null), []) + + // Guarded here, not just by hiding the toolbar/panel controls: `elementsSelectable` stays + // true even when locked (so a locked template can still be inspected), and these two + // setters go straight to setNodes/setEdges — they don't route through onNodesChange, which + // is what actually gets set to `undefined` when locked. Without this check, a locked + // template's box/line labels, rotation, and now the inline delete buttons would all still + // be editable via those paths. + function updateNodeData(nodeId: string, patch: Partial | Partial) { + if (locked) return + setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...patch } } : n))) + } + + function addStage() { + const id = `n${newId()}` + const existingStages = nodes.filter((n) => n.type === "stage") + const maxX = existingStages.reduce((max, n) => Math.max(max, n.position.x), 0) + const y = existingStages.length > 0 ? existingStages[existingStages.length - 1].position.y : 120 + setNodes((nds) => [ + ...nds, + { + id, + type: "stage", + position: { x: existingStages.length > 0 ? maxX + 280 : 40, y }, + data: { name: "New stage", roleLabel: "", estimatedMinutes: 15, inputs: [], outputs: [], fieldDefs: [] } satisfies StageNodeData, + }, + ]) + setSelectedNodeId(id) + } + + // Generic across every node type — stage cards, boxes, lines all use this (inline × buttons + // on the nodes themselves, plus the stage editor panel's "Delete stage" button). Box/line + // nodes never have edges, so the edge-filter is a no-op for them, not a special case. + function deleteNode(nodeId: string) { + if (locked) return + setNodes((nds) => nds.filter((n) => n.id !== nodeId)) + setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)) + setSelectedNodeId((id) => (id === nodeId ? null : id)) + } + + // Boxes/lines are prepended (not appended) so React Flow — which paints later array + // entries on top — renders them behind the stage nodes. + function addBox() { + setNodes((nds) => [ + { + id: `a${newId()}`, + type: "box", + position: { x: 40, y: 40 }, + width: 320, + height: 220, + data: { label: "" } satisfies AnnotationData, + }, + ...nds, + ]) + } + + function addLine() { + setNodes((nds) => [ + { + id: `a${newId()}`, + type: "line", + position: { x: 60, y: 300 }, + width: 220, + height: 4, + data: { label: "" } satisfies AnnotationData, + }, + ...nds, + ]) + } + + // React Flow's built-in keyboard delete (Backspace/Delete on a selected node) goes through + // this callback, not through deleteNode() above — stage edges need cleaning up either way. + // Box/line nodes never have edges, so this is a no-op for them. + const onNodesDelete = useCallback((deleted: Node[]) => { + const deletedIds = new Set(deleted.map((n) => n.id)) + setEdges((eds) => eds.filter((e) => !deletedIds.has(e.source) && !deletedIds.has(e.target))) + }, []) + + // Boxes/lines are pure annotations — never part of the stage graph, so every graph check + // below operates on stage nodes only (docs/21-FRONTEND-PHASE2.md §2 "Client-side graph + // checks (UX only — server re-validates on save)"). + const stageNodes = useMemo(() => nodes.filter((n) => n.type === "stage"), [nodes]) + + const analysis = useMemo(() => { + const terminalIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.source === n.id)).map((n) => n.id)) + const entryIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.target === n.id)).map((n) => n.id)) + const disconnectedIds = new Set( + stageNodes.length > 1 + ? stageNodes.filter((n) => !edges.some((e) => e.source === n.id || e.target === n.id)).map((n) => n.id) + : [] + ) + const hasCycle = detectCycle(stageNodes, edges) + return { terminalIds, entryIds, disconnectedIds, hasCycle } + }, [stageNodes, edges]) + + // Clear stale Upstream references after an edge is deleted, with a warning toast — per + // "re-check after edge deletions and clear broken references with a warning toast". + useEffect(() => { + for (const node of stageNodes) { + const data = node.data as StageNodeData + const directParentIds = new Set(edges.filter((e) => e.target === node.id).map((e) => e.source)) + const stale = data.inputs.filter((i) => i.source === "Upstream" && i.upstreamStageId && !directParentIds.has(i.upstreamStageId)) + if (stale.length > 0) { + updateNodeData(node.id, { + inputs: data.inputs.map((i) => + stale.includes(i) ? { ...i, upstreamStageId: undefined, upstreamOutputId: undefined } : i + ), + }) + toast.warning("Input reference cleared", `"${data.name}" referenced a stage that's no longer connected.`) + } + } + // Only re-run when the edge set changes — re-running on every node data edit would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [edges]) + + const issues = useMemo(() => { + const list: string[] = [] + if (analysis.hasCycle) list.push("Cycle detected — stages must form a one-directional flow.") + if (analysis.terminalIds.size !== 1) { + list.push( + analysis.terminalIds.size === 0 + ? "No terminal stage — connect stages so the line converges to a single final stage." + : `${analysis.terminalIds.size} terminal stages found — connect stages so the line converges to a single final stage.` + ) + } + if (analysis.entryIds.size === 0) list.push("No entry stage — at least one stage must have no inputs from other stages.") + if (analysis.disconnectedIds.size > 0) { + const names = stageNodes.filter((n) => analysis.disconnectedIds.has(n.id)).map((n) => (n.data as StageNodeData).name) + list.push(`Disconnected stage${names.length > 1 ? "s" : ""}: ${names.join(", ")}.`) + } + for (const node of stageNodes) { + if (!analysis.terminalIds.has(node.id)) continue + const data = node.data as StageNodeData + if (data.outputs.length === 0 || data.outputs.some((o) => !o.itemId)) { + list.push(`Terminal stage "${data.name}" needs an output with a finished-good item picked.`) + } + } + return list + }, [analysis, stageNodes]) + + const selectedNode = stageNodes.find((n) => n.id === selectedNodeId) + const upstreamOptions: UpstreamOutputOption[] = useMemo(() => { + if (!selectedNode) return [] + const parentIds = edges.filter((e) => e.target === selectedNode.id).map((e) => e.source) + return parentIds.flatMap((parentId) => { + const parent = nodes.find((n) => n.id === parentId) + if (!parent) return [] + const parentData = parent.data as StageNodeData + return parentData.outputs.map((o) => ({ + stageId: parent.id, + stageName: parentData.name, + outputId: o.outputId, + outputName: o.name || "(unnamed output)", + })) + }) + }, [selectedNode, edges, nodes]) + + const displayNodes = useMemo( + () => + nodes.map((n) => + n.type === "stage" + ? { + ...n, + data: { + ...n.data, + disconnected: analysis.disconnectedIds.has(n.id), + onDelete: locked ? undefined : () => deleteNode(n.id), + }, + } + : { + ...n, + data: { + ...n.data, + onLabelChange: locked ? undefined : (label: string) => updateNodeData(n.id, { label }), + onRotationChange: locked ? undefined : (rotation: number) => updateNodeData(n.id, { rotation }), + onDelete: locked ? undefined : () => deleteNode(n.id), + }, + } + ), + [nodes, analysis.disconnectedIds] // eslint-disable-line react-hooks/exhaustive-deps + ) + + function handleSave() { + if (issues.length > 0) { + toast.error("Can't save yet", `${issues.length} issue${issues.length > 1 ? "s" : ""} to fix first.`) + return + } + // No backend contract exists yet (docs/21-FRONTEND-PHASE2.md) — this is a UI-only stub. + toast.success("Template saved", `${template.name} — ${stageNodes.length} stage${stageNodes.length === 1 ? "" : "s"}.`) + } + + return ( +
+
+
+ + + +
+

{template.name}

+

{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection{edges.length === 1 ? "" : "s"}

+
+
+
+ {!locked && ( + + )} + {!locked && ( + + )} + {!locked && ( + + )} + {!locked && ( + + )} +
+
+ + {locked && ( +
+ + Template locked — {template.activeRunCount} run{template.activeRunCount === 1 ? "" : "s"} in progress. +
+ )} + + {issues.length > 0 && ( +
+ {issues.map((issue, i) => ( +
+ + {issue} +
+ ))} +
+ )} + +
+
+ {mounted && ( + + + + + + )} +
+ + {selectedNode && ( + updateNodeData(selectedNode.id, patch)} + onDelete={() => deleteNode(selectedNode.id)} + onClose={() => setSelectedNodeId(null)} + /> + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts new file mode 100644 index 0000000..29b5f1e --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts @@ -0,0 +1,73 @@ +// Canvas builder types (docs/21-FRONTEND-PHASE2.md §2). Frontend-only shapes — no +// Dtos/Production backend contract exists yet; these mirror the doc's described jsonb +// shapes closely enough to swap in real API types later without touching the canvas/panel. + +export type FieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select" + +export interface FieldDef { + fieldId: string + key: string + label: string + type: FieldType + /** Only meaningful when type === "Select". */ + options: string[] + required: boolean +} + +export type InputSource = "Stock" | "Upstream" + +export interface FormulaInput { + inputId: string + source: InputSource + // Stock source: + itemId?: number + itemName?: string + uom?: string + qty?: number + batch?: string + // Upstream source — references a direct parent stage's output: + upstreamStageId?: string + upstreamOutputId?: string +} + +export interface FormulaOutput { + outputId: string + /** Free text for a non-terminal stage; on the terminal stage this mirrors the picked item's name. */ + name: string + uom: string + qty: number + batch?: string + /** Required once this output sits on the terminal stage (finished good). */ + itemId?: number +} + +export interface StageNodeData extends Record { + name: string + roleLabel: string + estimatedMinutes: number + inputs: FormulaInput[] + outputs: FormulaOutput[] + fieldDefs: FieldDef[] + /** Computed by the page on every graph change, not user-editable — no in/out edges at all. */ + disconnected?: boolean + /** Injected by the page at render time — deletes this node (and any edges touching it). */ + onDelete?: () => void +} + +export interface MockItem { + itemId: number + name: string + uom: string +} + +/** + * Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry + * no graph semantics (no ports, never appear in cycle/terminal/entry/disconnected checks + * or the save-blocking issue list), unlike "stage" nodes. + */ +export interface AnnotationData extends Record { + label: string + /** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */ + rotation?: number +} + diff --git a/Frontend/erp-system/app/dashboard/production/templates/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/page.tsx new file mode 100644 index 0000000..c4e6db0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/page.tsx @@ -0,0 +1,254 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { LayoutTemplate, Plus, Search } from "lucide-react" + +import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates" +import { ProductionTemplate, TemplateStatus } from "@/types/production" +import { + LineHeaderNodeComponent, + LineStageNodeComponent, + type LineHeaderData, + type LineStageData, +} from "@/components/production/ProductionLineNodes" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +// Frontend-only mock data — no Dtos/Production backend exists yet (docs/21-FRONTEND-PHASE2.md). +const INITIAL_TEMPLATES: ProductionTemplate[] = [ + { templateId: 1, docNo: "TPL-1001", name: "Steel Bracket Assembly", status: "Active", stageCount: 3, activeRunCount: 2, updatedAt: "2026-07-20" }, + { templateId: 2, docNo: "TPL-1002", name: "PCB Soldering Line", status: "Active", stageCount: 5, activeRunCount: 0, updatedAt: "2026-07-18" }, + { templateId: 3, docNo: "TPL-1003", name: "Wooden Pallet Build", status: "Active", stageCount: 2, activeRunCount: 1, updatedAt: "2026-07-25" }, + { templateId: 4, docNo: "TPL-1004", name: "Plastic Injection Mold", status: "Inactive", stageCount: 4, activeRunCount: 0, updatedAt: "2026-07-10" }, + { templateId: 5, docNo: "TPL-1005", name: "Cable Harness Kit", status: "Active", stageCount: 3, activeRunCount: 0, updatedAt: "2026-07-22" }, +] + +type StatusFilter = TemplateStatus | "All" + +function todayIso() { + return new Date().toISOString().slice(0, 10) +} + +const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent } + +const ROW_HEIGHT = 150 +const STAGE_START_X = 300 +const STAGE_GAP_X = 200 + +/** One row per template — its production line, header on the left, stages left to right. */ +function buildLinesGraph(templates: ProductionTemplate[]): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [] + const edges: Edge[] = [] + + templates.forEach((t, row) => { + const y = row * ROW_HEIGHT + nodes.push({ + id: `h${t.templateId}`, + type: "lineHeader", + position: { x: 0, y }, + data: { + templateId: t.templateId, + docNo: t.docNo, + name: t.name, + status: t.status, + activeRunCount: t.activeRunCount, + } satisfies LineHeaderData, + draggable: false, + }) + + const stages = MOCK_TEMPLATE_INFO[t.templateId]?.stages ?? [] + stages.forEach((stageName, i) => { + const stageId = `s${t.templateId}-${i}` + nodes.push({ + id: stageId, + type: "lineStage", + position: { x: STAGE_START_X + i * STAGE_GAP_X, y: y + 22 }, + data: { templateId: t.templateId, name: stageName } satisfies LineStageData, + draggable: false, + }) + edges.push({ + id: `e-${stageId}`, + source: i === 0 ? `h${t.templateId}` : `s${t.templateId}-${i - 1}`, + target: stageId, + }) + }) + }) + + return { nodes, edges } +} + +export default function ProductionTemplatesPage() { + const router = useRouter() + const { resolvedTheme } = useTheme() + const [templates, setTemplates] = useState(INITIAL_TEMPLATES) + const [searchInput, setSearchInput] = useState("") + const [status, setStatus] = useState("All") + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [error, setError] = useState("") + const [submitting, setSubmitting] = useState(false) + + // Same hydration-mismatch guard as the builder canvas (theme-toggle.tsx / templates/[id]/page.tsx): + // colorMode depends on resolvedTheme, which is unknown on the server and on first paint. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const filtered = useMemo(() => { + const q = searchInput.trim().toLowerCase() + return templates.filter((t) => { + if (status !== "All" && t.status !== status) return false + if (q && !t.name.toLowerCase().includes(q) && !t.docNo.toLowerCase().includes(q)) return false + return true + }) + }, [templates, searchInput, status]) + + const hasFilters = searchInput.trim().length > 0 || status !== "All" + + const { nodes, edges } = useMemo(() => buildLinesGraph(filtered), [filtered]) + + const onNodeClick: NodeMouseHandler = (_, node) => { + const templateId = (node.data as LineHeaderData | LineStageData).templateId + router.push(`/dashboard/production/templates/${templateId}`) + } + + function openCreateDialog() { + setName("") + setError("") + setOpen(true) + } + + function handleCreate() { + const trimmed = name.trim() + if (!trimmed) { + setError("Name is required.") + return + } + setSubmitting(true) + const nextId = templates.reduce((max, t) => Math.max(max, t.templateId), 0) + 1 + const created: ProductionTemplate = { + templateId: nextId, + docNo: `TPL-${1000 + nextId}`, + name: trimmed, + status: "Active", + stageCount: 0, + activeRunCount: 0, + updatedAt: todayIso(), + } + setTemplates((prev) => [...prev, created]) + toast.success("Template created", trimmed) + setOpen(false) + setSubmitting(false) + // No backend exists yet, so the builder can't look this template up by id (its mock + // lookup only knows the 5 seeded ones) — pass the name through and start it blank. + router.push(`/dashboard/production/templates/${nextId}?name=${encodeURIComponent(trimmed)}&fresh=1`) + } + + return ( +
+
+
+

Production Templates

+

Every production line, stage by stage. Click a line to open its builder.

+
+ + New Template} /> + + + New template + Give the template a name — you'll build its stage graph next. + + + + Name + setName(e.target.value)} + placeholder="e.g. Aluminium Frame Assembly" + aria-invalid={!!error} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> + + + +
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search templates…" + className="h-14 w-full pl-11 text-base" + aria-label="Search templates" + /> +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {filtered.length === 0 ? ( +
+ +

+ {hasFilters ? "No templates match your search/filter." : "No templates yet."} +

+
+ ) : ( +
+ {mounted ? ( + + + + + ) : ( + + )} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index cd7f25a..2a19bdf 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -11,16 +11,19 @@ import { CalendarClock, ChevronRight, ClipboardList, + Factory, FileBarChart, FileText, HelpCircle, IdCard, LayoutGrid, + LayoutTemplate, ListTree, Menu, Package, PackageCheck, PackageX, + PlayCircle, Ruler, Settings, ShieldCheck, @@ -89,6 +92,18 @@ const navItems: { { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, { title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true }, + { + title: "Production", + code: "production", + href: "/dashboard/production", + landingHref: "/dashboard/production/runs", + icon: Factory, + chevron: true, + children: [ + { title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate }, + { title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle }, + ], + }, { title: "HRM", code: "hrm", @@ -333,7 +348,7 @@ export function AppSidebar() { // grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // anything server-side. - const bypassCodes = new Set(["procurement", "hrm"]) + const bypassCodes = new Set(["procurement", "hrm", "production"]) const visibleItems = loading ? [] : navItems diff --git a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx index 1d17c38..a5c4ad9 100644 --- a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx +++ b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx @@ -35,6 +35,9 @@ const SEGMENT_LABELS: Record = { rfqs: "RFQs", "purchase-orders": "Purchase Orders", "purchase-returns": "Purchase Returns", + production: "Production", + templates: "Templates", + runs: "Runs", } function labelFor(segment: string): string { diff --git a/Frontend/erp-system/components/production/ProductionLineNodes.tsx b/Frontend/erp-system/components/production/ProductionLineNodes.tsx new file mode 100644 index 0000000..81c2168 --- /dev/null +++ b/Frontend/erp-system/components/production/ProductionLineNodes.tsx @@ -0,0 +1,57 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" + +import { cn } from "@/lib/utils" + +export interface LineHeaderData extends Record { + templateId: number + docNo: string + name: string + status: "Active" | "Inactive" + activeRunCount: number +} + +/** Row label docked at the left of each production line — the template itself. */ +function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) { + return ( +
+
+ {data.docNo} + + {data.status} + +
+

{data.name}

+ {data.activeRunCount > 0 && ( + + {data.activeRunCount} in progress + + )} + +
+ ) +} + +export interface LineStageData extends Record { + templateId: number + name: string +} + +/** One stage on a production line — read-only, purely a visual chip on the overview canvas. */ +function LineStageNode({ data }: NodeProps & { data: LineStageData }) { + return ( +
+ +

{data.name}

+ +
+ ) +} + +export const LineHeaderNodeComponent = memo(LineHeaderNode) +export const LineStageNodeComponent = memo(LineStageNode) diff --git a/Frontend/erp-system/components/production/RunStageNode.tsx b/Frontend/erp-system/components/production/RunStageNode.tsx new file mode 100644 index 0000000..7326b06 --- /dev/null +++ b/Frontend/erp-system/components/production/RunStageNode.tsx @@ -0,0 +1,75 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" +import { ChevronRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { RunStatus } from "@/types/production" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors" + +export interface RunHeaderData extends Record { + docNo: string + templateName: string + status: RunStatus +} + +/** Left-most box on a run's production line — the run itself, not a stage. */ +function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) { + return ( +
+ {data.docNo} +

{data.templateName}

+ +
+ ) +} + +export interface RunStageData extends Record { + name: string + state: StageStatus + isActive: boolean + /** Present only on the current (leftmost incomplete) stage of an InProgress run. */ + onAdvance?: () => void +} + +/** One stage on a run's production line, colored by its live status — the box the "give + * progress" action lives on: the active stage grows an Advance button to push it forward. */ +function RunStageNode({ data }: NodeProps & { data: RunStageData }) { + const color = STAGE_STATUS_COLOR[data.state] + + return ( +
+ + +
+ +

{data.name}

+
+

{STAGE_STATUS_LABEL[data.state]}

+ + {data.onAdvance && ( + + )} + + +
+ ) +} + +export const RunHeaderNodeComponent = memo(RunHeaderNode) +export const RunStageNodeComponent = memo(RunStageNode) diff --git a/Frontend/erp-system/components/production/stage-progress-strip.tsx b/Frontend/erp-system/components/production/stage-progress-strip.tsx new file mode 100644 index 0000000..9cfcf64 --- /dev/null +++ b/Frontend/erp-system/components/production/stage-progress-strip.tsx @@ -0,0 +1,85 @@ +import { CheckCircle2 } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + RUN_CANCELLED_COLOR, + RUN_COMPLETED_COLOR, + STAGE_STATUS_COLOR, + STAGE_STATUS_LABEL, + STAGE_STATUS_ORDER, +} from "@/lib/production-status-colors" +import { RunStatus, StageSummary } from "@/types/production" + +/** + * One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render + * a full teal strip + check; cancelled runs get a red accent instead of per-stage segments. + */ +export function StageProgressStrip({ + status, + summary, + className, +}: { + status: RunStatus + summary: StageSummary + className?: string +}) { + if (status === "Completed") { + return ( +
+
+ +
+ ) + } + + const counts: Record = { + Waiting: summary.waiting, + Ready: summary.ready, + InProgress: summary.inProgress, + Done: summary.done, + Approved: summary.approved, + } + const total = STAGE_STATUS_ORDER.reduce((sum, key) => sum + counts[key], 0) + + return ( +
+ {total === 0 + ? null + : STAGE_STATUS_ORDER.map((key) => { + const count = counts[key] + if (count === 0) return null + return ( +
+ ) + })} +
+ ) +} + +export function StageStatusLegend({ className }: { className?: string }) { + return ( +
+ {STAGE_STATUS_ORDER.map((key) => ( +
+ + {STAGE_STATUS_LABEL[key]} +
+ ))} +
+ + Cancelled +
+
+ + Completed +
+
+ ) +} diff --git a/Frontend/erp-system/lib/production-mock-runs.ts b/Frontend/erp-system/lib/production-mock-runs.ts new file mode 100644 index 0000000..a56701a --- /dev/null +++ b/Frontend/erp-system/lib/production-mock-runs.ts @@ -0,0 +1,105 @@ +// Frontend-only mock run registry — no Dtos/Production backend exists yet +// (docs/21-FRONTEND-PHASE2.md). Shared by the Runs board and the run detail page so both +// read the same seed data (each page still keeps its own local edits — there's no backend +// to persist an advance/start-run action back to the other screen). +import { ProductionRun } from "@/types/production" +import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates" +import { STAGE_STATUS_ORDER, type StageStatus } from "@/lib/production-status-colors" + +export const INITIAL_RUNS: ProductionRun[] = [ + { + runId: 1, docNo: "PRD-2026-00001", templateName: "Steel Bracket Assembly", targetQty: 500, + finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress", + reworkCount: 0, createdAt: "2026-07-26", completedAt: null, + stageSummary: { waiting: 1, ready: 0, inProgress: 1, done: 1, approved: 0 }, + }, + { + runId: 2, docNo: "PRD-2026-00002", templateName: "PCB Soldering Line", targetQty: 200, + finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Colombo Warehouse", status: "InProgress", + reworkCount: 1, createdAt: "2026-07-25", completedAt: null, + stageSummary: { waiting: 0, ready: 1, inProgress: 2, done: 1, approved: 1 }, + }, + { + runId: 3, docNo: "PRD-2026-00003", templateName: "Wooden Pallet Build", targetQty: 1000, + finishedItemName: "Pallet Standard", uom: "PCS", warehouseName: "Main Warehouse", status: "Completed", + reworkCount: 0, createdAt: "2026-07-20", completedAt: "2026-07-24", + stageSummary: { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 2 }, + }, + { + runId: 4, docNo: "PRD-2026-00004", templateName: "Cable Harness Kit", targetQty: 300, + finishedItemName: "Harness Kit B", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress", + reworkCount: 0, createdAt: "2026-07-27", completedAt: null, + stageSummary: { waiting: 2, ready: 1, inProgress: 0, done: 0, approved: 0 }, + }, + { + runId: 5, docNo: "PRD-2026-00005", templateName: "Steel Bracket Assembly", targetQty: 150, + finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Colombo Warehouse", status: "Cancelled", + reworkCount: 0, createdAt: "2026-07-15", completedAt: null, + stageSummary: { waiting: 0, ready: 0, inProgress: 1, done: 0, approved: 0 }, + }, + { + runId: 6, docNo: "PRD-2026-00006", templateName: "PCB Soldering Line", targetQty: 400, + finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress", + reworkCount: 0, createdAt: "2026-07-23", completedAt: null, + stageSummary: { waiting: 1, ready: 2, inProgress: 1, done: 1, approved: 0 }, + }, +] + +// Active templates only (docs/21-FRONTEND-PHASE2.md §4 "Template picker (Active only)") — +// mirrors the 4 Active rows on the Template list page ("Plastic Injection Mold" is Inactive +// there, so it's excluded here too. `nominalBatchQty` backs the scaled-preview calculation; +// there's no real per-template formula graph shared across routes to scale properly (each +// builder page's stage data is local, unsaved state — see templates/[id]/page.tsx), so this +// is a simplified stand-in for the doc's full per-stage scaled preview. +export interface StartableTemplate { + templateId: number + name: string + finishedItemName: string + uom: string + nominalBatchQty: number + stageCount: number +} + +export const STARTABLE_TEMPLATES: StartableTemplate[] = [ + { templateId: 1, name: "Steel Bracket Assembly", finishedItemName: "Steel Bracket A", uom: "PCS", nominalBatchQty: 100, stageCount: 3 }, + { templateId: 2, name: "PCB Soldering Line", finishedItemName: "PCB Board X", uom: "PCS", nominalBatchQty: 50, stageCount: 5 }, + { templateId: 3, name: "Wooden Pallet Build", finishedItemName: "Pallet Standard", uom: "PCS", nominalBatchQty: 200, stageCount: 2 }, + { templateId: 5, name: "Cable Harness Kit", finishedItemName: "Harness Kit B", uom: "SET", nominalBatchQty: 75, stageCount: 3 }, +] + +export interface RunStagePlanItem { + name: string + state: StageStatus +} + +const SUMMARY_KEY_BY_STATUS: Record = { + Waiting: "waiting", + Ready: "ready", + InProgress: "inProgress", + Done: "done", + Approved: "approved", +} + +/** + * `stageSummary` only carries counts per status, not which named stage each count belongs + * to. Reconstruct a per-stage breakdown by looking up the template's real stage names (via + * MOCK_TEMPLATE_INFO) and allocating the counts across them most-complete-first — stages + * run left to right, so the furthest-along stages are assumed to be the earliest ones in + * the list. Pad with "Waiting" (and truncate) when the counts don't add up to the template's + * actual stage count — e.g. the Cancelled mock run stops partway through its stage list. + */ +export function buildStagePlan(templateName: string, summary: ProductionRun["stageSummary"]): RunStagePlanItem[] { + const info = Object.values(MOCK_TEMPLATE_INFO).find((t) => t.name === templateName) + const totalCount = Object.values(summary).reduce((sum, n) => sum + n, 0) + const stageNames = info?.stages ?? Array.from({ length: Math.max(totalCount, 1) }, (_, i) => `Stage ${i + 1}`) + + const statuses: StageStatus[] = [] + for (const s of [...STAGE_STATUS_ORDER].reverse()) { + const count = summary[SUMMARY_KEY_BY_STATUS[s]] + for (let i = 0; i < count; i++) statuses.push(s) + } + while (statuses.length < stageNames.length) statuses.push("Waiting") + statuses.length = stageNames.length + + return stageNames.map((name, i) => ({ name, state: statuses[i] })) +} diff --git a/Frontend/erp-system/lib/production-mock-templates.ts b/Frontend/erp-system/lib/production-mock-templates.ts new file mode 100644 index 0000000..6f83558 --- /dev/null +++ b/Frontend/erp-system/lib/production-mock-templates.ts @@ -0,0 +1,16 @@ +// Frontend-only mock template registry — no Dtos/Production backend exists yet +// (docs/21-FRONTEND-PHASE2.md). Shared by the Template list page (production-line preview +// per card) and the canvas builder page (initial graph + edit-lock), so the two never drift. +export interface MockTemplateInfo { + name: string + activeRunCount: number + stages: string[] +} + +export const MOCK_TEMPLATE_INFO: Record = { + "1": { name: "Steel Bracket Assembly", activeRunCount: 2, stages: ["Cutting", "Welding", "QA Inspection"] }, + "2": { name: "PCB Soldering Line", activeRunCount: 0, stages: ["Component Placement", "Soldering", "Inspection", "Cleaning", "Final Test"] }, + "3": { name: "Wooden Pallet Build", activeRunCount: 1, stages: ["Assembly", "Quality Check"] }, + "4": { name: "Plastic Injection Mold", activeRunCount: 0, stages: ["Mold Prep", "Injection", "Cooling", "Trimming"] }, + "5": { name: "Cable Harness Kit", activeRunCount: 0, stages: ["Wire Cutting", "Crimping", "Bundling"] }, +} diff --git a/Frontend/erp-system/lib/production-status-colors.ts b/Frontend/erp-system/lib/production-status-colors.ts new file mode 100644 index 0000000..bcaeb3e --- /dev/null +++ b/Frontend/erp-system/lib/production-status-colors.ts @@ -0,0 +1,27 @@ +// Single source of truth for stage-status coloring (docs/21-FRONTEND-PHASE2.md §3): +// "These colors are the single source for status coloring everywhere (board, run graph, +// drawers, legend)." Every screen that renders a stage status imports from here. + +export type StageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved" + +export const STAGE_STATUS_ORDER: StageStatus[] = ["Waiting", "Ready", "InProgress", "Done", "Approved"] + +export const STAGE_STATUS_COLOR: Record = { + Waiting: "#9CA3AF", + Ready: "#3B82F6", + InProgress: "#F59E0B", + Done: "#22C55E", + Approved: "#14B8A6", +} + +export const STAGE_STATUS_LABEL: Record = { + Waiting: "Waiting", + Ready: "Ready", + InProgress: "In Progress", + Done: "Done", + Approved: "Approved", +} + +/** Run-level (not stage-level) colors, per the same table. */ +export const RUN_CANCELLED_COLOR = "#EF4444" +export const RUN_COMPLETED_COLOR = "#14B8A6" diff --git a/Frontend/erp-system/package.json b/Frontend/erp-system/package.json index 63efa97..0c624e3 100644 --- a/Frontend/erp-system/package.json +++ b/Frontend/erp-system/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.4.0", "@radix-ui/react-icons": "^1.3.2", "@tanstack/react-query": "^5.101.2", + "@xyflow/react": "^12.11.2", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/Frontend/erp-system/types/production.ts b/Frontend/erp-system/types/production.ts new file mode 100644 index 0000000..8b71056 --- /dev/null +++ b/Frontend/erp-system/types/production.ts @@ -0,0 +1,42 @@ +// Phase 2 (Manufacturing) frontend-only types — mirrors docs/21-FRONTEND-PHASE2.md. +// No backend contract exists yet (no Dtos/Production, no 30-BACKEND-PHASE2.md), so these +// are UI-shape placeholders for the mock data driving the Template list / Run board screens +// until the real API lands. + +export type TemplateStatus = "Active" | "Inactive" + +export interface ProductionTemplate { + templateId: number + docNo: string + name: string + status: TemplateStatus + stageCount: number + activeRunCount: number + updatedAt: string +} + +export type RunStatus = "InProgress" | "Completed" | "Cancelled" + +/** One count per canonical stage status (docs/21-FRONTEND-PHASE2.md §3). */ +export interface StageSummary { + waiting: number + ready: number + inProgress: number + done: number + approved: number +} + +export interface ProductionRun { + runId: number + docNo: string + templateName: string + targetQty: number + finishedItemName: string + uom: string + warehouseName: string + status: RunStatus + reworkCount: number + createdAt: string + completedAt: string | null + stageSummary: StageSummary +} diff --git a/docs/21-FRONTEND-PHASE2.md b/docs/21-FRONTEND-PHASE2.md new file mode 100644 index 0000000..c4c98c9 --- /dev/null +++ b/docs/21-FRONTEND-PHASE2.md @@ -0,0 +1,122 @@ +# 21 · FRONTEND-PHASE2 — Manufacturing: Production Lines (Flows & Rules) + +> **Purpose:** Frontend source of truth for Phase 2 (Manufacturing): the template canvas builder, the run board, and run execution screens. API contract and all business rules live in `30-BACKEND-PHASE2.md` — this doc never redefines them. Validation posture follows `20-FRONTEND §3`: client validation is UX only; the server is authoritative. Register this doc in `00-CORE.md §7` and `01-DOC-GUIDE.md §2`. + +--- + +## 1. Screens + +| Screen | Route (suggested) | Actual route (frontend-only build) | Purpose | +|---|---|---|---| +| Template list | `/production/templates` | `/dashboard/production/templates` — a single shared React Flow canvas, one row per template (header + stages left→right), not a list/grid | Browse templates, status, active-run count; open builder. | +| Template builder (canvas) | `/production/templates/{id}` | `/dashboard/production/templates/{id}` | Drag-and-drop stage graph design. | +| Run board | `/production/runs` | `/dashboard/production/runs` | All runs with per-stage progress at a glance. | +| Start run dialog | modal from board/list | modal from run board | Pick template, target qty, warehouse; preview scaled quantities. | +| Run detail | `/production/runs/{id}` | `/dashboard/production/runs/{id}` | Read-only graph with live statuses + stage action drawer. | + +> **Build status:** §§1–4 are implemented as a **frontend-only mock** (per-page `useState`, no persistence across pages/reloads) — no `Dtos/Production` or `30-BACKEND-PHASE2.md` exist yet, so nothing here talks to a real API. §5 (Run detail) is implemented in a **simplified form**: one generic per-stage advance action instead of the full status-specific stage drawer. §6 (validation/error posture) does not apply yet — there's no server to surface `ProblemDetails`/error codes from. See §8 for the itemized gap list. + +--- + +## 2. Template builder (canvas) + +**Library:** React Flow (drag/drop nodes, edge drawing, pan/zoom, minimap). Node positions map 1:1 to `posX`/`posY`; the backend stores layout uninterpreted, so all layout behavior is client-owned. + +**Node (stage card)** shows: name, role label chip, estimated minutes, input count → output count. Selecting a node opens the **stage editor panel**: +- Name, role label (free text with suggestions e.g. QA, Assembly), estimated minutes. +- **Formula rows** — Inputs: source toggle `Stock | Upstream`; Stock → Item picker (active items only) + UOM + qty/batch; Upstream → dropdown of *direct parents' outputs only* (disable others). Outputs: name + UOM + qty/batch; on the terminal stage the single output requires an Item picker (finished good). +- **Custom field builder** — add/remove fields: key (auto-slug from label), label, type (`Text|Number|Checkbox|Date|Select` + options), required toggle. Serialized to the `fieldDefs` jsonb shape verbatim. + +**Edges:** drawn parent → child. Client blocks duplicate edges and self-loops at draw time. + +**Client-side graph checks (UX only — server re-validates on save):** +- Cycle detection (toposort) — highlight the offending edge. +- Exactly one terminal (no-outbound) node — banner "Connect stages so the line converges to a single final stage" when ≠1. +- ≥1 entry node; no disconnected nodes (grey them out). +- Terminal output has an Item; Upstream inputs reference a current direct parent (re-check after edge deletions and clear broken references with a warning toast). + +**Save:** full-graph `POST`/`PUT` with `If-Match`. Surface `422 GRAPH_*` codes by focusing the offending node/edge. **Edit lock:** when `activeRunCount > 0`, render the canvas read-only with a banner "Template locked — N run(s) in progress" (server enforces via `409 TEMPLATE_IN_USE`; the banner is UX). Deactivate action instead of delete. + +--- + +## 3. Run board + +List/grid of runs, newest first, filters: status, template, warehouse, search by doc no. + +Each row/card: `docNo` (`PRD-2026-00001`), template name, target qty + finished item, created/completed timestamps, rework badge when `reworkCount > 0`, and a **stage progress strip** rendered from `stageSummary` — one segment per stage-status count using the canonical colors: + +| Status | Color | +|---|---| +| Waiting | grey `#9CA3AF` | +| Ready | blue `#3B82F6` | +| InProgress | amber `#F59E0B` | +| Done | green `#22C55E` | +| Approved | teal `#14B8A6` | +| Run Cancelled | red accent on the card | +| Run Completed | full teal strip + check | + +These colors are the single source for status coloring everywhere (board, run graph, drawers, legend). Show a legend on the board. + +--- + +## 4. Start run dialog + +1. Template picker (Active only), target quantity (of the finished item, unit shown), warehouse, optional output bin. +2. **Scaled preview:** client computes `scaleFactor = targetQty / terminalOutputQtyPerBatch` and shows every stage's scaled inputs/outputs *as a preview only* — the authoritative scaled figures come back on the `201` response. +3. On create → navigate to run detail. Quantity fine-tuning happens there via the per-stage quantities editor (not in this dialog). + - **As built:** stays on the run board with a success toast instead of navigating — the new run's stages start `waiting: stageCount-1, ready: 1` and the user opens it from the board like any other run. + +--- + +## 5. Run detail + +> **As built (mock):** a single-row React Flow line (header box + one box per real stage name, left→right) instead of the full copied-template graph, with a header progress bar/percentage and one **Give Progress** action (canvas button on the active stage, and a mirrored button in the header) that steps that stage through the canonical status sequence `Waiting → Ready → InProgress → Done → Approved`. No drawer, no per-status action set, no quantities/scrap/custom-field forms, no delivered/available badges, no polling (single local page, no backend to refetch from). All state is local `useState` — reloading the page resets to the seeded mock run. See §8. + +**Layout:** the template graph re-rendered read-only (same React Flow canvas, positions from the run's copied stages), each node colored by live status, with `deliveredQty/plannedQty` badges on inbound edges and an available-to-transfer badge on approved stages holding a remainder. Poll or refetch after every action. + +**Stage drawer** (click a node) — content by status: +- Any status: name, role chip, estimated vs **actual** time (`actualStartAt`/`actualEndAt`, live elapsed while InProgress), event history timeline. +- **Waiting:** per-upstream-input delivery progress bars; nothing actionable except *Reject intake* when `deliveredQty > 0` (see below). +- **Ready:** *Edit quantities* (planned in/out — disabled after start, surface `409 STAGE_NOT_EDITABLE`), Stock-input availability hints (`on-hand` enquiry, advisory only — never block client-side, per `20-FRONTEND §3`), and **Start**. On start errors surface `STOCK_NEGATIVE_BLOCKED` / `ONHOLD_NOT_ISSUABLE` / `EXPIRED_BATCH_BLOCKED` with the item named. +- **InProgress:** **Complete** form — per output: produced qty, scrapped qty (reason-code picker appears and becomes required when scrap > 0), plus the **custom field form rendered from `fieldDefs`** (required fields block submit client-side; server backs with `400 REQUIRED_FIELD_MISSING`). +- **Done:** **Approve** — non-terminal: default "transfer all" with an optional per-output partial amount (validated ≤ available); terminal: confirmation summarizing the receipt (qty, computed unit cost from cost pool preview). Terminal also offers **Reject** with a strong confirm modal: *"This resets the entire run to its starting stages (rework #N). Consumed materials remain in the run."* +- **Approved (non-terminal):** *Transfer remainder* action while available > 0 (`422 TRANSFER_EXCEEDS_AVAILABLE` surfaced inline). +- **Reject intake** (on a Ready/Waiting stage with deliveries): confirm modal *"Returns work to the previous completed stage for rework"* → parents visibly flip back to InProgress on refresh. + +**Run-level actions:** *Return leftover* (per started Stock input: qty ≤ consumed − returned, reason code required; hidden once run Completed — `RUN_COST_CLOSED`), *Cancel run* (reason code + note, confirm modal explaining stock return; hidden when Completed). + +--- + +## 6. Validation posture & error surfacing + +- Client checks: required/format/range, graph checks (§2), qty ≤ available style guards — all UX; never assume stock rules client-side. +- Every `ProblemDetails` renders its `title`; map domain `code`s to friendly inline messages (table in `30-BACKEND-PHASE2 §D.4`). Unknown codes fall back to the ProblemDetails title + trace id. +- `412 CONCURRENCY_CONFLICT` → "This item changed elsewhere — reloading" + refetch. Stage-action `409`s (wrong status) → refetch the run silently and re-render; another user likely acted first. +- Stage-transition posts send an `Idempotency-Key` (uuid per click) so double-clicks are replay-safe. + +--- + +## 7. Foundation additions (PROGRESS seed) + +- [x] React Flow dependency + canvas components — but **not** a single shared editable/read-only variant: the template-overview canvas (`templates/page.tsx`), the builder canvas (`templates/[id]/page.tsx`), and the run-detail canvas (`runs/[id]/page.tsx`) are three separate node-type sets (`ProductionLineNodes.tsx`, `StageNode.tsx`/`AnnotationNodes.tsx`, `RunStageNode.tsx`). +- [ ] Types mirroring `Dtos/Production` (template graph, run graph, stage actions) — not started; `types/production.ts` is a standalone frontend-only placeholder shape, nothing to mirror against yet. +- [x] Status-color tokens (§3 table) exported from one module — `lib/production-status-colors.ts` (`STAGE_STATUS_COLOR`/`_LABEL`/`_ORDER`, `RUN_CANCELLED_COLOR`, `RUN_COMPLETED_COLOR`). +- [~] Custom-field renderer (defs jsonb → form) + builder (form → defs jsonb) — builder half only (`StageEditorPanel.tsx`, defs jsonb ← form). The runtime renderer (form → filled values, used during the spec'd Complete action) doesn't exist since there's no stage drawer/Complete step (§5). +- [~] Screens: template list · builder · run board · start dialog · run detail + drawer — list/builder/board/dialog implemented (as mock); run detail implemented **without** the drawer or per-status action set (§5, §8). +- [ ] Error-code → message map for §D.4 additions — not started, no backend/`ProblemDetails` to map yet. + +--- + +## 8. Gaps vs. this spec (frontend-only mock — no `Dtos/Production` / `30-BACKEND-PHASE2.md` yet) + +Everything below is intentional scope for the current build, not a bug — recorded so whoever wires up the real backend knows exactly what's still owed against this doc: + +- **No persistence.** All state is per-page `useState` seeded from hardcoded mock arrays (`lib/production-mock-templates.ts`, `lib/production-mock-runs.ts`). Templates, runs, and stage-status edits don't survive a reload and don't sync across the three canvases/pages. +- **Run detail is a simplified single action, not the stage drawer (§5).** One generic "Give Progress" step (Waiting→Ready→InProgress→Done→Approved) replaces Start/Complete (qty+scrap+custom fields)/Approve/Reject/Transfer remainder/Reject intake. No event history timeline, no actual-vs-estimated time tracking, no delivered/available badges. +- **No run-level actions.** Return leftover and Cancel run (§5) aren't implemented. +- **Stage identity on the run board/detail is reconstructed, not authoritative.** `ProductionRun.stageSummary` only carries counts per status; `buildStagePlan()` (`lib/production-mock-runs.ts`) maps those counts onto the template's real stage names most-complete-first as a display approximation — a real backend would return named per-stage records directly. +- **No validation/error posture (§6).** No `ProblemDetails`, no domain error-code mapping, no `412`/`409` handling, no `Idempotency-Key` — there's no server to produce any of it yet. +- **Save uses no `If-Match`/concurrency token** on the builder (§2) — a local `locked` boolean (from mock `activeRunCount`) stands in for the server's `409 TEMPLATE_IN_USE` edit lock. +- **Template overview deviates from "list" (§1).** Implemented as one shared canvas (all templates as production lines, one row each) instead of a browsable list/grid, per explicit product direction during the build. + +*End of 21-FRONTEND-PHASE2.md. Contract: `30-BACKEND-PHASE2.md`. Record work: `Frontend/PROGRESS.md`.*