Article Console View (Web) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Created: Thu 02 Jul 2026 17:35 CEST Spec: Plings-Web/internal/specs/2026-07-02-manufacturer-article-registry-design.md (v1.1) Sibling plan: Plings-API/docs/plans/2026-07-02-manufacturer-article-registry.mdmerge the API plan FIRST; this UI calls its new mutations/queries.

Goal: A manufacturer article view in the admin console — article list, create-article wizard (name, article number, community parent, namespace placement, image), article detail with batches — enough for Paul to create the green/white bag articles in the browser.

Architecture: Mirrors the existing classes console exactly (list + editor + dialog on one page). New gql literals in src/graphql/articles.ts, one hook useArticles, three components under src/components/admin/articles/, page AdminArticles, route /admin/articles. Org scoping via useOrganizationContext(); manufacturer + namespace tree via the existing useNamespaceTree(organizationId) hook (DEV_WALLET_VERSION = 3).

Tech Stack: React 18 + TS (Vite/SWC), Apollo Client (hand-written gql, no codegen), shadcn/ui + Tailwind, sonner toasts, react-router v6. No test runner — verification is npx tsc --noEmit + npm run build.


Task 1: types + GraphQL documents

Files:

  • Modify: src/types/graphql.ts
  • Create: src/graphql/articles.ts

  • Step 1: Add the type in src/types/graphql.ts (next to ObjectClassAdmin, ~line 94):
export interface ManufacturerArticle {
  classRef: string;          // the crypto cp
  name: string;
  description?: string | null;
  articleNumber: string;
  classKind: string;
  cpPath: string;
  cpWalletVersion: number;
  ownerOrgId: string;
  nodeId: string;
  prefixDecimal: string;
  imageUrl?: string | null;
  instanceCount: number;
}
  • Step 2: Create src/graphql/articles.ts (mirror src/graphql/classes.ts):
import { gql } from '@apollo/client';

const ARTICLE_FIELDS = `
  classRef name description articleNumber classKind cpPath cpWalletVersion
  ownerOrgId nodeId prefixDecimal imageUrl instanceCount
`;

export const MANUFACTURER_ARTICLES = gql`
  query ManufacturerArticles($organizationId: ID!, $walletVersion: Int!) {
    manufacturerArticles(organizationId: $organizationId, walletVersion: $walletVersion) {
      ${ARTICLE_FIELDS}
    }
  }
`;

export const CREATE_MANUFACTURER_ARTICLE = gql`
  mutation CreateManufacturerArticle($input: CreateManufacturerArticleInput!) {
    createManufacturerArticle(input: $input) {
      ${ARTICLE_FIELDS}
    }
  }
`;

(Name/description edits and image reuse the existing UPDATE_OBJECT_CLASS / SET_OBJECT_CLASS_IMAGE from src/graphql/classes.ts — an article IS an ObjectClass; no duplicate mutations.)

  • Step 3: Verify: npx tsc --noEmit → clean.

  • Step 4: Commit

git add src/types/graphql.ts src/graphql/articles.ts
git commit -m "feat: ManufacturerArticle type + gql documents"

Task 2: useArticles hook

Files:

  • Create: src/hooks/useArticles.ts

  • Step 1: Write the hook (mirror src/hooks/useObjectClasses.ts:1-45; wallet constant from src/hooks/useNamespaceTree.ts:11):

// src/hooks/useArticles.ts
import { useQuery, useMutation } from '@apollo/client';
import { MANUFACTURER_ARTICLES, CREATE_MANUFACTURER_ARTICLE } from '@/graphql/articles';
import { UPDATE_OBJECT_CLASS, SET_OBJECT_CLASS_IMAGE } from '@/graphql/classes';
import { DEV_WALLET_VERSION } from '@/hooks/useNamespaceTree';
import type { ManufacturerArticle } from '@/types/graphql';

interface ManufacturerArticlesData {
  manufacturerArticles: ManufacturerArticle[];
}

export interface CreateArticleInput {
  organizationId: string;
  name: string;
  articleNumber: string;
  description?: string;
  parentClassRef?: string;
  namespaceParentNodeId?: string | null;
  existingNodeId?: string | null;
}

