Class Admin — Web Implementation Plan (Plings-Web)
Class Admin — Web Implementation Plan (Plings-Web)
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.
Goal: Replace the mock AdminClasses.tsx with a real master–detail Class Admin: list my org’s classes, create a class, edit name/description, and set/replace the class image.
Architecture: A session-/admin-guarded route under /admin/classes. Apollo talks to the API added in the companion Plings-API PR (myObjectClasses, createObjectClass, updateObjectClass, setObjectClassImage). Class images upload directly to Supabase storage (then setObjectClassImage(classPointer, imageUrl)), since the existing /register-image/{objectId} path is object-scoped.
Tech Stack: React + TypeScript, Vite, Apollo Client, Tailwind, shadcn/ui, sonner (toasts), Supabase JS (storage + auth).
Spec: docs/specs/2026-06-28-class-admin-web-design.md
Branch/worktree: task/class-admin-web off origin/dev.
Depends on: the Plings-API PR merged first. For dev, point the BackendSwitcher at the API branch’s Vercel preview, or run the API locally (dev:local).
No unit-test runner in this repo → gates are npx tsc --noEmit + npm run build + browser-preview verification.
Pre-flight (read, don’t skip)
src/pages/admin/AdminClasses.tsx(the mock you replace),src/App.tsx(routes ~93–110:/admin→AdminRoute→AdminLayout→classes).src/components/admin/shared/AdminRoute.tsx(guard — already wraps/admin/*; the route is already admin-guarded, so the page itself needs no extra guard).src/graphql/queries.ts+mutations.ts+src/hooks/useMyOrganizations.ts(the gql + hook idiom).src/hooks/useImageUpload.ts(storage upload idiom to adapt for class images).src/contexts/OrganizationContext.tsx(useOrganizationContext→ currentOrganization / userOrganizations / defaultOrganization).src/types/graphql.ts(add types here).
File structure
- Create
src/graphql/classes.ts— gql formyObjectClasses,createObjectClass,updateObjectClass,setObjectClassImage. - Create
src/hooks/useObjectClasses.ts— wraps the query + mutations with cache updates. - Create
src/hooks/useClassImageUpload.ts— Supabase storage upload →public_url(no object register step). - Modify
src/types/graphql.ts— addObjectClassAdmin. - Rewrite
src/pages/admin/AdminClasses.tsx— master–detail screen (composes the pieces below). - Create
src/components/admin/classes/ClassList.tsx,ClassEditor.tsx,NewClassDialog.tsx— focused units. - Create
docs/flows/class-admin.md+ a use-case skeleton indocs/use-cases/(use-case catch). - Modify
ROADMAP.md(Plings-Web) — add the Class Admin slice to Done.
Task 1: Types + GraphQL operations
Files:
- Modify:
src/types/graphql.ts -
Create:
src/graphql/classes.ts - Step 1: Add the type to
src/types/graphql.ts(near the existingObjectClass):
export interface ObjectClassAdmin {
id: string;
classRef: string;
name: string;
description?: string;
imageUrl?: string | null;
visibility?: string | null;
ownerOrgId?: string | null;
instanceCount: number;
}
- Step 2: Create
src/graphql/classes.ts
import { gql } from '@apollo/client';
export const MY_OBJECT_CLASSES = gql`
query MyObjectClasses {
myObjectClasses {
id
classRef
name
description
imageUrl
visibility
ownerOrgId
instanceCount
}
}
`;
export const CREATE_OBJECT_CLASS = gql`
mutation CreateObjectClass($organizationId: ID!, $name: String!, $description: String) {
createObjectClass(organizationId: $organizationId, name: $name, description: $description) {
id classRef name description imageUrl visibility ownerOrgId instanceCount
}
}
`;
export const UPDATE_OBJECT_CLASS = gql`
mutation UpdateObjectClass($classPointer: String!, $name: String, $description: String) {
updateObjectClass(classPointer: $classPointer, name: $name, description: $description) {
id classRef name description imageUrl visibility ownerOrgId instanceCount
}
}
`;
export const SET_OBJECT_CLASS_IMAGE = gql`
mutation SetObjectClassImage($classPointer: String!, $imageUrl: String!) {
setObjectClassImage(classPointer: $classPointer, imageUrl: $imageUrl) {
success error imageId
}
}
`;
- Step 3: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors.
- Step 4: Commit
git add src/types/graphql.ts src/graphql/classes.ts
git commit -m "feat(web): ObjectClassAdmin type + class admin gql operations"
Task 2: useObjectClasses hook (query + mutations with cache update)
Files:
-
Create:
src/hooks/useObjectClasses.ts -
Step 1: Implement
import { useMutation, useQuery } from '@apollo/client';
import {
MY_OBJECT_CLASSES, CREATE_OBJECT_CLASS, UPDATE_OBJECT_CLASS, SET_OBJECT_CLASS_IMAGE,
} from '@/graphql/classes';
import type { ObjectClassAdmin } from '@/types/graphql';
interface MyObjectClassesData { myObjectClasses: ObjectClassAdmin[]; }
export const useObjectClasses = () => {
const { data, loading, error, refetch } = useQuery<MyObjectClassesData>(MY_OBJECT_CLASSES, {
errorPolicy: 'all',
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
});
const [createMutation, { loading: creating }] = useMutation(CREATE_OBJECT_CLASS, {
refetchQueries: [{ query: MY_OBJECT_CLASSES }], awaitRefetchQueries: true,
});
const [updateMutation, { loading: updating }] = useMutation(UPDATE_OBJECT_CLASS, {
refetchQueries: [{ query: MY_OBJECT_CLASSES }], awaitRefetchQueries: true,
});
const [setImageMutation, { loading: settingImage }] = useMutation(SET_OBJECT_CLASS_IMAGE, {
refetchQueries: [{ query: MY_OBJECT_CLASSES }], awaitRefetchQueries: true,
});
const createClass = async (organizationId: string, name: string, description?: string) => {
const res = await createMutation({ variables: { organizationId, name, description } });
return res.data?.createObjectClass as ObjectClassAdmin | undefined;
};
const updateClass = async (classPointer: string, fields: { name?: string; description?: string }) => {
const res = await updateMutation({ variables: { classPointer, ...fields } });
return res.data?.updateObjectClass as ObjectClassAdmin | undefined;
};
const setClassImage = async (classPointer: string, imageUrl: string) => {
const res = await setImageMutation({ variables: { classPointer, imageUrl } });
return res.data?.setObjectClassImage as { success: boolean; error?: string; imageId?: string };
};
return {
classes: data?.myObjectClasses ?? [],
loading, error, refetch,
createClass, updateClass, setClassImage,
busy: creating || updating || settingImage,
};
};
- Step 2: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors.
- Step 3: Commit
git add src/hooks/useObjectClasses.ts
git commit -m "feat(web): useObjectClasses hook (list + create/update/setImage)"
Task 3: useClassImageUpload hook (Supabase storage → public_url)
Files:
- Create:
src/hooks/useClassImageUpload.ts
The existing
useImageUploadposts to/register-image/{objectId}(object-scoped). Class images have no object, so we upload to the sameobject-imagesbucket under aclasses/ObjectClass/{classRef}/…path and return the public URL; the caller then runssetObjectClassImage.
- Step 1: Implement
import { useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
function ext(name: string): string {
const i = name.lastIndexOf('.');
return i >= 0 ? name.slice(i) : '';
}
export const useClassImageUpload = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Upload one image for a class, return its public URL. */
const uploadClassImage = async (classRef: string, file: File): Promise<string> => {
setLoading(true);
setError(null);
try {
const storage = supabase.storage.from('object-images');
const path = `classes/ObjectClass/${classRef}/${Date.now()}${ext(file.name)}`;
const { error: upErr } = await storage.upload(path, file, {
upsert: false, contentType: file.type,
});
if (upErr) throw upErr;
const { data } = storage.getPublicUrl(path);
return data.publicUrl;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Uppladdning misslyckades';
setError(msg);
throw e;
} finally {
setLoading(false);
}
};
return { uploadClassImage, loading, error };
};
Confirm the Supabase client import path matches the repo (the Explore found
supabase.auth.getSession()inuseImageUpload.ts; reuse that same import — likely@/integrations/supabase/client). Fix the import to match if different.
- Step 2: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors.
- Step 3: Commit
git add src/hooks/useClassImageUpload.ts
git commit -m "feat(web): useClassImageUpload (class-scoped storage upload)"
Task 4: ClassList (left pane)
Files:
-
Create:
src/components/admin/classes/ClassList.tsx -
Step 1: Implement
import { useMemo, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { ObjectClassAdmin } from '@/types/graphql';
interface Props {
classes: ObjectClassAdmin[];
selectedRef: string | null;
onSelect: (ref: string) => void;
onNew: () => void;
loading: boolean;
}
export function ClassList({ classes, selectedRef, onSelect, onNew, loading }: Props) {
const [q, setQ] = useState('');
const filtered = useMemo(
() => classes.filter((c) => c.name.toLowerCase().includes(q.toLowerCase())),
[classes, q],
);
return (
<div className="flex flex-col gap-3 p-3 border-r min-w-[260px] max-w-[340px] w-1/3">
<div className="flex items-center gap-2">
<Input placeholder="Sök klass…" value={q} onChange={(e) => setQ(e.target.value)} />
<Button onClick={onNew} size="sm">+ Ny klass</Button>
</div>
{loading && classes.length === 0 && <p className="text-sm text-muted-foreground">Laddar…</p>}
{!loading && filtered.length === 0 && (
<p className="text-sm text-muted-foreground">Inga klasser ännu.</p>
)}
<ul className="flex flex-col gap-1 overflow-y-auto">
{filtered.map((c) => (
<li key={c.classRef}>
<button
onClick={() => onSelect(c.classRef)}
className={`w-full flex items-center gap-3 rounded-md p-2 text-left hover:bg-accent ${
selectedRef === c.classRef ? 'bg-accent' : ''
}`}
>
<div className="h-9 w-9 rounded bg-muted overflow-hidden flex-shrink-0">
{c.imageUrl && <img src={c.imageUrl} alt="" className="h-full w-full object-cover" />}
</div>
<div className="min-w-0">
<div className="font-medium truncate">{c.name}</div>
<div className="text-xs text-muted-foreground truncate">
{c.instanceCount} exemplar · {c.classRef}
</div>
</div>
</button>
</li>
))}
</ul>
</div>
);
}
- Step 2: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors.
- Step 3: Commit
git add src/components/admin/classes/ClassList.tsx
git commit -m "feat(web): ClassList master pane"
Task 5: ClassEditor (right pane — edit name/description + image)
Files:
-
Create:
src/components/admin/classes/ClassEditor.tsx -
Step 1: Implement
import { useEffect, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import type { ObjectClassAdmin } from '@/types/graphql';
import { useClassImageUpload } from '@/hooks/useClassImageUpload';
interface Props {
klass: ObjectClassAdmin;
onSave: (fields: { name?: string; description?: string }) => Promise<void>;
onSetImage: (imageUrl: string) => Promise<void>;
busy: boolean;
}
export function ClassEditor({ klass, onSave, onSetImage, busy }: Props) {
const [name, setName] = useState(klass.name);
const [description, setDescription] = useState(klass.description ?? '');
const fileRef = useRef<HTMLInputElement>(null);
const { uploadClassImage, loading: uploading } = useClassImageUpload();
useEffect(() => {
setName(klass.name);
setDescription(klass.description ?? '');
}, [klass.classRef]); // reset when a different class is selected
const dirty = name !== klass.name || description !== (klass.description ?? '');
const handleSave = async () => {
if (!name.trim()) { toast.error('Namnet får inte vara tomt'); return; }
try {
await onSave({ name: name.trim(), description });
toast.success('Sparat');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Kunde inte spara');
}
};
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const url = await uploadClassImage(klass.classRef, file);
await onSetImage(url);
toast.success('Bild uppdaterad');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Kunde inte sätta bild');
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
return (
<div className="flex-1 p-5 flex flex-col gap-4 max-w-2xl">
<div className="flex items-center gap-4">
<div className="h-24 w-24 rounded-lg bg-muted overflow-hidden flex-shrink-0">
{klass.imageUrl && <img src={klass.imageUrl} alt="" className="h-full w-full object-cover" />}
</div>
<div>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={handleFile} />
<Button variant="outline" size="sm" disabled={uploading || busy}
onClick={() => fileRef.current?.click()}>
{uploading ? 'Laddar upp…' : 'Byt bild'}
</Button>
<p className="text-xs text-muted-foreground mt-2">
{klass.instanceCount} exemplar pekar hit · {klass.classRef}
</p>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cls-name">Namn</Label>
<Input id="cls-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cls-desc">Beskrivning</Label>
<Textarea id="cls-desc" rows={4} value={description}
onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={!dirty || busy}>Spara</Button>
{klass.visibility && (
<span className="text-xs rounded-full bg-muted px-2 py-0.5">{klass.visibility}</span>
)}
</div>
</div>
);
}
- Step 2: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors.
- Step 3: Commit
git add src/components/admin/classes/ClassEditor.tsx
git commit -m "feat(web): ClassEditor detail pane (edit name/description + set image)"
Task 6: NewClassDialog (create)
Files:
-
Create:
src/components/admin/classes/NewClassDialog.tsx -
Step 1: Implement
import { useState } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useOrganizationContext } from '@/contexts/OrganizationContext';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (organizationId: string, name: string, description?: string) => Promise<void>;
busy: boolean;
}
export function NewClassDialog({ open, onOpenChange, onCreate, busy }: Props) {
const { currentOrganization, defaultOrganization, userOrganizations } = useOrganizationContext();
const initialOrg = currentOrganization?.id ?? defaultOrganization?.id ?? userOrganizations[0]?.id ?? '';
const [orgId, setOrgId] = useState(initialOrg);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const submit = async () => {
if (!name.trim()) { toast.error('Namnet får inte vara tomt'); return; }
if (!orgId) { toast.error('Välj en organisation'); return; }
try {
await onCreate(orgId, name.trim(), description || undefined);
toast.success('Klass skapad');
setName(''); setDescription('');
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Kunde inte skapa klass');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader><DialogTitle>Ny klass</DialogTitle></DialogHeader>
<div className="flex flex-col gap-4 py-2">
{userOrganizations.length > 1 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="nc-org">Organisation</Label>
<select id="nc-org" className="border rounded-md h-9 px-2 bg-background"
value={orgId} onChange={(e) => setOrgId(e.target.value)}>
{userOrganizations.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="nc-name">Namn</Label>
<Input id="nc-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="nc-desc">Beskrivning</Label>
<Textarea id="nc-desc" rows={3} value={description}
onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Avbryt</Button>
<Button onClick={submit} disabled={busy}>Skapa</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
- Step 2: Type-check
Run: cd Plings-Web && npx tsc --noEmit
Expected: no new errors. (If userOrganizations/defaultOrganization names differ from the context, adjust to the actual useOrganizationContext shape.)
- Step 3: Commit
git add src/components/admin/classes/NewClassDialog.tsx
git commit -m "feat(web): NewClassDialog (create a class under my org)"
Task 7: Rewrite AdminClasses.tsx (compose master–detail)
Files:
-
Rewrite:
src/pages/admin/AdminClasses.tsx -
Step 1: Implement
import { useEffect, useState } from 'react';
import { ClassList } from '@/components/admin/classes/ClassList';
import { ClassEditor } from '@/components/admin/classes/ClassEditor';
import { NewClassDialog } from '@/components/admin/classes/NewClassDialog';
import { useObjectClasses } from '@/hooks/useObjectClasses';
export default function AdminClasses() {
const { classes, loading, error, createClass, updateClass, setClassImage, busy } = useObjectClasses();
const [selectedRef, setSelectedRef] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
// Default selection: first class once loaded.
useEffect(() => {
if (!selectedRef && classes.length > 0) setSelectedRef(classes[0].classRef);
}, [classes, selectedRef]);
const selected = classes.find((c) => c.classRef === selectedRef) ?? null;
return (
<div className="flex h-full">
<ClassList
classes={classes}
selectedRef={selectedRef}
onSelect={setSelectedRef}
onNew={() => setNewOpen(true)}
loading={loading}
/>
<div className="flex-1">
{error && <p className="p-5 text-sm text-destructive">Kunde inte ladda klasser.</p>}
{!error && selected && (
<ClassEditor
key={selected.classRef}
klass={selected}
busy={busy}
onSave={async (fields) => { await updateClass(selected.classRef, fields); }}
onSetImage={async (imageUrl) => {
const r = await setClassImage(selected.classRef, imageUrl);
if (r && !r.success) throw new Error(r.error ?? 'FEL');
}}
/>
)}
{!error && !selected && !loading && (
<p className="p-5 text-sm text-muted-foreground">Välj en klass eller skapa en ny.</p>
)}
</div>
<NewClassDialog
open={newOpen}
onOpenChange={setNewOpen}
busy={busy}
onCreate={async (organizationId, name, description) => {
const created = await createClass(organizationId, name, description);
if (created) setSelectedRef(created.classRef);
}}
/>
</div>
);
}
If
AdminClasseswas a named export consumed insrc/App.tsx, keep the existing export style (default vs named) so the route import doesn’t break. CheckApp.tsx’s import line and match it.
- Step 2: Type-check + build
Run: cd Plings-Web && npx tsc --noEmit && npm run build
Expected: both succeed.
- Step 3: Commit
git add src/pages/admin/AdminClasses.tsx
git commit -m "feat(web): real Class Admin master-detail screen (replaces mock)"
Task 8: Browser-preview verification (the real gate)
Run against the API (merged, or its Vercel preview via BackendSwitcher, or local
dev:local). Log in as Paul (member of “Pauls”).
- Step 1:
preview_start, navigate to/admin/classes. - Step 2:
preview_snapshot— confirm the list shows “Påse 10x15cm” with “4 exemplar” and the class image (after the API backfill ran).preview_console_logs— no errors. - Step 3: Click “+ Ny klass”, fill name + description, Skapa →
preview_snapshotconfirms it appears in the list, selected,0 exemplar. - Step 4: Edit the name on the selected class, Spara → snapshot shows the new name in both panes;
preview_networkshowsupdateObjectClass200. - Step 5: “Byt bild” → upload an image → snapshot shows the new cover;
preview_networkshowssetObjectClassImagesuccess. - Step 6:
preview_screenshotfor the report.
If any step fails: read the source, fix, re-run tsc/build, re-verify. Don’t ask the user to check manually.
Task 9: Flow doc + use-case (use-case catch) + ROADMAP
Files:
- Create:
docs/flows/class-admin.md(drafted from the code) - Create/Modify: a use-case skeleton in
docs/use-cases/+ the row indocs/flows/README.mdindex -
Modify:
ROADMAP.md - Step 1: Draft the flow doc
docs/flows/class-admin.md— the living “Administrera klasser” flow: the master–detail screen, create/edit/image operations, that it’s org-owned + member-gated, links to ADR (classes org-owned) for why and the spec for history. Add its row todocs/flows/README.md. - Step 2: Use-case skeleton in
docs/use-cases/— draft only; the why-users-need-it intent must be confirmed by Paul (ask him; if he defers, leave the skeleton + add “confirm class-admin use-case intent” to ROADMAP Backlog). - Step 3: ROADMAP — add the Class Admin web slice to Done (PR ref + date).
- Step 4: Commit
git add docs/flows/class-admin.md docs/flows/README.md docs/use-cases/ ROADMAP.md
git commit -m "docs(web): Class Admin flow doc + use-case skeleton + ROADMAP"
Task 10: QC + ship
- Step 1: Run the quality-control-enforcer agent over the diff. Address findings.
- Step 2: Push
task/class-admin-web, open the PR (note: merge after the Plings-API PR is live), follow the autonomous merge workflow, then FF the localPlings-Web/devcheckout.
Self-review notes (author)
- Spec coverage: list (Tasks 4/7), create (Tasks 6/7), edit name/description (Tasks 5/7), set image (Tasks 3/5/7), master–detail layout D-E (Task 7), session/admin guard (reused via the existing
/adminAdminRoute— no new guard), flow doc + use-case (Task 9). The web thin-instance-name fallback is explicitly out of scope (admin shows instance counts, not instance names) — consistent with the spec. - Type consistency:
ObjectClassAdminfields match the API’s exactly. The hook returns{ classes, createClass, updateClass, setClassImage, busy, loading, error, refetch }; components consume those names verbatim. - Repo-specific risks flagged inline: confirm the Supabase client import path, the
AdminClassesexport style, and the exactuseOrganizationContextfield names against the live code before finalizing each file.