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: /adminAdminRouteAdminLayoutclasses).
  • 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 for myObjectClasses, 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 — add ObjectClassAdmin.
  • 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 in docs/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 existing ObjectClass):
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 useImageUpload posts to /register-image/{objectId} (object-scoped). Class images have no object, so we upload to the same object-images bucket under a classes/ObjectClass/{classRef}/… path and return the public URL; the caller then runs setObjectClassImage.

  • 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() in useImageUpload.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 AdminClasses was a named export consumed in src/App.tsx, keep the existing export style (default vs named) so the route import doesn’t break. Check App.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_snapshot confirms 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_network shows updateObjectClass 200.
  • Step 5: “Byt bild” → upload an image → snapshot shows the new cover; preview_network shows setObjectClassImage success.
  • Step 6: preview_screenshot for 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 in docs/flows/README.md index
  • 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 to docs/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 local Plings-Web/dev checkout.

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 /admin AdminRoute — 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: ObjectClassAdmin fields 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 AdminClasses export style, and the exact useOrganizationContext field names against the live code before finalizing each file.