export const useArticles = (organizationId: string | undefined) => {
  const { data, loading, error, refetch } = useQuery<ManufacturerArticlesData>(MANUFACTURER_ARTICLES, {
    variables: { organizationId: organizationId ?? '', walletVersion: DEV_WALLET_VERSION },
    skip: !organizationId,
    errorPolicy: 'all',
    fetchPolicy: 'cache-and-network',
  });

  const refetchList = organizationId
    ? [{ query: MANUFACTURER_ARTICLES, variables: { organizationId, walletVersion: DEV_WALLET_VERSION } }]
    : [];

  const [createMutation, { loading: creating }] = useMutation(CREATE_MANUFACTURER_ARTICLE, {
    refetchQueries: refetchList, awaitRefetchQueries: true,
  });
  const [updateMutation, { loading: updating }] = useMutation(UPDATE_OBJECT_CLASS, {
    refetchQueries: refetchList, awaitRefetchQueries: true,
  });
  const [setImageMutation, { loading: settingImage }] = useMutation(SET_OBJECT_CLASS_IMAGE, {
    refetchQueries: refetchList, awaitRefetchQueries: true,
  });

  const createArticle = async (input: CreateArticleInput) => {
    const res = await createMutation({
      variables: { input: { ...input, walletVersion: DEV_WALLET_VERSION } },
    });
    return res.data?.createManufacturerArticle as ManufacturerArticle | undefined;
  };

  const updateArticle = async (classRef: string, fields: { name?: string; description?: string }) => {
    const res = await updateMutation({ variables: { classPointer: classRef, ...fields } });
    return res.data?.updateObjectClass;
  };

  const setArticleImage = async (classRef: string, imageUrl: string) => {
    const res = await setImageMutation({ variables: { classPointer: classRef, imageUrl } });
    return res.data?.setObjectClassImage;
  };

  return {
    articles: data?.manufacturerArticles ?? [],
    loading, error, refetch,
    createArticle, updateArticle, setArticleImage,
    busy: creating || updating || settingImage,
  };
};
  • Step 2: Verify: npx tsc --noEmit → clean.

  • Step 3: Commit

git add src/hooks/useArticles.ts
git commit -m "feat: useArticles hook (org-scoped list + create/update/image)"

Task 3: NewArticleDialog (create wizard)

Files:

  • Create: src/components/admin/articles/NewArticleDialog.tsx

Fields: name, article number, description, community parent (select from useObjectClasses().classes, optional), namespace placement (parent-node select from useNamespaceTree(orgId).nodes, “under roten” default). Image is added afterwards in the editor (same flow as classes — keeps the dialog simple).

  • Step 1: Write the component (mirror NewClassDialog.tsx:1-70; same Dialog/Input/Label/Button/sonner idioms):
// src/components/admin/articles/NewArticleDialog.tsx
import { useState } from 'react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useObjectClasses } from '@/hooks/useObjectClasses';
import { useNamespaceTree } from '@/hooks/useNamespaceTree';
import type { CreateArticleInput } from '@/hooks/useArticles';

interface Props {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  organizationId: string;
  onCreate: (input: CreateArticleInput) => Promise<unknown>;
  busy: boolean;
}

const NONE = '__none__';

