From a2377215e7352668142d19a6668dae2a46d4f09d Mon Sep 17 00:00:00 2001 From: sax3l Date: Tue, 29 Sep 2026 16:10:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20InboxList=20=E2=80=94=20orderk?= =?UTF-8?q?=C3=B6n=20som=20universal=20inkorg=20(v=C3=A5g=203.3-adoption)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servern projicerar öppna ordrar (draft/quoted/in_production/review) på universal-inbox-konvolverna via createInboxItem (buildOrderInboxItem i lib/order-flow, ADR-0001-mönstret: policy/resolution i serverlagret). Admin renderar InboxList (@siax/ui 0.11.1) ovanpå orderkön — resolve- aktionen gateras genom advance_action-state (available krävs). Terminala statusar ger ingen post. tests/order-flow 6/6; full svit 1144/1144; build ✓. --- app/admin/page.tsx | 22 +++++++++++++++++++++- app/api/orders/route.ts | 4 ++-- lib/order-flow.ts | 37 +++++++++++++++++++++++++++++++++++++ tests/order-flow.test.ts | 30 +++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 13cfec9..df88526 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ActionGate } from "@siax/ui"; +import { ActionGate, InboxList } from "@siax/ui"; import { NEXT_STATUS } from "@/lib/order-flow"; import { useCallback, useEffect, useState } from "react"; @@ -92,6 +92,26 @@ export default function AdminPage() { ))} + {orders.some((o) => o.inbox_item) ? ( +
+

{t({ en: "Inbox", sv: "Inkorg" })}

+
+ o.inbox_item).filter(Boolean)} + labels={{ + resolve: t({ en: "Advance", sv: "Flytta fram" }), + emptyTitle: t({ en: "Inbox is empty", sv: "Inkorgen är tom" }), + }} + onResolve={(item) => { + const order = orders.find((o) => `order:${o.order_id}` === item.id); + if (order?.advance_action?.state === "available" && NEXT_STATUS[order.status]) { + advance(order.order_id, order.status); + } + }} + /> +
+
+ ) : null}

{t({ en: "Order queue", sv: "Orderkö" })}

{orders.length === 0 ? ( diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index ca59c8a..24d1548 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store"; import { requireOrderOwner } from "@/src/auth/require-order-owner"; -import { resolveAdvanceAction } from "@/lib/order-flow"; +import { resolveAdvanceAction, buildOrderInboxItem } from "@/lib/order-flow"; import { isSiaxError, siaxErrorResponseForLegacy } from "../siax-error-mapping"; // POST /api/orders (docs/04) — create a draft order from a configurator config. @@ -59,7 +59,7 @@ export async function GET(req: NextRequest) { const orders = await listOwnedOrders(auth.owner.tenantId); // ADR-0001: servern resolvér advance-aktionen per order — klienten // renderar bara (ActionGate). Policy ägs här, inte i UI-lagret. - const withActions = orders.map((o) => ({ ...o, advance_action: resolveAdvanceAction(o.status) })); + const withActions = orders.map((o) => ({ ...o, advance_action: resolveAdvanceAction(o.status), inbox_item: buildOrderInboxItem(o) })); return NextResponse.json(withActions); } catch (err: unknown) { if (isSiaxError(err)) return siaxErrorResponseForLegacy(err); diff --git a/lib/order-flow.ts b/lib/order-flow.ts index 352c17f..77df17b 100644 --- a/lib/order-flow.ts +++ b/lib/order-flow.ts @@ -1,3 +1,4 @@ +import { createInboxItem, type InboxItem } from "@siax/contracts/inbox"; import { resolveAction, type ResolvedAction } from "@siax/contracts/authz-ux"; /** Kanonisk orderflöde — servern äger resolutionen (ADR-0001), UI renderar. */ @@ -21,3 +22,39 @@ export function resolveAdvanceAction(status: string): ResolvedAction { } return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: true }); } + +/** Orderstatus som utgör en inkorgspost (våg 3.3: orders kö = inbox). */ +const INBOX_ELIGIBLE: Record = { + draft: { kind: "task", priority: "normal" }, + quoted: { kind: "task", priority: "normal" }, + in_production: { kind: "task", priority: "normal" }, + review: { kind: "approval", priority: "high" }, +}; + +/** + * Server-renderad inkorgspost (ADR-0001-mönstret): en order som väntar på + * nästa steg projiceras på universal-inbox-konvolverna. Terminala statusar + * (delivered m.fl.) ger ingen post — inboxen är öppna arbetsobjekt. + */ +export function buildOrderInboxItem(order: { + order_id: string; + status: string; + flow: string; + total_ore?: number | null; +}): InboxItem | null { + const eligible = INBOX_ELIGIBLE[order.status]; + if (!eligible) return null; + return createInboxItem({ + id: `order:${order.order_id}`, + kind: eligible.kind, + tenantId: "0pt", + subject: `Order ${order.order_id.slice(0, 8)} — ${order.status} → ${NEXT_STATUS[order.status] ?? "nästa steg"}`, + priority: eligible.priority, + createdAt: new Date().toISOString(), + source: { system: "0pt", channel: "order-queue" }, + entity: { type: "order", id: order.order_id }, + actions: NEXT_STATUS[order.status] + ? [{ id: "advance", label: `→ ${NEXT_STATUS[order.status]}` }] + : undefined, + }); +} diff --git a/tests/order-flow.test.ts b/tests/order-flow.test.ts index abbb82e..6ec9a53 100644 --- a/tests/order-flow.test.ts +++ b/tests/order-flow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { NEXT_STATUS, resolveAdvanceAction } from "@/lib/order-flow"; +import { NEXT_STATUS, resolveAdvanceAction, buildOrderInboxItem } from "@/lib/order-flow"; describe("order-flow — server-renderad advance-resolution (ADR-0001)", () => { it("flödets fem steg har nästa status", () => { @@ -17,3 +17,31 @@ describe("order-flow — server-renderad advance-resolution (ADR-0001)", () => { expect(resolveAdvanceAction("cancelled")).toMatchObject({ state: "read_only", reason: "read_only_resource" }); }); }); + +describe("order-flow — inbox-projektion (våg 3.3)", () => { + const order = (over: Partial<{ order_id: string; status: string; flow: string; total_ore: number }> = {}) => ({ + order_id: "abcd1234-5678", + status: "review", + flow: "custom", + total_ore: 500000, + ...over, + }); + + it("review-order → approval-post med hög prioritet och advance-action", () => { + const item = buildOrderInboxItem(order()); + expect(item).toMatchObject({ kind: "approval", priority: "high", state: "open" }); + expect(item!.actions?.[0].label).toContain("accepted"); + expect(item!.entity).toMatchObject({ type: "order", id: "abcd1234-5678" }); + }); + + it("levererad order → ingen post (inkorgen är öppna arbetsobjekt)", () => { + expect(buildOrderInboxItem(order({ status: "delivered" }))).toBeNull(); + expect(buildOrderInboxItem(order({ status: "cancelled" }))).toBeNull(); + }); + + it("draft-order → task med normal prioritet", () => { + const item = buildOrderInboxItem(order({ status: "draft" })); + expect(item).toMatchObject({ kind: "task", priority: "normal" }); + expect(item!.subject).toContain("draft"); + }); +}); -- 2.54.0