Hoppa till innehållet

Devportalen /portal/dev

Startsideportalens ekosystem för människa och agent

Devportalen

Recept

Steg för de fem uppgifter som återkommer: kort, nyckeltal, handlingsrad, fokusflöde och mockdata.

Den kanoniska förlagan

Recepten nedan säger hur. packages/modules/arenden visar hur det ser ut när de är följda – en riktig modul i drift, inte ett exempel byggt för att visas upp. Visar mest av modulkontraktet på minst kod: nav, en main-slot och en actions-provider i samma manifest, med data och typer åtskilda från komponenten. Läs den i ordningen nedan och diffa ditt eget arbete mot den.

Tabell: Fil, Vad den visar
FilVad den visar
packages/modules/arenden/src/manifest.tsManifestet. defineModule med nav, slots.main och slots.actions – actions är en funktion eftersom innehållet kommer per användare.
packages/modules/arenden/src/data/types.tsDatakontraktet. Formen ett riktigt mikrotjänst-API skulle ge, inte formen komponenten råkar vilja ha.
packages/modules/arenden/src/data/mock.tsMockdatan, skild från hämtningen. Bytet till en riktig källa ska bli en adapterfråga.
packages/modules/arenden/src/data/index.tshamtaArenden(ctx): den enda vägen in till datan. Speglas av /api/mock/arenden.
packages/modules/arenden/src/components/Arendetabell.tsxServer component byggd enbart på primitiver ur @startsidan/ui. Ingen egen CSS, inga hårdkodade värden.
apps/portal/src/app/dev/portal/katalog.tsKatalogposten arenden: identiteten, källan, relationerna. Skriven i samma commit som koden.
apps/portal/src/app/dev/portal/dok.tsDoket arenden: ansvar, filkarta, API, kontrakt, kvarstår. Utan det räknas modulen som påbörjad.

De två sista raderna är inte efterarbete. En modul utan katalogpost och dok räknas som påbörjad, inte levererad, och drifttesterna fäller bygget – också för förlagan själv.

Skapa modulen från noll

Fyra filer och en rad i registret. Kopiera dem rakt av – formen är densamma i alla moduler, och det är den likheten som gör dem läsbara.

packages/modules/<namn>/package.json

{
  "name": "@startsidan/modul-<namn>",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./data": "./src/data/index.ts"
  },
  "files": ["src"],
  "scripts": { "typecheck": "tsc --noEmit" },
  "dependencies": {
    "@startsidan/module-sdk": "workspace:^",
    "@startsidan/ui": "workspace:^"
  },
  "peerDependencies": { "react": "^19.0.0" },
  "devDependencies": {
    "@types/react": "^19.2.0",
    "typescript": "~6.0.3"
  }
}
  • Paketet publiceras aldrig – private och workspace:^ hela vägen.
  • exports-mappen har två ingångar: "." för manifestet och "./data" så att route handlern kan spegla datafunktionen.
  • react är peerDependency, inte dependency: appen äger versionen.
  • Inga lint- eller test-skript behövs. Roten lintar och testar hela repot på filnamn, så en ny modul täcks utan att någon lägger till något.

packages/modules/<namn>/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src"]
}
  • noUncheckedIndexedAccess: en uppslagning i en array eller ett Record ger T | undefined. Räkna med det i stället för att lägga till ett utropstecken.
  • verbatimModuleSyntax: typimporter måste skrivas import type { … }, annars faller bygget.

packages/modules/<namn>/src/index.ts

export { manifest } from "./manifest";
export { hamtaNagot } from "./data";
export type { Nagot, NagotSvar } from "./data";

packages/modules/<namn>/src/data/index.ts

import type { Role, UserContext } from "@startsidan/module-sdk";

/** Formen är den ett mikrotjänst-API skulle svara med. */
export interface Nagot {
  id: string;
  rubrik: string;
  kommentar: string;
}

export interface NagotSvar {
  hamtad: string;
  poster: Nagot[];
}

