Zum Hauptinhalt springen
Sachbearbeiter-Cockpit

Erste App bauen

Wir bauen die App aus Sachbearbeiter-Cockpit aufsetzen — eine Aktenliste mit Detailansicht. Am Ende steht eine App, die Daten lädt, anzeigt, navigiert und eine Zuweisung speichert.

Innerhalb der App ist alles schon bereitgestellt: React Query, der aktive Mandant und der Router. Du kannst die Store-Hooks also direkt aufrufen.

Das Grundgerüst

Eine App ist eine gewöhnliche React-Komponente. Der Typ AppComponent beschreibt, was sie bekommt ({ app }):

// src/apps/akten/AktenApp.tsx
import { Routes, Route } from 'react-router';
import type { AppComponent } from '@gentics/cockpit/core';
import { AktenListe } from './AktenListe';
import { AktenDetail } from './AktenDetail';

export const AktenApp: AppComponent = ({ app }) => (
<div className="p-6">
<h1 className="mb-4 text-xl font-bold">{app.title}</h1>

{/* Eigene, RELATIVE Routen der App. Das Sachbearbeiter-Cockpit hat die App
unter /:appId/* gemountet – hier gibt es keine führenden Slashes. */}
<Routes>
<Route index element={<AktenListe />} />
<Route path=":fileId" element={<AktenDetail />} />
</Routes>
</div>
);

Mehr zu den Routen — auch wie tief du sie schachteln kannst — steht unter Routing in Apps.

Die Liste

useFiles() lädt die Akten des aktiven Mandanten, List zeigt sie an. Die Spalten sind Konfiguration, keine JSX:

// src/apps/akten/AktenListe.tsx
import { useNavigate } from 'react-router';
import { useFiles, type IListColumns } from '@gentics/cockpit/core';
import { List } from '@gentics/cockpit/widgets';

const columns: IListColumns[] = [
{ label: 'Betreff', path: 'title.de', width: 4 },
{ label: 'Zahl', path: 'formattedNumber', width: 2 },
{ label: 'Art', path: 'fileType.displayName', width: 2, hasFilter: true },
{
label: 'Status',
path: 'fileHandlingState',
width: 2,
type: 'badge',
hasFilter: true,
badgeConfig: {
Work: { label: 'In Bearbeitung', color: '#137DFF' },
Closed: { label: 'Abgeschlossen', color: '#1A7F37' },
},
},
{ label: 'Angelegt', path: 'openingDate', width: 2, type: 'date' },
];

export function AktenListe() {
const navigate = useNavigate();

return (
<List
columns={columns}
dataSource={useFiles}
defaultSort={{ path: 'openingDate', direction: 'desc' }}
onItemClick={(item) => navigate(item.$apiId)}
/>
);
}

Drei Dinge sind hier wichtig:

  • dataSource ist eine Funktion, kein Ergebnis. List ruft sie beim Rendern selbst auf. dataSource={useFiles} ist deshalb richtig, dataSource={useFiles()} falsch. Und dataSource={() => useFiles()} läuft zwar, ist aber ein Lint-Fehler — ein Hook darf nicht in einer Inline-Funktion stehen, mehr dazu unter Datenquellen.
  • Freitextsuche, Spaltenfilter und Sortierung bringt List mit. Filter erscheinen für jede Spalte mit hasFilter: true; Sortierung und Filterwahl werden im localStorage gemerkt.
  • navigate(item.$apiId) ist relativ und landet damit auf /akten/<id> — genau der Route :fileId von oben.

Die Detailansicht: eine Datenquelle für alles

In der Detailansicht brauchen mehrere Widgets dieselbe Akte. Definiere die Quelle einmal und gib sie überall mit — React Query bündelt die Aufrufe über den Query-Key zu einem Request:

// src/apps/akten/AktenDetail.tsx
import { useParams } from 'react-router';
import { useFile } from '@gentics/cockpit/core';
import { Badge, Field, FileManager, Assignment } from '@gentics/cockpit/widgets';

