Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea6158cd9 | |||
| 415ac94ab2 | |||
| 4561ef7ba8 | |||
| b12adebaa0 | |||
| 0b95d6f1cd | |||
| d35b076435 | |||
| c564916c60 | |||
| 86bb4d4908 | |||
| 816ffbbfb6 | |||
| ae20bc4e34 |
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||
[Route("api/v1/dashboard")]
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
|
||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
||||
|
||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(ct));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ERPCore.Dtos.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed record DashboardStatsDto(
|
||||
int LowStockAlerts,
|
||||
decimal OnHandTotal,
|
||||
int OnHandWarehouses,
|
||||
decimal StockValuationTotal,
|
||||
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
|
||||
int PendingApprovalPurchaseOrders,
|
||||
int PendingGrns,
|
||||
int OpenRequisitions,
|
||||
int PendingCounts,
|
||||
int OpenRfqs);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
@@ -97,6 +97,9 @@ builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
|
||||
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
|
||||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||||
builder.Services.AddScoped<IBranchService, BranchService>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate counts pulled straight from each domain's repository — no PagedResponse
|
||||
/// overhead, since the dashboard only needs totals. Low-stock reuses <see cref="IReorderService"/>
|
||||
/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7).
|
||||
/// On-hand summary and stock valuation sum <see cref="StockLayer.QtyRemaining"/> (and
|
||||
/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts,
|
||||
/// because they need no per-item live lookup.
|
||||
/// </summary>
|
||||
public sealed class DashboardService : IDashboardService
|
||||
{
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<Requisition> _requisitions;
|
||||
private readonly IRepository<StockCount> _counts;
|
||||
private readonly IRepository<Rfq> _rfqs;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
public DashboardService(
|
||||
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
||||
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||
IReorderService reorder)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
_requisitions = requisitions;
|
||||
_counts = counts;
|
||||
_rfqs = rfqs;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
public async Task<DashboardStatsDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -181,6 +181,13 @@ Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -118,6 +118,16 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md`
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 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 `<canvas>` 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.
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function AttendanceBatchDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
|
||||
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
|
||||
@@ -132,9 +132,9 @@ export default function AttendanceBatchDetailPage() {
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="u-reason">Reason</FieldLabel><Input id="u-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function AttendanceBatchesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Attendance</h1>
|
||||
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.</p>
|
||||
@@ -86,7 +86,7 @@ export default function AttendanceBatchesPage() {
|
||||
<DialogDescription>Columns: Employee Code, Date, Check In, Check Out.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="a-start">Period start</FieldLabel><Input id="a-start" type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} /></Field>
|
||||
<Field><FieldLabel htmlFor="a-end">Period end</FieldLabel><Input id="a-end" type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} /></Field>
|
||||
</div>
|
||||
@@ -95,9 +95,9 @@ export default function AttendanceBatchesPage() {
|
||||
<Input id="a-file" type="file" accept=".xlsx,.csv" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleUpload} disabled={submitting}><Upload className="size-4" />{submitting ? "Uploading…" : "Upload"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleUpload} disabled={submitting}><Upload className="size-4" />{submitting ? "Uploading…" : "Upload"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -237,7 +237,7 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
{tab === "Overview" && (
|
||||
<FieldGroup className="max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Full name</FieldLabel><Input value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>Email</FieldLabel><Input type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||
<Field><FieldLabel>NIC</FieldLabel><Input value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
||||
@@ -363,7 +363,7 @@ export default function EmployeeDetailPage() {
|
||||
<DialogDescription>Supersedes the current open-ended structure from this date.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Effective from</FieldLabel><Input type="date" value={structureEffectiveFrom} onChange={(e) => setStructureEffectiveFrom(e.target.value)} /></Field>
|
||||
<Field><FieldLabel>Basic salary</FieldLabel><Input type="number" min={0} value={structureBasic} onChange={(e) => setStructureBasic(Number(e.target.value))} /></Field>
|
||||
</div>
|
||||
@@ -383,9 +383,9 @@ export default function EmployeeDetailPage() {
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setStructureOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createStructure} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setStructureOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createStructure} disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -433,7 +433,7 @@ export default function EmployeeDetailPage() {
|
||||
<SelectContent><SelectItem value="Loan">Loan</SelectItem><SelectItem value="Advance">Advance</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Principal</FieldLabel><Input type="number" min={0} value={loanPrincipal} onChange={(e) => setLoanPrincipal(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel>Installment amount</FieldLabel><Input type="number" min={0} value={loanInstallmentAmount} onChange={(e) => setLoanInstallmentAmount(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel># installments</FieldLabel><Input type="number" min={1} value={loanCount} onChange={(e) => setLoanCount(Number(e.target.value))} /></Field>
|
||||
@@ -445,9 +445,9 @@ export default function EmployeeDetailPage() {
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setLoanOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createLoan} disabled={busy}>{busy ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setLoanOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createLoan} disabled={busy}>{busy ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function EmployeesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
|
||||
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
|
||||
@@ -134,7 +134,7 @@ export default function EmployeesPage() {
|
||||
<DialogDescription>Not every employee needs a login — a system user account is optional and separate.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.employeeCode}>
|
||||
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
|
||||
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
|
||||
@@ -170,7 +170,7 @@ export default function EmployeesPage() {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.departmentId}>
|
||||
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
|
||||
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
|
||||
@@ -205,9 +205,9 @@ export default function EmployeesPage() {
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function LeaveRequestsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
|
||||
<p className="text-base text-muted-foreground">Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.</p>
|
||||
@@ -129,15 +129,15 @@ export default function LeaveRequestsPage() {
|
||||
<SelectContent>{leaveTypes.map((t) => <SelectItem key={t.leaveTypeId} value={String(t.leaveTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Start date</FieldLabel><Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /></Field>
|
||||
<Field><FieldLabel>End date</FieldLabel><Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /></Field>
|
||||
</div>
|
||||
<Field><FieldLabel>Reason (optional)</FieldLabel><Input value={reason} onChange={(e) => setReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Submitting…" : "Submit"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Submitting…" : "Submit"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PayrollLineDetailPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm text-muted-foreground">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground sm:grid-cols-3">
|
||||
<div>Present days: {line.presentDays}</div>
|
||||
<div>Absent days: {line.absentDays}</div>
|
||||
<div>Leave days: {line.leaveDays}</div>
|
||||
|
||||
@@ -88,12 +88,12 @@ export default function PayrollRunDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{run.docNo}</h1>
|
||||
<p className="text-base text-muted-foreground">{run.periodMonth.toString().padStart(2, "0")}/{run.periodYear} · {run.employeeCount} employees</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" className="h-7 px-3 text-sm">{run.status}</Badge>
|
||||
{run.status === "Draft" && <Button onClick={approve} disabled={busy}>Approve</Button>}
|
||||
{run.status === "Approved" && <Button onClick={lock} disabled={busy}>Lock Payroll</Button>}
|
||||
@@ -110,9 +110,9 @@ export default function PayrollRunDetailPage() {
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="p-reason">Reason</FieldLabel><Input id="p-reason" value={unlockReason} onChange={(e) => setUnlockReason(e.target.value)} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setUnlockOpen(false)}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={unlock} disabled={busy}>Unlock</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function PayrollRunsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
|
||||
<p className="text-base text-muted-foreground">Generate → Review → Approve → Lock → Payslips.</p>
|
||||
@@ -76,14 +76,14 @@ export default function PayrollRunsPage() {
|
||||
<DialogDescription>Blocked if attendance for this period isn't fully Confirmed yet.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field><FieldLabel>Year</FieldLabel><Input type="number" value={periodYear} onChange={(e) => setPeriodYear(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel>Month</FieldLabel><Input type="number" min={1} max={12} value={periodMonth} onChange={(e) => setPeriodMonth(Number(e.target.value))} /></Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleGenerate} disabled={submitting}>{submitting ? "Generating…" : "Generate"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleGenerate} disabled={submitting}>{submitting ? "Generating…" : "Generate"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function DepartmentsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
|
||||
<p className="text-base text-muted-foreground">Org structure — unlimited nesting, optionally scoped to a branch.</p>
|
||||
@@ -140,9 +140,9 @@ export default function DepartmentsPage() {
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function DocumentTypesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
|
||||
<p className="text-base text-muted-foreground">The staff document catalog — NIC, contracts, certificates, etc.</p>
|
||||
@@ -120,9 +120,9 @@ export default function DocumentTypesPage() {
|
||||
<FieldLabel htmlFor="dt-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function LeaveTypesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
|
||||
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.</p>
|
||||
@@ -121,9 +121,9 @@ export default function LeaveTypesPage() {
|
||||
<FieldLabel htmlFor="lt-carry">Carry-forward allowed</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function SalaryComponentsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
|
||||
<p className="text-base text-muted-foreground">Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.</p>
|
||||
@@ -120,9 +120,9 @@ export default function SalaryComponentsPage() {
|
||||
<FieldLabel htmlFor="sc-epfetf">EPF/ETF applicable</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function StatutorySettingsPage() {
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
|
||||
<Dialog open={settingOpen} onOpenChange={setSettingOpen}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Setting</Button>} />
|
||||
@@ -101,9 +101,9 @@ export default function StatutorySettingsPage() {
|
||||
<Field><FieldLabel>ETF employer rate (0-1)</FieldLabel><Input type="number" step="0.001" value={etfEmployerRate} onChange={(e) => setEtfEmployerRate(Number(e.target.value))} /></Field>
|
||||
<Field><FieldLabel>Default OT multiplier</FieldLabel><Input type="number" step="0.1" value={otMultiplierDefault} onChange={(e) => setOtMultiplierDefault(Number(e.target.value))} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setSettingOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createSetting} disabled={submitting}>{submitting ? "Saving…" : "Save"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setSettingOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createSetting} disabled={submitting}>{submitting ? "Saving…" : "Save"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -133,7 +133,7 @@ export default function StatutorySettingsPage() {
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
|
||||
<Dialog open={slabOpen} onOpenChange={setSlabOpen}>
|
||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Slab</Button>} />
|
||||
@@ -148,9 +148,9 @@ export default function StatutorySettingsPage() {
|
||||
<Field><FieldLabel>Upper bound (optional)</FieldLabel><Input type="number" value={upperBound} onChange={(e) => setUpperBound(e.target.value)} placeholder="And above" /></Field>
|
||||
<Field><FieldLabel>Rate (0-1)</FieldLabel><Input type="number" step="0.01" value={rate} onChange={(e) => setRate(Number(e.target.value))} /></Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setSlabOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={createSlab} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setSlabOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={createSlab} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function WorkShiftsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
|
||||
<p className="text-base text-muted-foreground">The baseline Attendance computes Late/Early/OT against.</p>
|
||||
@@ -122,7 +122,7 @@ export default function WorkShiftsPage() {
|
||||
<Input id="w-name" value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-start">Start time</FieldLabel>
|
||||
<Input id="w-start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
@@ -136,7 +136,7 @@ export default function WorkShiftsPage() {
|
||||
<Checkbox id="w-overnight" checked={isOvernight} onCheckedChange={(v) => setIsOvernight(v === true)} />
|
||||
<FieldLabel htmlFor="w-overnight">Overnight shift (end time rolls past midnight)</FieldLabel>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="w-grace">Grace (minutes)</FieldLabel>
|
||||
<Input id="w-grace" type="number" min={0} value={graceMinutes} onChange={(e) => setGraceMinutes(Number(e.target.value))} />
|
||||
@@ -166,9 +166,9 @@ export default function WorkShiftsPage() {
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -13,10 +13,10 @@ export default function DashboardLayout({
|
||||
<AuthProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<div className="min-w-0 flex-1 overflow-auto">
|
||||
<div className="min-w-0 px-6 pt-3 pb-6 lg:px-8 lg:pt-4 lg:pb-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-border shadow-sm">
|
||||
<div className="p-6">
|
||||
|
||||
@@ -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<Date>()
|
||||
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
|
||||
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Basic */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Product Detail</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||
</div>
|
||||
|
||||
{/* With ellipsis */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbEllipsis />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/purchase-orders">
|
||||
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||
Stock Valuation by Warehouse
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
<Button variant="success">Success</Button>
|
||||
<Button variant="warning">Warning</Button>
|
||||
<Button variant="info">Info</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="xs" className={indigoButton}>
|
||||
Extra Small
|
||||
</Button>
|
||||
<Button size="sm" className={indigoButton}>
|
||||
Small
|
||||
</Button>
|
||||
<Button size="default" className={indigoButton}>
|
||||
Default
|
||||
</Button>
|
||||
<Button size="lg" className={indigoButton}>
|
||||
Large
|
||||
</Button>
|
||||
<Button size="lg" className={cn(indigoButton, "h-12 px-8 text-base")}>
|
||||
Extra Large
|
||||
</Button>
|
||||
<Button size="icon-sm" className={indigoButton} aria-label="Add (small)">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="icon" className={indigoButton} aria-label="Add">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="icon-lg" className={indigoButton} aria-label="Add (large)">
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className={cn(indigoButton, "size-12")}
|
||||
aria-label="Add (extra large)"
|
||||
>
|
||||
<Plus className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Date Picker</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Single date</span>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder="Select a date"
|
||||
{loaded ? (
|
||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<BarChart
|
||||
labels={stats.stockValuationByWarehouse.map(
|
||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||
)}
|
||||
datasets={[
|
||||
{
|
||||
label: "Stock Value (LKR)",
|
||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||
backgroundColor: "#6366f1",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Date range</span>
|
||||
<DateRangePicker
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
placeholder="Select date range"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(date || range?.from) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{date && <>Selected: <span className="font-medium text-foreground">{date.toLocaleDateString()}</span></>}
|
||||
{range?.from && (
|
||||
<>
|
||||
{date && " · "}
|
||||
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span>
|
||||
{range.to && <> – <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p>
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} />
|
||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircle2 className="size-7 text-success" />
|
||||
</div>
|
||||
<DialogTitle className="text-lg font-bold">Order confirmed</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your order has been placed successfully and is now being processed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-center px-2 pb-2 sm:px-4">
|
||||
<Button size="lg" className="w-full sm:w-auto sm:px-10">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => toast({ title: "Default toast message" })}>
|
||||
Default
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => toast.success("Success!", "Your changes have been saved.")}>
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => toast.error("Error", "Something went wrong. Please try again.")}>
|
||||
Error
|
||||
</Button>
|
||||
<Button variant="warning" onClick={() => toast.warning("Warning", "This action cannot be undone.")}>
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="info" onClick={() => toast.info("Info", "Your session will expire in 5 minutes.")}>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
toast({
|
||||
title: "With Action",
|
||||
description: "Do you want to undo this change?",
|
||||
actionLabel: "Undo",
|
||||
onAction: () => toast.success("Undone!", "Change has been reverted."),
|
||||
})
|
||||
}
|
||||
>
|
||||
With Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="info">Info</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="info"
|
||||
title="Update available"
|
||||
description="A new version is ready. Reload the page to apply the latest changes."
|
||||
confirmLabel="Reload"
|
||||
onConfirm={() => toast.info("Reloading...", "Applying the latest update.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="success">Success</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title="Order confirmed"
|
||||
description="Your order has been placed successfully and is now being processed."
|
||||
confirmLabel="Continue"
|
||||
cancelLabel="View order"
|
||||
onConfirm={() => toast.success("Done!", "Redirecting to dashboard.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="warning">Warning</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Unsaved changes"
|
||||
description="You have unsaved changes. Leaving this page will discard them."
|
||||
confirmLabel="Leave anyway"
|
||||
onConfirm={() => toast.warning("Changes discarded")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="destructive">Delete</Button>} />
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title="Delete record?"
|
||||
description="This action is permanent and cannot be undone. All associated data will be removed."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => toast.error("Deleted", "The record has been permanently removed.")}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
Stock Movement Trend (last {TREND_DAYS} days)
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Total Revenue" value={45231.89} icon={DollarSign} />
|
||||
<StatCard label="Orders" value={1284} icon={ShoppingCart} />
|
||||
<StatCard label="Customers" value={892} icon={Users} />
|
||||
<StatCard label="Low Stock Items" value={16} icon={Package} />
|
||||
</div>
|
||||
|
||||
<RecentOrdersTable />
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3>
|
||||
<div className="h-56">
|
||||
{loaded ? (
|
||||
<div className="h-52 sm:h-64">
|
||||
<LineChart
|
||||
labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}
|
||||
datasets={[{ label: "Sales", data: [120, 200, 150, 220, 180, 260] }]}
|
||||
labels={movementTrend.labels}
|
||||
datasets={[
|
||||
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
|
||||
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||
Recent Stock Movements
|
||||
</h2>
|
||||
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
|
||||
<div className="h-56">
|
||||
<BarChart
|
||||
labels={["Q1", "Q2", "Q3", "Q4"]}
|
||||
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
|
||||
<div className="h-56">
|
||||
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
|
||||
</div>
|
||||
</div>
|
||||
{loaded ? (
|
||||
movements.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{movements.map((entry) => (
|
||||
<TableRow key={entry.ledgerId}>
|
||||
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center text-xs",
|
||||
entry.direction === "In"
|
||||
? "border-transparent bg-success/10 text-success"
|
||||
: "border-transparent bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
|
||||
)
|
||||
) : (
|
||||
!error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -267,11 +267,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
|
||||
@@ -283,7 +280,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -221,9 +221,6 @@ function NewPurchaseOrderContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PurchaseOrdersListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation and freely editable while open (FR-PROC-03..05).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import {} from "lucide-react"
|
||||
|
||||
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
@@ -156,9 +156,6 @@ function NewPurchaseReturnContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).</p>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function PurchaseReturnsListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
|
||||
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react"
|
||||
import { FileText, Send, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -72,11 +72,8 @@ export default function RequisitionDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
|
||||
@@ -86,7 +83,7 @@ export default function RequisitionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{requisition.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleSubmit} disabled={submitting}>
|
||||
<Send className="size-5" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -103,9 +103,6 @@ export default function NewRequisitionPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1>
|
||||
<p className="text-base text-muted-foreground">Request items for procurement; submit once the lines are ready (FR-PROC-01).</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function RequisitionsListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Requisitions</h1>
|
||||
<p className="text-base text-muted-foreground">Raise a purchase requisition and submit it into procurement (FR-PROC-01).</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ShoppingCart } from "lucide-react"
|
||||
import { ShoppingCart } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -158,9 +158,6 @@ export default function RfqDetailPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { rfqsApi } from "@/lib/api/rfqs"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -149,9 +149,6 @@ function NewRfqContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function RfqsListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">RFQs</h1>
|
||||
<p className="text-base text-muted-foreground">Request quotations from vendors and compare pricing (FR-PROC-02).</p>
|
||||
|
||||
@@ -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<RunStatus>(run?.status ?? "InProgress")
|
||||
const [completedAt, setCompletedAt] = useState<string | null>(run?.completedAt ?? null)
|
||||
const [stages, setStages] = useState<RunStagePlanItem[]>(() =>
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<p className="text-base text-muted-foreground">Run not found.</p>
|
||||
<Button variant="outline" onClick={() => router.push("/dashboard/production/runs")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to runs
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-fit px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push("/dashboard/production/runs")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to runs
|
||||
</Button>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-lg font-bold text-foreground">{run.docNo}</span>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(status))}>
|
||||
{status === "InProgress" ? "In Progress" : status}
|
||||
</Badge>
|
||||
{run.reworkCount > 0 && (
|
||||
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning">
|
||||
<RotateCcw className="size-3.5" />
|
||||
Rework #{run.reworkCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{run.templateName} · {run.warehouseName}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col text-left sm:text-right">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{run.targetQty.toLocaleString()} {run.uom} · {run.finishedItemName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Created {new Date(run.createdAt).toLocaleDateString()}
|
||||
{completedAt && <> · Completed {new Date(completedAt).toLocaleDateString()}</>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StageProgressStrip status={status} summary={stageSummary} className="mt-4" />
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{progressPercent}% complete
|
||||
{activeStage && <span className="text-muted-foreground"> · Current: {activeStage.name}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${progressPercent}%`, backgroundColor: STAGE_STATUS_COLOR.Approved }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={giveProgress} disabled={!canGiveProgress} className="w-full shrink-0 sm:w-auto">
|
||||
{activeStage ? `Give Progress — ${activeStage.name}` : "All stages approved"}
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
<div className="h-[45vh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<Skeleton className="size-full rounded-none" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ProductionRun[]>(INITIAL_RUNS)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [template, setTemplate] = useState<NameFilter>("All")
|
||||
const [warehouse, setWarehouse] = useState<NameFilter>("All")
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [startTemplateId, setStartTemplateId] = useState<number | null>(null)
|
||||
const [targetQty, setTargetQty] = useState("")
|
||||
const [startWarehouse, setStartWarehouse] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
|
||||
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Start a run</DialogTitle>
|
||||
<DialogDescription>Fine-tune per-stage quantities afterward on the run itself.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!formError && !startTemplate}>
|
||||
<FieldLabel>Template</FieldLabel>
|
||||
<Select<number> value={startTemplateId ?? null} onValueChange={(v) => setStartTemplateId(v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STARTABLE_TEMPLATES.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!formError && !(targetQtyNum > 0)}>
|
||||
<FieldLabel htmlFor="target-qty">
|
||||
Target quantity{startTemplate && <span className="font-normal text-muted-foreground"> ({startTemplate.uom}, {startTemplate.finishedItemName})</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="target-qty"
|
||||
type="number"
|
||||
min={0}
|
||||
value={targetQty}
|
||||
onChange={(e) => setTargetQty(e.target.value)}
|
||||
placeholder="e.g. 200"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!formError && !startWarehouse}>
|
||||
<FieldLabel>Warehouse</FieldLabel>
|
||||
<Select<string> value={startWarehouse} onValueChange={setStartWarehouse}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="Pick a warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WAREHOUSE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="output-bin">Output bin (optional)</FieldLabel>
|
||||
<Input id="output-bin" value={outputBin} onChange={(e) => setOutputBin(e.target.value)} placeholder="e.g. BIN-04" />
|
||||
</Field>
|
||||
|
||||
{scaleFactor !== null && (
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
Scale factor <span className="font-semibold text-foreground">{scaleFactor.toFixed(2)}×</span> — 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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldError errors={[formError ? { message: formError } : undefined]} />
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleStartRun} disabled={submitting}>
|
||||
{submitting ? "Starting…" : "Start run"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search runs"
|
||||
/>
|
||||
</div>
|
||||
<Select<NameFilter> value={template} onValueChange={(v) => setTemplate(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All templates</SelectItem>
|
||||
{TEMPLATE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<NameFilter> value={warehouse} onValueChange={(v) => setWarehouse(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
|
||||
{WAREHOUSE_NAMES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-base">{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10">
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PlayCircle className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No runs match your search/filter." : "No runs yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{filtered.map((r) => (
|
||||
<button
|
||||
key={r.runId}
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${r.runId}`)}
|
||||
className="w-full rounded-2xl bg-card p-4 text-left shadow-sm ring-1 ring-foreground/10 transition-colors hover:ring-primary/40 sm:p-5"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{r.docNo}</span>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(r.status))}>
|
||||
{r.status === "InProgress" ? "In Progress" : r.status}
|
||||
</Badge>
|
||||
{r.reworkCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 w-fit justify-center gap-1 border-transparent bg-warning/10 px-2.5 text-sm text-warning"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Rework #{r.reworkCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{r.templateName} · {r.warehouseName}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2 sm:items-center">
|
||||
<div className="flex flex-col text-left sm:text-right">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{r.targetQty.toLocaleString()} {r.uom} · {r.finishedItemName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Created {new Date(r.createdAt).toLocaleDateString()}
|
||||
{r.completedAt && <> · Completed {new Date(r.completedAt).toLocaleDateString()}</>}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="hidden size-5 shrink-0 text-muted-foreground sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StageProgressStrip status={r.status} summary={r.stageSummary} className="mt-4" />
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{buildStagePlan(r.templateName, r.stageSummary).map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted/50 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||
>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[s.state] }} />
|
||||
{s.name}
|
||||
<span className="text-muted-foreground">· {STAGE_STATUS_LABEL[s.state]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete"
|
||||
title="Delete"
|
||||
className={`nodrag flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90 ${className ?? ""}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.()
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="relative size-full rounded-xl border-2 border-dashed border-muted-foreground/30 bg-muted/40">
|
||||
<NodeResizer isVisible={selected} minWidth={160} minHeight={100} lineClassName="!border-primary" handleClassName="!size-2.5 !border-primary !bg-card" />
|
||||
{selected && data.onDelete && <DeleteHandle onDelete={data.onDelete} className="absolute -top-2.5 -right-2.5" />}
|
||||
<input
|
||||
defaultValue={data.label}
|
||||
disabled={!data.onLabelChange}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement>(null)
|
||||
const rotation = data.rotation ?? 0
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative size-full">
|
||||
<div className="relative size-full" style={{ transform: `rotate(${rotation}deg)` }}>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={80}
|
||||
minHeight={4}
|
||||
maxHeight={4}
|
||||
lineClassName="!border-primary"
|
||||
handleClassName="!size-2.5 !border-primary !bg-card"
|
||||
/>
|
||||
|
||||
{selected && (data.onRotationChange || data.onDelete) && (
|
||||
<div className="absolute -top-7 left-1/2 flex -translate-x-1/2 items-center gap-1.5">
|
||||
{data.onRotationChange && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Rotate line"
|
||||
title="Drag to rotate"
|
||||
className="nodrag flex size-5 cursor-grab items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm active:cursor-grabbing"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
const handle = e.currentTarget
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
// atan2 is 0° pointing right; +90 so "handle straight up" reads as 0° rotation.
|
||||
const angle = Math.atan2(ev.clientY - cy, ev.clientX - cx) * (180 / Math.PI) + 90
|
||||
data.onRotationChange?.(Math.round(angle))
|
||||
}
|
||||
const onUp = () => {
|
||||
handle.releasePointerCapture(e.pointerId)
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
}}
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{data.onDelete && <DeleteHandle onDelete={data.onDelete} className="static" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex size-full flex-col items-center justify-center">
|
||||
<div className="h-0.5 w-full rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
defaultValue={data.label}
|
||||
disabled={!data.onLabelChange}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BoxNode)
|
||||
export const LineNodeComponent = memo(LineNode)
|
||||
@@ -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<StageNodeData>) => void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
function updateInput(inputId: string, patch: Partial<FormulaInput>) {
|
||||
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<FormulaOutput>) {
|
||||
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<FieldDef>) {
|
||||
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 (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.inputId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<InputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && updateInput(input.inputId, { source: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.inputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{input.source === "Stock" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select<number>
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={input.qty ?? 0}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateInput(input.inputId, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Qty"
|
||||
/>
|
||||
<span className="w-14 shrink-0 text-sm text-muted-foreground">{input.uom ?? "—"}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={input.batch ?? ""}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateInput(input.inputId, { batch: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Batch (optional)"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.upstreamOutputId ?? null}
|
||||
onValueChange={(v) => {
|
||||
const opt = upstreamOptions.find((o) => o.outputId === v)
|
||||
updateInput(input.inputId, { upstreamOutputId: v ?? undefined, upstreamStageId: opt?.stageId })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputId} value={o.outputId} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.outputId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number>
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">{i.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { name: e.target.value })}
|
||||
placeholder="Output name"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.outputId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={output.qty}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-8 text-sm"
|
||||
placeholder="Qty"
|
||||
/>
|
||||
<Input
|
||||
value={output.uom}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.outputId, { uom: e.target.value })}
|
||||
className="h-8 w-20 text-sm"
|
||||
placeholder="UOM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.fieldId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.fieldId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.fieldId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<FieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.fieldId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.fieldId, { required: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { newId }
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-56 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
|
||||
selected ? "ring-primary" : "ring-foreground/10",
|
||||
disconnected && "opacity-40"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
|
||||
{selected && data.onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete stage"
|
||||
title="Delete stage"
|
||||
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDelete?.()
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name || "Untitled stage"}</p>
|
||||
{data.roleLabel && (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{data.roleLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-muted-foreground">{data.estimatedMinutes} min</p>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{data.inputs.length} in</span>
|
||||
<ArrowRight className="size-3" />
|
||||
<span>{data.outputs.length} out</span>
|
||||
</div>
|
||||
|
||||
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StageNode)
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useSearchParams } from "next/navigation"
|
||||
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, Lock, Minus, Plus, Save, Square } from "lucide-react"
|
||||
|
||||
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
|
||||
import { Button } 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<Node[]>(initial.nodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initial.edges)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<StageNodeData> | Partial<AnnotationData>) {
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{template.name}</h1>
|
||||
<p className="text-base text-muted-foreground">{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection{edges.length === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addStage}>
|
||||
<Plus className="size-5" />
|
||||
Add Stage
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addBox}>
|
||||
<Square className="size-5" />
|
||||
Add Box
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" variant="outline" onClick={addLine}>
|
||||
<Minus className="size-5" />
|
||||
Add Line
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" onClick={handleSave}>
|
||||
<Save className="size-5" />
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||
<Lock className="size-4 shrink-0" />
|
||||
Template locked — {template.activeRunCount} run{template.activeRunCount === 1 ? "" : "s"} in progress.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
nodeId={selectedNode.id}
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={MOCK_ITEMS}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
label: string
|
||||
/** Degrees, applied as a CSS rotation around the node's own center. Lines only (§ AnnotationNodes). */
|
||||
rotation?: number
|
||||
}
|
||||
|
||||
@@ -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<ProductionTemplate[]>(INITIAL_TEMPLATES)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
|
||||
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New template</DialogTitle>
|
||||
<DialogDescription>Give the template a name — you'll build its stage graph next.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!error}>
|
||||
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="tpl-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Aluminium Frame Assembly"
|
||||
aria-invalid={!!error}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create & open builder"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search templates…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<LayoutTemplate className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No templates match your search/filter." : "No templates yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<Skeleton className="size-full rounded-none" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -181,11 +181,8 @@ export default function ItemDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
|
||||
@@ -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"
|
||||
@@ -155,15 +155,10 @@ export default function BrandsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -180,11 +175,11 @@ export default function BrandsPage() {
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -239,21 +234,21 @@ export default function BrandsPage() {
|
||||
{!error && brands !== null && brands.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -364,7 +359,7 @@ function SortableHeader({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
@@ -376,7 +371,7 @@ function SortableHeader({
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
|
||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
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"
|
||||
@@ -111,11 +109,8 @@ export default function CategorySubCategoriesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -138,11 +133,11 @@ export default function CategorySubCategoriesPage() {
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -171,13 +166,13 @@ export default function CategorySubCategoriesPage() {
|
||||
|
||||
{!error && subCategories !== null && subCategories.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -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"
|
||||
@@ -154,15 +154,10 @@ export default function CategoriesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -179,11 +174,11 @@ export default function CategoriesPage() {
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -238,21 +233,21 @@ export default function CategoriesPage() {
|
||||
{!error && categories !== null && categories.length > 0 && (
|
||||
<>
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -371,7 +366,7 @@ function SortableHeader({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-indigo-900"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
@@ -383,7 +378,7 @@ function SortableHeader({
|
||||
<ArrowDown className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
|
||||
import { Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
|
||||
|
||||
import { itemTypesApi } from "@/lib/api/item-types"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateItemTypeName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemType } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -108,11 +106,8 @@ export default function ItemTypesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -141,11 +136,11 @@ export default function ItemTypesPage() {
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -174,13 +169,13 @@ export default function ItemTypesPage() {
|
||||
|
||||
{!error && itemTypes !== null && itemTypes.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||
import { Plus, X } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
@@ -254,9 +254,6 @@ export default function NewItemPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
||||
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
|
||||
@@ -532,14 +529,14 @@ export default function NewItemPage() {
|
||||
{variants.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader className="bg-indigo-50">
|
||||
<TableRow className="hover:bg-indigo-50">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{activeCategories.map((cat) => (
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
|
||||
{priceMode === "fixed" && (
|
||||
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
|
||||
)}
|
||||
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
|
||||
Item contract and no initial-receipt flow — stock arrives via a GRN.
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function ItemsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||
<p className="text-base text-muted-foreground">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Package } from "lucide-react"
|
||||
import { Package } from "lucide-react"
|
||||
|
||||
import { productConfigApi } from "@/lib/api/product-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ProductConfig } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -70,9 +67,6 @@ export default function ProductSettingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Ruler } from "lucide-react"
|
||||
import { Plus, Ruler } from "lucide-react"
|
||||
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateUomName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Uom } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -57,11 +55,8 @@ export default function UomsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
|
||||
@@ -82,11 +77,11 @@ export default function UomsPage() {
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -116,11 +116,8 @@ export default function GrnDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
@@ -303,9 +303,6 @@ export default function NewGrnPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
|
||||
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
|
||||
@@ -400,20 +397,7 @@ export default function NewGrnPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lines default from the PO's open quantities — add a row for anything received that wasn't ordered.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{mode === "po" && (
|
||||
@@ -422,7 +406,7 @@ export default function NewGrnPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
|
||||
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
@@ -459,7 +443,7 @@ export default function NewGrnPage() {
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
@@ -467,7 +451,7 @@ export default function NewGrnPage() {
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function GrnListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link"
|
||||
import { ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Roles",
|
||||
href: "/dashboard/settings/roles",
|
||||
icon: ShieldCheck,
|
||||
description: "Manage roles and navigation permissions",
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
href: "/dashboard/settings/users",
|
||||
icon: Users,
|
||||
description: "Create users and assign their roles",
|
||||
},
|
||||
{
|
||||
title: "Product Configuration",
|
||||
href: "/dashboard/products/settings",
|
||||
icon: SlidersHorizontal,
|
||||
description: "Configure product and master-data options",
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">ERP Settings</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Manage ERP access, users, and system configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="flex items-center gap-4 rounded-2xl border bg-card p-5 shadow-sm ring-1 ring-foreground/5 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<card.icon className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{card.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{card.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
@@ -132,9 +132,6 @@ export default function RoleDetailPage() {
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function RolesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -182,11 +182,11 @@ export default function RolesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
import { Save } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -83,9 +83,6 @@ export default function UserDetailPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
@@ -242,11 +242,11 @@ export default function UsersPage() {
|
||||
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react"
|
||||
import { CheckCircle2, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -123,9 +123,6 @@ export default function NewAdjustmentPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately on creation (FR-STK-07) — a reason code is mandatory.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react"
|
||||
import { Plus, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -37,11 +37,8 @@ export default function AdjustmentsListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
|
||||
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostCountResponse, StockCount } from "@/types/stock"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -89,11 +88,8 @@ export default function CountDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import {} from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -82,9 +82,6 @@ export default function NewCountPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Count</h1>
|
||||
<p className="text-base text-muted-foreground">System quantities are snapshotted immediately; enter counted quantities next.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ClipboardList, Plus } from "lucide-react"
|
||||
import { ClipboardList, Plus } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -34,11 +34,8 @@ export default function CountsListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1>
|
||||
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, PackageSearch, Search } from "lucide-react"
|
||||
import { PackageSearch, Search } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { OnHand } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -59,9 +57,6 @@ export default function StockEnquiryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1>
|
||||
<p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -13,7 +12,7 @@ import { LedgerEntry } from "@/types/stock"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -81,9 +80,6 @@ export default function StockLedgerPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1>
|
||||
<p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReorderAlert } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -59,9 +57,6 @@ export default function ReorderAlertsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1>
|
||||
<p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
|
||||
import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -83,11 +81,8 @@ export default function TransferDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -138,9 +138,6 @@ export default function NewTransferPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1>
|
||||
<p className="text-base text-muted-foreground">Create a transfer, then dispatch and receive it (FR-STK-05).</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react"
|
||||
import { ArrowLeftRight, Plus } from "lucide-react"
|
||||
|
||||
import { stockTransfersApi } from "@/lib/api/stock-transfers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -34,11 +34,8 @@ export default function TransfersListPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1>
|
||||
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
|
||||
import { BadgeDollarSign } from "lucide-react"
|
||||
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Valuation } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -65,9 +62,6 @@ function ValuationContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Valuation</h1>
|
||||
<p className="text-base text-muted-foreground">FIFO cost-layer breakdown and total stock value (FR-STK-04).</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
import { AlertOctagon, CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
@@ -99,9 +99,6 @@ export default function NewWastagePage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
|
||||
import { AlertOctagon, Plus } from "lucide-react"
|
||||
|
||||
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
@@ -66,11 +66,8 @@ export default function WastagePage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
|
||||
+2
-5
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
@@ -130,11 +130,8 @@ export default function VendorDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{vendor.code}</h1>
|
||||
|
||||
+5
-5
@@ -127,7 +127,7 @@ export default function VendorsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Vendors</h1>
|
||||
<p className="text-base text-muted-foreground">Supplier master data — code, terms, tax registration, currency (FR-MD-06).</p>
|
||||
@@ -172,11 +172,11 @@ export default function VendorsPage() {
|
||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -303,7 +303,7 @@ export default function VendorsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center pt-2">
|
||||
<DialogClose render={<Button variant="outline" className="min-w-36" />}>Close</DialogClose>
|
||||
<DialogClose render={<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" />}>Close</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, MapPinned, Plus } from "lucide-react"
|
||||
import { MapPinned, Plus } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
@@ -88,11 +86,8 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/warehouse" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
|
||||
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
|
||||
@@ -124,11 +119,11 @@ export default function WarehouseDetailPage() {
|
||||
<Input id="bin-type" value={binType} onChange={(e) => setBinType(e.target.value)} placeholder="Shelf, Pallet, …" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
|
||||
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Bin, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
@@ -98,11 +97,8 @@ export default function WarehousesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
|
||||
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
|
||||
@@ -134,11 +130,11 @@ export default function WarehousesPage() {
|
||||
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is(.dark *, .vibrant *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -129,6 +129,69 @@
|
||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
||||
}
|
||||
|
||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
||||
cards, header, table panels) paired with a dark sidebar — the same split
|
||||
Linear/Vercel/Notion use in their light themes. One violet accent drives
|
||||
every interactive state; the sidebar keeps its own dark token family
|
||||
(applied via .sidebar-surface below) so it stays dark regardless. */
|
||||
.vibrant {
|
||||
--background: oklch(0.97 0.004 265);
|
||||
--foreground: oklch(0.2 0.02 265);
|
||||
--card: oklch(0.995 0.002 265);
|
||||
--card-foreground: oklch(0.2 0.02 265);
|
||||
--popover: oklch(0.995 0.002 265);
|
||||
--popover-foreground: oklch(0.2 0.02 265);
|
||||
--primary: oklch(0.55 0.2 275);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.93 0.02 275);
|
||||
--secondary-foreground: oklch(0.32 0.15 275);
|
||||
--muted: oklch(0.94 0.006 265);
|
||||
--muted-foreground: oklch(0.48 0.02 265);
|
||||
--accent: oklch(0.55 0.14 210);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.58 0.22 25);
|
||||
--border: oklch(0.88 0.012 265);
|
||||
--input: oklch(0.92 0.01 265);
|
||||
--ring: oklch(0.55 0.2 275);
|
||||
--success: oklch(0.55 0.15 150);
|
||||
--warning: oklch(0.72 0.15 80);
|
||||
--error: oklch(0.58 0.22 25);
|
||||
--info: oklch(0.55 0.14 210);
|
||||
--chart-1: oklch(0.55 0.2 275);
|
||||
--chart-2: oklch(0.55 0.14 210);
|
||||
--chart-3: oklch(0.55 0.15 150);
|
||||
--chart-4: oklch(0.72 0.15 80);
|
||||
--chart-5: oklch(0.58 0.22 25);
|
||||
--sidebar: oklch(0.18 0.02 265);
|
||||
--sidebar-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-primary: oklch(0.64 0.19 275);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.28 0.03 265);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
||||
--sidebar-border: oklch(0.26 0.025 265);
|
||||
--sidebar-ring: oklch(0.64 0.19 275);
|
||||
}
|
||||
|
||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||
at the sidebar's own dark family for anything inside this scope, so
|
||||
AppSidebar's existing bg-card/text-foreground/text-muted-foreground
|
||||
classes render dark without AppSidebar.tsx needing sidebar-specific
|
||||
classes. CSS custom properties resolve against the cascade at point of
|
||||
use, so this indirection (the same trick .dark/.vibrant use at the root)
|
||||
works scoped to just this subtree. */
|
||||
.vibrant .sidebar-surface {
|
||||
--card: var(--sidebar);
|
||||
--card-foreground: var(--sidebar-foreground);
|
||||
--popover: var(--sidebar);
|
||||
--popover-foreground: var(--sidebar-foreground);
|
||||
--foreground: var(--sidebar-foreground);
|
||||
--muted: var(--sidebar-accent);
|
||||
--muted-foreground: oklch(0.72 0.015 265);
|
||||
--primary: var(--sidebar-primary);
|
||||
--primary-foreground: var(--sidebar-primary-foreground);
|
||||
--border: var(--sidebar-border);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -154,4 +217,5 @@
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark", "vibrant"]}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -25,10 +25,10 @@ export default function ForgotOtpPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-card p-8 shadow-lg border border-gray-200">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-card p-8 shadow-lg border border-border">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="bg-black/10 rounded-full p-4 mb-4">
|
||||
<Mail className="text-black size-6" />
|
||||
<div className="bg-foreground/10 rounded-full p-4 mb-4">
|
||||
<Mail className="text-foreground size-6" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ export default function ForgotOtpPage() {
|
||||
e.preventDefault()
|
||||
handleResend()
|
||||
}}
|
||||
className="text-sm font-medium text-black hover:text-black/80 transition-colors"
|
||||
className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
|
||||
>
|
||||
Didn't receive the code? Resend
|
||||
</button>
|
||||
@@ -57,10 +57,10 @@ export default function ForgotOtpPage() {
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href="/login" className="flex-1">
|
||||
<Button variant="outline" className="w-full h-12 rounded-full border-gray-300 text-black hover:bg-black/5">Back</Button>
|
||||
<Button variant="outline" className="w-full h-12 rounded-full border-input text-foreground hover:bg-foreground/5">Back</Button>
|
||||
</Link>
|
||||
<Link href="/login/forgot/reset" className="flex-1">
|
||||
<Button className="w-full h-12 rounded-full bg-black text-white hover:bg-black/90">Verify</Button>
|
||||
<Button className="w-full h-12 rounded-full bg-foreground text-background hover:bg-foreground/90">Verify</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ForgotEmailPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-gray-200">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p>
|
||||
@@ -40,19 +40,28 @@ export default function ForgotEmailPage() {
|
||||
{emailError !== "" && <p className="text-sm text-error mt-2 font-medium">{emailError}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-12 text-base font-medium rounded-full bg-black text-white hover:bg-black/90"
|
||||
disabled={emailError !== ""}
|
||||
onClick={() => {
|
||||
if (email.length === 0) {
|
||||
setEmailError('Email is required')
|
||||
return
|
||||
}
|
||||
if (emailError === "") router.push('/login/forgot/otp')
|
||||
}}
|
||||
>
|
||||
Send verification code
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/login')}
|
||||
className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 text-base font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
disabled={emailError !== ""}
|
||||
onClick={() => {
|
||||
if (email.length === 0) {
|
||||
setEmailError('Email is required')
|
||||
return
|
||||
}
|
||||
if (emailError === "") router.push('/login/forgot/otp')
|
||||
}}
|
||||
>
|
||||
Send verification code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ForgotResetPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
|
||||
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-gray-200">
|
||||
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-foreground">Create a new password</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Choose a strong password to secure your account.</p>
|
||||
@@ -103,15 +103,15 @@ export default function ForgotResetPage() {
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 h-12 rounded-full border-gray-300 text-black hover:bg-black/5"
|
||||
onClick={() => router.push('/login')}
|
||||
className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!allValid}
|
||||
onClick={() => setShowSuccess(true)}
|
||||
className="flex-1 h-12 rounded-full bg-black text-white hover:bg-black/90"
|
||||
className="flex-1 h-12 rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
@@ -131,7 +131,7 @@ export default function ForgotResetPage() {
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<Button onClick={() => router.push('/login')} className="w-4/5 mx-auto h-12 mt-6 rounded-full bg-black text-white hover:bg-black/90">
|
||||
<Button onClick={() => router.push('/login')} className="w-4/5 mx-auto h-12 mt-6 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
Return to sign in
|
||||
</Button>
|
||||
</DialogContent>
|
||||
|
||||
@@ -17,29 +17,6 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M23.52 12.27c0-.85-.08-1.67-.22-2.45H12v4.64h6.47a5.53 5.53 0 0 1-2.4 3.63v3h3.89c2.27-2.09 3.56-5.17 3.56-8.82Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 24c3.24 0 5.95-1.07 7.93-2.91l-3.89-3c-1.08.73-2.46 1.15-4.04 1.15-3.1 0-5.73-2.09-6.67-4.9H1.32v3.09A12 12 0 0 0 12 24Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.33 14.34a7.2 7.2 0 0 1 0-4.68V6.57H1.32a12 12 0 0 0 0 10.86l4.01-3.09Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 4.77c1.76 0 3.35.6 4.6 1.79l3.45-3.45C17.94 1.19 15.23 0 12 0A12 12 0 0 0 1.32 6.57l4.01 3.09C6.27 6.86 8.9 4.77 12 4.77Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
@@ -77,19 +54,28 @@ function LoginForm() {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden">
|
||||
{/* Full-page background photo — right side only */}
|
||||
<div className="absolute inset-y-0 left-[45%] right-0">
|
||||
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
|
||||
cutout (whithoutbackground.png) so there's no white scene box floating on the
|
||||
dark panel. */}
|
||||
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
|
||||
<Image
|
||||
src="/images/loginerps.png"
|
||||
alt="ERP Illustration"
|
||||
fill
|
||||
className="object-contain object-center bg-white"
|
||||
className="object-contain object-center dark:hidden"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/images/whithoutbackground.png"
|
||||
alt="ERP Illustration"
|
||||
fill
|
||||
className="hidden object-contain object-center dark:block"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Login panel — overlays left side, no hard border with the photo */}
|
||||
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white px-6 py-12 sm:px-12 md:w-[45%]">
|
||||
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<Image src="/images/logo%20(2).png" alt="Hexa ERP" width={300} height={150} />
|
||||
@@ -153,7 +139,7 @@ function LoginForm() {
|
||||
</div>
|
||||
<FieldError errors={[form.formState.errors.password]} />
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link href="/login/forgot" className="text-sm font-medium text-black hover:text-black/70 transition-colors">
|
||||
<Link href="/login/forgot" className="text-sm font-medium text-foreground hover:text-foreground/70 transition-colors">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
@@ -168,7 +154,7 @@ function LoginForm() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-black text-white hover:bg-black/90"
|
||||
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
|
||||
@@ -181,27 +167,9 @@ function LoginForm() {
|
||||
{Object.values(form.formState.errors).length > 0 && "There are errors in the form."}
|
||||
</div>
|
||||
|
||||
<div className="my-8 flex items-center gap-4">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-sm text-muted-foreground font-medium">Or continue with</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-medium inline-flex items-center justify-center gap-3 rounded-full bg-transparent text-foreground border border-gray-300 hover:bg-black/5"
|
||||
onClick={() => {
|
||||
alert('Google OAuth flow placeholder')
|
||||
}}
|
||||
aria-label="Continue with Google"
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account? <Link href="#" className="font-semibold text-black hover:text-black/80 transition-colors">Create an account</Link>
|
||||
Don't have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -11,16 +11,19 @@ import {
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
Factory,
|
||||
FileBarChart,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
ListTree,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
PlayCircle,
|
||||
Ruler,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -47,6 +50,9 @@ const navItems: {
|
||||
/** Where clicking the row actually navigates, if different from `href`. `href` itself
|
||||
* stays the section prefix used to decide whether this row is "active". */
|
||||
landingHref?: string
|
||||
/** This item's own href has no page of its own (no landingHref either) — clicking the
|
||||
* row should only expand/collapse its children, never navigate. */
|
||||
expandOnly?: boolean
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||
@@ -86,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",
|
||||
@@ -106,6 +124,7 @@ const navItems: {
|
||||
title: "Settings",
|
||||
code: "settings",
|
||||
href: "/dashboard/settings",
|
||||
expandOnly: true,
|
||||
icon: Settings,
|
||||
chevron: true,
|
||||
children: [
|
||||
@@ -139,9 +158,13 @@ function SidebarContent({
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const parent = items.find((i) =>
|
||||
i.children?.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
|
||||
)
|
||||
const parent = items.find((i) => {
|
||||
if (!i.children?.length) return false
|
||||
if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true
|
||||
// Landing on the parent's own hub page (e.g. /dashboard/settings itself,
|
||||
// not one of its children) should also reveal its sub-items.
|
||||
return pathname === i.href || pathname.startsWith(`${i.href}/`)
|
||||
})
|
||||
if (parent) {
|
||||
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
|
||||
}
|
||||
@@ -153,7 +176,7 @@ function SidebarContent({
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
||||
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||
)}
|
||||
>
|
||||
@@ -187,7 +210,7 @@ function SidebarContent({
|
||||
|
||||
{/* Nav items — scrolls internally when it overflows, without a visible
|
||||
scrollbar so the rounded panel stays clean. */}
|
||||
<ul className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ul className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
|
||||
@@ -205,15 +228,18 @@ function SidebarContent({
|
||||
<Link
|
||||
href={item.landingHref ?? item.href}
|
||||
title={iconOnly ? item.title : undefined}
|
||||
onClick={onClose}
|
||||
onClick={() => {
|
||||
onClose?.()
|
||||
if (hasChildren) setExpanded((prev) => (prev[item.code] ? prev : { ...prev, [item.code]: true }))
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
|
||||
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
|
||||
iconOnly && "justify-center px-0",
|
||||
isActive ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
{!iconOnly && (
|
||||
<>
|
||||
@@ -270,14 +296,14 @@ function SidebarContent({
|
||||
onClick={onClose}
|
||||
tabIndex={isOpen ? undefined : -1}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center gap-2.5 rounded-xl px-3 py-1 text-sm font-medium transition-colors",
|
||||
childActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<child.icon
|
||||
className={cn("size-4 shrink-0", childActive ? "text-primary" : "text-muted-foreground")}
|
||||
className={cn("size-3 shrink-0", childActive ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
{child.title}
|
||||
</Link>
|
||||
@@ -322,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
|
||||
@@ -362,9 +388,9 @@ export function AppSidebar() {
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open menu"
|
||||
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
className="fixed top-8 left-5 z-40 flex size-8 items-center justify-center rounded-xl bg-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<Menu className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* ── Mobile overlay backdrop ──────────────────────── */}
|
||||
|
||||
@@ -35,6 +35,9 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
rfqs: "RFQs",
|
||||
"purchase-orders": "Purchase Orders",
|
||||
"purchase-returns": "Purchase Returns",
|
||||
production: "Production",
|
||||
templates: "Templates",
|
||||
runs: "Runs",
|
||||
}
|
||||
|
||||
function labelFor(segment: string): string {
|
||||
|
||||
@@ -164,8 +164,8 @@ export function Header() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-4 pl-14 shadow-sm ring-1 ring-foreground/10 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -176,10 +176,10 @@ export function Header() {
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
|
||||
<h1 className="min-w-0 truncate text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
|
||||
<Popover>
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 (
|
||||
<div className="flex w-56 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
data.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
{data.activeRunCount > 0 && (
|
||||
<span className="w-fit rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
{data.activeRunCount} in progress
|
||||
</span>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface LineStageData extends Record<string, unknown> {
|
||||
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 (
|
||||
<div className="w-36 rounded-xl bg-primary/10 px-3 py-2.5 text-center shadow-sm ring-1 ring-primary/20">
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
<p className="truncate text-sm font-semibold text-primary">{data.name}</p>
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LineHeaderNodeComponent = memo(LineHeaderNode)
|
||||
export const LineStageNodeComponent = memo(LineStageNode)
|
||||
@@ -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<string, unknown> {
|
||||
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 (
|
||||
<div className="flex w-48 flex-col gap-1.5 rounded-2xl bg-card p-3 shadow-sm ring-1 ring-foreground/10">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{data.docNo}</span>
|
||||
<p className="truncate text-sm font-bold text-foreground">{data.templateName}</p>
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RunStageData extends Record<string, unknown> {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"w-44 rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all",
|
||||
data.isActive ? "ring-offset-2 ring-offset-background" : "ring-foreground/10"
|
||||
)}
|
||||
style={data.isActive ? { boxShadow: `0 0 0 2px ${color}` } : undefined}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<p className="min-w-0 truncate text-sm font-bold text-foreground">{data.name}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium" style={{ color }}>{STAGE_STATUS_LABEL[data.state]}</p>
|
||||
|
||||
{data.onAdvance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onAdvance?.()
|
||||
}}
|
||||
className="nodrag mt-2 flex w-full items-center justify-center gap-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Advance
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RunHeaderNodeComponent = memo(RunHeaderNode)
|
||||
export const RunStageNodeComponent = memo(RunStageNode)
|
||||
@@ -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 (
|
||||
<div className={cn("flex h-2.5 items-center gap-1.5 rounded-full", className)}>
|
||||
<div className="h-2.5 flex-1 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
|
||||
<CheckCircle2 className="size-4 shrink-0" style={{ color: RUN_COMPLETED_COLOR }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {
|
||||
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 (
|
||||
<div
|
||||
className={cn("flex h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||
style={status === "Cancelled" ? { boxShadow: `0 0 0 2px ${RUN_CANCELLED_COLOR}` } : undefined}
|
||||
>
|
||||
{total === 0
|
||||
? null
|
||||
: STAGE_STATUS_ORDER.map((key) => {
|
||||
const count = counts[key]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
title={`${STAGE_STATUS_LABEL[key]}: ${count}`}
|
||||
style={{ backgroundColor: STAGE_STATUS_COLOR[key], width: `${(count / total) * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StageStatusLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5", className)}>
|
||||
{STAGE_STATUS_ORDER.map((key) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: STAGE_STATUS_COLOR[key] }} />
|
||||
{STAGE_STATUS_LABEL[key]}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_CANCELLED_COLOR }} />
|
||||
Cancelled
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: RUN_COMPLETED_COLOR }} />
|
||||
Completed
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const options = [
|
||||
{ value: "light", label: "Light mode", icon: Sun },
|
||||
{ value: "vibrant", label: "Vibrant theme", icon: Monitor },
|
||||
{ value: "dark", label: "Dark mode", icon: Moon },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Renders an empty slot until mounted: `resolvedTheme` is unknown on the server (and on
|
||||
* the client's first paint, before next-themes reads localStorage), so rendering an icon
|
||||
* before that would either be wrong or cause a hydration mismatch.
|
||||
* Renders an empty slot until mounted: `theme` is unknown on the server (and on the
|
||||
* client's first paint, before next-themes reads localStorage), so rendering the active
|
||||
* segment before that would either be wrong or cause a hydration mismatch.
|
||||
*/
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return <div className={cn("size-10 shrink-0", className)} aria-hidden="true" />
|
||||
return <div className={cn("h-10 w-27 shrink-0", className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
const isDark = resolvedTheme === "dark"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
className={cn("flex shrink-0 items-center gap-0.5 rounded-full bg-muted p-1", className)}
|
||||
>
|
||||
{isDark ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
||||
</button>
|
||||
{options.map(({ value, label, icon: Icon }) => {
|
||||
const isActive = theme === value
|
||||
const isVibrant = value === "vibrant"
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
isActive
|
||||
? isVibrant
|
||||
? "bg-linear-to-br from-indigo-500 to-violet-600 text-white shadow-sm"
|
||||
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ const variantConfig: Record<
|
||||
> = {
|
||||
info: {
|
||||
icon: InfoIcon,
|
||||
iconClass: "text-sky-600 bg-sky-50",
|
||||
ringClass: "ring-sky-100",
|
||||
iconClass: "text-info bg-info/10",
|
||||
ringClass: "ring-info/20",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangleIcon,
|
||||
iconClass: "text-amber-600 bg-amber-50",
|
||||
ringClass: "ring-amber-100",
|
||||
iconClass: "text-warning bg-warning/10",
|
||||
ringClass: "ring-warning/20",
|
||||
},
|
||||
destructive: {
|
||||
icon: XCircleIcon,
|
||||
iconClass: "text-red-600 bg-red-50",
|
||||
ringClass: "ring-red-100",
|
||||
iconClass: "text-destructive bg-destructive/10",
|
||||
ringClass: "ring-destructive/20",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2Icon,
|
||||
iconClass: "text-emerald-600 bg-emerald-50",
|
||||
ringClass: "ring-emerald-100",
|
||||
iconClass: "text-success bg-success/10",
|
||||
ringClass: "ring-success/20",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function AlertDialogContent({
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
|
||||
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
ringClass,
|
||||
className
|
||||
@@ -105,11 +105,11 @@ function AlertDialogContent({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<AlertDialogPrimitive.Title className="text-base font-bold text-slate-900">
|
||||
<AlertDialogPrimitive.Title className="text-base font-bold text-foreground">
|
||||
{title}
|
||||
</AlertDialogPrimitive.Title>
|
||||
{description && (
|
||||
<AlertDialogPrimitive.Description className="text-sm text-slate-500">
|
||||
<AlertDialogPrimitive.Description className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
|
||||
@@ -143,12 +143,11 @@ export function DataTable<T>({
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-indigo-50 hover:bg-indigo-50">
|
||||
<TableRow className="border-b-0 hover:bg-primary/5">
|
||||
{columns.map((column, index) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"text-indigo-700",
|
||||
index === 0 && "rounded-l-lg pl-3",
|
||||
index === columns.length - 1 && !actions && "rounded-r-lg pr-3",
|
||||
column.headerClassName
|
||||
@@ -158,7 +157,7 @@ export function DataTable<T>({
|
||||
</TableHead>
|
||||
))}
|
||||
{actions && (
|
||||
<TableHead className="rounded-r-lg pr-3 text-right text-indigo-700">
|
||||
<TableHead className="rounded-r-lg pr-3 text-right">
|
||||
Actions
|
||||
</TableHead>
|
||||
)}
|
||||
|
||||
@@ -150,7 +150,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-violet-100 focus:text-violet-900 dark:focus:bg-violet-500/25 dark:focus:text-violet-200 not-data-[variant=destructive]:focus:**:text-violet-900 dark:not-data-[variant=destructive]:focus:**:text-violet-200 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -163,7 +163,7 @@ function SelectItem({
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
<CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
|
||||
@@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) {
|
||||
className="h-4.5 w-12 shrink-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} className="fill-indigo-600" stroke="white" strokeWidth={1.5} />
|
||||
<path d={path} fill="none" className="stroke-muted-foreground/40" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -71,21 +71,21 @@ export function StatCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200",
|
||||
"flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
|
||||
<Icon className="size-5 text-indigo-600" />
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-xl font-bold tracking-tight text-slate-900">{formatValue(value)}</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export function StatCard({
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
|
||||
isGood ? "text-success" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{isPositive ? "+" : "-"}
|
||||
|
||||
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
className={cn("bg-muted/50 [&_tr]:border-b [&_tr]:hover:bg-muted/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-10 px-2 text-left align-middle font-semibold whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
|
||||
export const dashboardApi = {
|
||||
stats(): Promise<DashboardStats> {
|
||||
return apiRequest<DashboardStats>("/dashboard/stats")
|
||||
},
|
||||
}
|
||||
@@ -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<StageStatus, keyof ProductionRun["stageSummary"]> = {
|
||||
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] }))
|
||||
}
|
||||
@@ -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<string, MockTemplateInfo> = {
|
||||
"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"] },
|
||||
}
|
||||
@@ -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<StageStatus, string> = {
|
||||
Waiting: "#9CA3AF",
|
||||
Ready: "#3B82F6",
|
||||
InProgress: "#F59E0B",
|
||||
Done: "#22C55E",
|
||||
Approved: "#14B8A6",
|
||||
}
|
||||
|
||||
export const STAGE_STATUS_LABEL: Record<StageStatus, string> = {
|
||||
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"
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user