const mock: Record<Role, NagotSvar> = {
  chef: { hamtad: "2026-08-20T07:00:00+02:00", poster: [] },
  medarbetare: { hamtad: "2026-08-20T07:00:00+02:00", poster: [] },
};

/** Den enda ingången till modulens data, och det enda som ändras i fas 4. */
export async function hamtaNagot(ctx: UserContext): Promise<NagotSvar> {
  return mock[ctx.role];
}

packages/modules/<namn>/src/manifest.ts

import { defineModule } from "@startsidan/module-sdk";
import { NagotKort } from "./components/NagotKort";

export const manifest = defineModule({
  id: "<namn>",
  name: "Visningsnamn",
  roles: ["medarbetare", "chef"],
  nav: [{ label: "Visningsnamn", href: "#", order: 50 }],
  slots: {
    main: [{ id: "<namn>-kort", order: 40, Component: NagotKort }],
  },
});

Komponenten skrivs enligt nästa recept. Därefter registreras modulen – tre rader, se Modulkontraktet.

  1. Sist: dokumentera modulen

    Modulen är inte klar förrän den har en katalogpost och ett dok. Båda är data, båda ligger i portalen, och båda skrivs i samma commit som koden.

    // apps/portal/src/app/dev/portal/katalog.ts – identiteten
    { id: "<namn>", app: "portal", slag: "Modul", namn: "<namn>", status: "Byggd",
      beskrivning: "…", kalla: "packages/modules/<namn>",
      relationer: ["ui", "module-sdk"], levererar: ["slots.main: <namn>-kort"],
      roller: ["medarbetare", "chef"] }
    
    // apps/portal/src/app/dev/portal/dok.ts – djupet
    { id: "<namn>", sammanfattning: "…", ansvar: [...], filer: [...],
      api: [...], beroenden: [...], kvarstar: [...], uppdaterad: "ÅÅÅÅ-MM-DD" }

Lägg till ett kort på arbetsytan

  1. Skriv komponenten

    En async server component som hämtar sin data och renderar ett Card. Rubriken sätts av Card – skriv aldrig en egen h2 inuti.

    // packages/modules/<namn>/src/components/NagotKort.tsx
    import { Card, Chip, ListRow, MoreLink } from "@startsidan/ui";
    import type { UserContext } from "@startsidan/module-sdk";
    import { hamtaNagot } from "../data";
    
    export async function NagotKort({ ctx }: { ctx: UserContext }) {
      const { poster } = await hamtaNagot(ctx);
    
      return (
        <Card headingId="nagot-h" title="Rubrik" count={poster.length}
              action={<MoreLink href="#">Visa alla →</MoreLink>}>
          <ul className="grid list-none">
            {poster.map((p) => (
              <ListRow key={p.id} title={p.rubrik} meta={p.kommentar}
                       end={<Chip tone={p.chip.ton}>{p.chip.etikett}</Chip>} />
            ))}
          </ul>
        </Card>
      );
    }
  2. Anmäl den i manifestet

    main för huvudinnehåll, side för stödinformation. order är lägre först.

    slots: { main: [{ id: "nagot-kort", order: 30, Component: NagotKort }] }
  3. Kontrollera mot designsystemet

    headingId satt, tokens i stället för hex, statusfärg i chip eller kant – inte på hela kortet. Felindexet på /dev/ui listar resten.

Lägg till ett nyckeltal

Nyckeltalsraden har fyra spår över hela arbetsytans bredd. Värdet bär tonen, aldrig hela rutan.

// datat: en post per ruta
interface NyckeltalPost {
  id: string;
  etikett: string;                       // versal, kort
  varde: string;
  enhet?: string;
  kommentar: string;
  ton?: "neutral" | "positiv" | "varning";
  lank: string;
}

// manifestet
slots: { kpi: [{ id: "<namn>-nyckeltal", order: 0, Component: Nyckeltal }] }

Rutans markup finns som mönstret monster.nyckeltal på /dev/ui – kopiera därifrån i stället för att skriva om den.

