Article Console View (Web) Implementation Plan
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.md — merge 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 toObjectClassAdmin, ~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(mirrorsrc/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 fromsrc/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/selectortextareais missing insrc/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(mirrorClassList.tsx:1-55— search overname+articleNumber, showarticleNumber · name, badge withprefixDecimal, select byclassRef) -
Create:
src/components/admin/articles/ArticleEditor.tsx - Step 1: ArticleList — copy
ClassList.tsxstructure; 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 viaupdateArticle, image upload viauseClassImageUpload+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(mirrorAdminClasses.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/admintree) -
Modify:
src/components/admin/layouts/AdminLayout.tsx:35-113(nav item “Artiklar”, iconPackagefrom lucide-react, abilityADMIN_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 atPlings-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)
- ◻️ Skapa “Påse grön 10x15cm” (art.nr, förälder
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 = 3mirrors the existing registry UI; production wallet switch is a later system-wide change, not this feature’s concern.