export function NewArticleDialog({ open, onOpenChange, organizationId, onCreate, busy }: Props) {
  const [name, setName] = useState('');
  const [articleNumber, setArticleNumber] = useState('');
  const [description, setDescription] = useState('');
  const [parentClassRef, setParentClassRef] = useState(NONE);
  const [parentNodeId, setParentNodeId] = useState(NONE);

  const { classes } = useObjectClasses();
  const { nodes, isIssuer } = useNamespaceTree(organizationId);
  const activeNodes = nodes.filter((n) => n.status === 'active');

  const submit = async () => {
    if (!name.trim()) { toast.error('Namn krävs'); return; }
    if (!articleNumber.trim()) { toast.error('Artikelnummer krävs'); return; }
    try {
      await onCreate({
        organizationId,
        name: name.trim(),
        articleNumber: articleNumber.trim(),
        description: description.trim() || undefined,
        parentClassRef: parentClassRef === NONE ? undefined : parentClassRef,
        namespaceParentNodeId: parentNodeId === NONE ? null : parentNodeId,
      });
      toast.success('Artikel skapad');
      setName(''); setArticleNumber(''); setDescription('');
      setParentClassRef(NONE); setParentNodeId(NONE);
      onOpenChange(false);
    } catch (e) {
      toast.error(e instanceof Error ? e.message : 'Kunde inte skapa artikeln');
    }
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-[520px]">
        <DialogHeader><DialogTitle>Ny artikel</DialogTitle></DialogHeader>
        {!isIssuer && (
          <p className="text-sm text-destructive">
            Organisationen är inte registrerad tillverkare  artiklar kan inte skapas.
          </p>
        )}
        <div className="flex flex-col gap-4 py-2">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="art-name">Namn</Label>
            <Input id="art-name" value={name} onChange={(e) => setName(e.target.value)} />
          </div>
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="art-nr">Artikelnummer</Label>
            <Input id="art-nr" placeholder="PL-0001" value={articleNumber}
                   onChange={(e) => setArticleNumber(e.target.value)} />
          </div>
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="art-desc">Beskrivning</Label>
            <Textarea id="art-desc" rows={3} value={description}
                      onChange={(e) => setDescription(e.target.value)} />
          </div>
          <div className="flex flex-col gap-1.5">
            <Label>Community-förälder (SUBCLASS_OF)</Label>
            <Select value={parentClassRef} onValueChange={setParentClassRef}>
              <SelectTrigger><SelectValue placeholder="Ingen" /></SelectTrigger>
              <SelectContent>
                <SelectItem value={NONE}>Ingen</SelectItem>
                {classes.map((c) => (
                  <SelectItem key={c.classRef} value={c.classRef}>{c.name}</SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <div className="flex flex-col gap-1.5">
            <Label>Placering i namespace-trädet</Label>
            <Select value={parentNodeId} onValueChange={setParentNodeId}>
              <SelectTrigger><SelectValue placeholder="Under roten" /></SelectTrigger>
              <SelectContent>
                <SelectItem value={NONE}>Under roten</SelectItem>
                {activeNodes.map((n) => (
                  <SelectItem key={n.id} value={n.id}>{n.prefixDecimal}  {n.name}</SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
        </div>
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)}>Avbryt</Button>
          <Button onClick={submit} disabled={busy || !isIssuer}>Skapa artikel</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
  • Step 2: Verify: npx tsc --noEmit → clean. (If @/components/ui/select or textarea is missing in src/components/ui/, add the shadcn component file following the local shadcn pattern — check first, both are expected present among the 49 ui files.)

  • Step 3: Commit

git add src/components/admin/articles/NewArticleDialog.tsx
git commit -m "feat: create-article dialog (name, art.nr, community parent, namespace placement)"

Task 4: ArticleList + ArticleEditor

Files:

  • Create: src/components/admin/articles/ArticleList.tsx (mirror ClassList.tsx:1-55 — search over name + articleNumber, show articleNumber · name, badge with prefixDecimal, select by classRef)
  • Create: src/components/admin/articles/ArticleEditor.tsx

  • Step 1: ArticleList — copy ClassList.tsx structure; filter:
const filtered = useMemo(
  () => articles.filter((a) =>
    `${a.articleNumber} ${a.name}`.toLowerCase().includes(q.toLowerCase())),
  [articles, q],
);
  • Step 2: ArticleEditor — mirror ClassEditor.tsx:1-90 (name/description editing via updateArticle, image upload via useClassImageUpload + setArticleImage) plus a read-only identity block and batches table:
// Identity block (read-only)
<div className="grid grid-cols-2 gap-2 text-sm text-muted-foreground">
  <span>Artikelnummer</span><span className="font-mono">{article.articleNumber}</span>
  <span>cp (class_ref)</span><span className="font-mono">{article.classRef}</span>
  <span>Nod</span><span className="font-mono">{article.prefixDecimal}</span>
</div>

Batches: query MANUFACTURER_ALLOCATIONS (add to src/graphql/articles.ts:

export const MANUFACTURER_ALLOCATIONS = gql`
  query ManufacturerAllocations($walletVersion: Int!, $manufacturerIndex: Int!) {
    manufacturerAllocations(walletVersion: $walletVersion, manufacturerIndex: $manufacturerIndex) {
      id nodeId batchNumber instanceStart instanceEnd batchLabel batchPathDecimal
      status issuedAt tagArticleRef producerOrgId producerNote
    }
  }
`;

) with useNamespaceTree(orgId).manufacturer.manufacturerIndex, filter client-side on nodeId === article.nodeId, render in the shadcn Table (batchPathDecimal, count = instanceEnd - instanceStart + 1, status, issuedAt).

  • Step 3: Verify: npx tsc --noEmit → clean.

  • Step 4: Commit

git add src/components/admin/articles/ArticleList.tsx src/components/admin/articles/ArticleEditor.tsx src/graphql/articles.ts
git commit -m "feat: article list + editor with identity block and batch table"

Task 5: page, route, nav

Files:

  • Create: src/pages/admin/AdminArticles.tsx (mirror AdminClasses.tsx:1-57: useOrganizationContext() for org, useArticles(orgId), list + editor + dialog composition)
  • Modify: src/App.tsx:93-110 (add <Route path="articles" element={<AdminArticles />} /> inside the /admin tree)
  • Modify: src/components/admin/layouts/AdminLayout.tsx:35-113 (nav item “Artiklar”, icon Package from lucide-react, ability ADMIN_CLASS_MANAGEMENT — same gate as classes; no new ability needed)

  • Step 1: Write page + route + nav item.
  • Step 2: Verify: npx tsc --noEmit && npm run build → both clean.
  • Step 3: Commit
git add src/pages/admin/AdminArticles.tsx src/App.tsx src/components/admin/layouts/AdminLayout.tsx
git commit -m "feat: /admin/articles page, route and nav"

Task 6: docs + roadmap (same PR — hard gate)

Files:

  • Modify: ROADMAP.md (this repo): move console-article-view item to Done with PR ref; Backlog additions from the spec’s non-goals that belong to Web.
  • Modify: docs/flows/README.md (flow index): row for “Artikelregister” pointing at Plings-API/docs/flows/artikelregister.md (the flow doc lives API-side per the API plan).

  • Step 1: Write both.
  • Step 2: npm run build → clean (docs are Jekyll-published; no {{ }} unescaped).
  • Step 3: Commit: git add ROADMAP.md docs/flows/README.md && git commit -m "docs: article console — roadmap + flow index"

FINAL: quality-control-enforcer + verification

  • QC review (new GraphQL surface consumption + admin UI).
  • Manual dev-loop verification against a local API (npm run dev:local + uvicorn) before merge: create both bag articles end-to-end.
  • Merge order: Plings-API first, then this (UI must never call endpoints that aren’t live).
  • After merge: FF local checkout; hand Paul the device/console checklist:
    • ◻️ Skapa “Påse grön 10x15cm” (art.nr, förälder Påse 10x15cm, plats under Containrar) — lastbärande
    • ◻️ Skapa “Påse vit 10x15cm”
    • ◻️ Dubblett-artikelnummer avvisas med tydligt fel
    • ◻️ Icke-tillverkarorg ser spärren i dialogen
    • ◻️ Bild på artikeln syns i listan
    • ◻️ Keygen: generera 50+50 taggar med cp från nodens class_ref — lastbärande
    • ◻️ Skanna en tagg → instansen får rätt klass/bild/namn (thin-instance-arv)

Self-review notes

  • Spec coverage (web scope): console list/wizard/detail = Tasks 3-5; batches display = Task 4; org scoping + manufacturer gate = Tasks 2-3; identity (cp/art.nr read-only) = Task 4.
  • Reuses UPDATE_OBJECT_CLASS/SET_OBJECT_CLASS_IMAGE — an article IS an ObjectClass (spec decision 1); no duplicated mutations.
  • DEV_WALLET_VERSION = 3 mirrors the existing registry UI; production wallet switch is a later system-wide change, not this feature’s concern.