Lägg till en rad i "Att agera på"

Modulen bidrar med data, inte med ett kort. Kortet agera renderar raderna åt alla (ADR-007).

// packages/modules/<namn>/src/manifest.ts
async function actions(ctx: UserContext): Promise<ActionItem[]> {
  if (ctx.role !== "chef") return [];

  const { antal } = await hamtaNagot(ctx);
  if (!antal) return [];

  return [
    {
      id: "<namn>-attest",
      order: 20,
      status: "warn",                       // krit när tidsgränsen passerats
      title: "Attest: " + antal + " ansökningar",
      meta: "Kontrollerad mot schema och saldo",
      chip: { tone: "warn", label: "Väntar" },
      action: { label: "Godkänn", href: "#", variant: "primary" },
    },
  ];
}

export const manifest = defineModule({ …, slots: { actions } });
  • Returnera tom lista när det inte finns något att göra – raden ska aldrig visas tom.
  • status följer av data: passerad tidsgräns → krit, väntar på någon → warn, klart att utföra → ok.
  • Är underlaget AI-genererat sätts aiNote, t.ex. "✦ Utkast finns".

Bygg ett fokusflöde

  1. Deklarera rutten

    Skalet renderar vilken modulrutt som helst. mode: "focus" ger topprad med Avbryt och inget annat krom.

    routes: [{
      path: "/flode/<namn>",
      title: "Rubrik i toppraden",
      mode: "focus",
      Component: MittFlode,
    }]
  2. Skriv flödet som data

    FlowDefinition med autoFields och askFields. Skrivs flödet som data redan nu blir M4 en flytt i stället för en omskrivning.

  3. Lägg serverändan i en route handler

    Prompt, profil och beslutet att skapa något bor på servern. Klienten skickar text och renderar svaret.

    // apps/portal/src/app/api/flows/<namn>/route.ts
    const user = await getCurrentUser();          // identiteten ur sessionen
    if (!inomTakgrans(user.userId)) return 429;
    const rad = Body.safeParse(await request.json());   // zod
    // … kör turen, validera, besluta
  4. Visa kvittot

    Samtalet är inmatningen, formuläret är kvittot. Användaren ska se i realtid vad assistenten uppfattat – AI-tonad kant och ✦ per fält.

  5. Skapa aldrig utan bekräftelse

    Att modellen sätter create: true räcker aldrig. Servern kontrollerar själv att allt obligatoriskt finns, och användaren bekräftar uttryckligen.

Lägg till mockdata

Datat bor i modulen, inte bakom ett nätverksanrop. Komponenten är en server component och anropar funktionen direkt – en fetch mot den egna appen vore ett onödigt hopp ut och tillbaka. Formen på datat ska ändå vara den ett mikrotjänst-API skulle ge, så att fas 4 blir ett byte inuti funktionen.

packages/modules/<namn>/src/data/
├── types.ts     formen: samma som mikrotjänsten skulle svara med
├── mock.ts      datat (större moduler; små håller allt i index.ts)
└── index.ts     hamtaNagot(ctx) – den enda ingången, och det enda
                 som ändras när källan blir riktig

Route handlern speglar samma funktion

// apps/portal/src/app/api/mock/<namn>/route.ts
import { hamtaNagot } from "@startsidan/modul-<namn>";
import { getCurrentUser, toContext } from "../../../../shell";

export async function GET() {
  const user = await getCurrentUser();
  return Response.json(await hamtaNagot(toContext(user)));
}
  • Route handlern är dokumentation, inte datakälla: den visar vilken form API:et skulle ha. Modulen anropar aldrig sin egen endpoint.
  • Modulen exporterar datafunktionen i sin index.ts så att route handlern kommer åt den.
  • Rollskillnader hör hemma i datat eller i funktionen, inte i komponenten.
  • Klientkomponenter som ska skriva något anropar en riktig route handler – se felanmalan/src/store.ts.