export function AktenDetail() {
const { fileId = '' } = useParams();

// EINE Datenquelle – an jedes Widget weitergegeben. Der Name beginnt mit
// `use`, weil ein Hook darin steckt (sonst meckert react-hooks/rules-of-hooks).
const useAkte = () => useFile(fileId);

return (
<div className="flex flex-col gap-6 lg:flex-row">
<div className="flex-1">
<Field dataSource={useAkte} jsonPath="title.de" label="Betreff" />
<Field dataSource={useAkte} jsonPath="formattedNumber" label="Zahl" />
<Field dataSource={useAkte} jsonPath="openingDate" label="Angelegt am" />

<Badge
value="fileHandlingState"
dataSource={useAkte}
config={{ Work: { label: 'In Bearbeitung', color: '#137DFF' } }}
/>
</div>

<div className="w-full lg:w-96">
{/* Liest die Kontext-Id selbst als `$apiId` aus der Quelle. */}
<FileManager dataSource={useAkte} accept=".pdf,image/*" maxFiles={10} />

{/* Speichert über den Standard-Store (filesStore) – kein Save-Prop nötig. */}
<Assignment type="group" id={fileId} />
</div>
</div>
);
}

Drei Dinge, über die man hier typischerweise stolpert:

  • Warum () => useFile(fileId) und nicht useFile direkt? Weil die Widgets dataSource() ohne Argumente aufrufen. useFiles passt deshalb direkt in die Prop, useFile braucht die fileId — die kommt aus dem Closure.
  • useFile oder filesStore.useOne? Das ist dieselbe Funktion: filesStore ist nur ein Bündel { useOne: useFile, useList: useFiles, useSave: useSaveFile }. In eigenem Code nimm den konkreten Namen; useOne brauchst du erst, wenn ein Widget ein ganzes Bündel bekommt (store-Prop).
  • Doppeltes Laden gibt es nicht. Alle vier Widgets erzeugen denselben Query-Key und hängen damit am selben Cache-Eintrag.

Alle drei Punkte im Detail — samt Randfällen wie leerer Id, enabled und Fehleranzeige — stehen unter Datenquellen.

Lade- und Fehlerzustände

Darum musst du dich meist nicht kümmern: Field, List und Submitter zeigen von sich aus ein Skeleton, solange die Query isPending oder isPlaceholderData meldet (der Badge einen leeren Platzhalter). Der FileManager zeigt zusätzlich eine Fehlermeldung mit „Erneut versuchen".

Zwei Fälle solltest du kennen, weil sie beim Entwickeln auffallen:

SituationWas du siehst
Pfad existiert, Wert ist nullPlatzhalter
Pfad existiert nicht im Objektroter Entwickler-Hinweis Field.tsx – Could not find <pfad> for the given data

Der zweite Fall ist Absicht: ein Tippfehler im jsonPath soll auffallen und nicht als leeres Feld durchgehen.

Speichern und Rückmeldung geben

Zum Schreiben nimmst du die Mutation aus dem Store; sie invalidiert danach die betroffenen Queries selbst, sodass die Anzeige aktuell wird. Für Meldungen gibt es den Toast-Kanal der Widgets:

import { useState } from 'react';
import { useFile, useSaveFile } from '@gentics/cockpit/core';
import { widgetToast, ToastHost } from '@gentics/cockpit/widgets';

function AnmerkungBearbeiten({ fileId }: { fileId: string }) {
const { data: akte } = useFile(fileId);
const save = useSaveFile();
const [remark, setRemark] = useState('');

const onSave = async () => {
try {
// PATCH als merge-patch+json: die geänderten Felder genügen.
await save.mutateAsync({ id: fileId, file: { remark } });
widgetToast.success('Gespeichert');
} catch {
widgetToast.error('Speichern fehlgeschlagen');
}
};

return (
<div>
<textarea
defaultValue={(akte?.remark as string) ?? ''}
onChange={(e) => setRemark(e.target.value)}
/>
<button onClick={onSave} disabled={save.isPending}>
{save.isPending ? 'Speichere …' : 'Speichern'}
</button>

{/* Widgets bringen den Host selbst mit; ein zusätzlicher an der Wurzel
ist unschädlich – und nötig, wenn du selbst Toasts auslöst. */}
<ToastHost />
</div>
);
}

Nach erfolgreichem useSaveFile() invalidiert der Store ["files", tenant] und ["file", tenant, id] — die Liste und alle Widgets, die an derselben Quelle hängen, aktualisieren sich also von selbst.

hinweis
Field schreibt nicht zurück

<Field editable /> rendert ein Input-Feld, gibt Änderungen aber nicht an einen Store weiter — es gibt derzeit keinen onChange-Weg. Zum Speichern brauchst du wie oben ein eigenes Formular plus useSaveFile().

Weiter