feat(admin): InboxList — orderkön som universal inkorg (våg 3.3-adoption) #130
+21
-1
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user