feat(admin): InboxList — orderkön som universal inkorg (våg 3.3-adoption) #130

Merged
admin merged 1 commits from feat/inbox-pilot-20260929 into main 2026-09-29 14:20:10 +00:00
4 changed files with 89 additions and 4 deletions
+21 -1
View File
@@ -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() {
))}
</div>
{orders.some((o) => o.inbox_item) ? (
<section className="mt-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Inbox", sv: "Inkorg" })}</h2>
<div className="mt-4">
<InboxList
items={orders.map((o) => 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);
}
}}
/>
</div>
</section>
) : null}
<section className="mt-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Order queue", sv: "Orderkö" })}</h2>
{orders.length === 0 ? (
+2 -2
View File
@@ -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);
+37
View File
@@ -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<string, { kind: "task" | "approval"; priority: "normal" | "high" }> = {
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,
});
}
+29 -1
View File
@@ -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");
});
});