Zum Hauptinhalt springen
Sachbearbeiter-Cockpit

Daten & Stores

Der Datenzugriff hat zwei Schichten, und die Trennung ist bewusst:

SchichtWasWofür
ServicesKlassen (FilesService, DocumentsService, …) ohne ReactEin Aufruf gegen die ActaNova-API. Imperativ nutzbar, auch außerhalb von Komponenten.
StoresReact-Query-Hooks (useFiles, useFile, …)Dieselben Aufrufe mit Cache, Deduplizierung, Lade-/Fehlerzuständen und Invalidierung.

In Komponenten nimmst du praktisch immer die Hooks. Zu einem Service greifst du, wenn du außerhalb von React etwas laden oder schreiben musst.

Wohin die Aufrufe gehen

Alle Services rufen relativ zur Base-URL /api/cockpit/an auf, die mandantenbezogenen Endpunkte zusätzlich unter /Tenants/Items/<tenant>:

/api/cockpit/an/Tenants/Items/<tenant>/Files/Items?$top=100
/api/cockpit/an/Me

Dein Host muss diesen Pfad weiterleiten. Jeder Aufruf trägt automatisch Authorization: Bearer <access_token>; zur Fehler- und Erneuerungslogik siehe Auth & Rechte.

Die Hooks im Überblick

Alle mandantenbezogenen Hooks nehmen den Tenant optional als letzten Parameter; ohne Angabe kommt er aus dem Context.

HookSignaturQuery-KeyBesonderheit
useFiles(tenant?)["files", tenant]lädt bis zu 100 Einträge ($top=100); Akten mit fileHandlingState === "Destroyed" werden ausgefiltert
useFile(id, tenant?)["file", tenant, id]refetchOnMount: "always" — beim Öffnen der Detailansicht immer frisch
useSaveFile(tenant?)Mutation { id, file }PATCH als merge-patch+json; invalidiert danach ["files", tenant] und ["file", tenant, id]
useGroups(tenant?)["groups", tenant]placeholderData: []; liefert Group[] (Name aus name.de)
useUsers(tenant?)["users", tenant]placeholderData: []; liefert Group[] (Name aus displayName)
useCurrentUser()["me"]nicht mandantenbezogen; staleTime: Infinity
usePersons({ enabled?, tenant? })["persons", tenant]läuft nur mit enabled: true; staleTime: 60 s
usePerson(id, { tenant? })["person", tenant, id]läuft nur mit gesetzter id; staleTime: 60 s
useAnnotations(id, tenant?)["annotations"]placeholderData: []
useDocumentsByFile(fileId, { enabled? }, tenant?)["documents", tenant, fileId]enabled steuert, ob geladen wird
useUploadDocument(tenant?)Mutation { fileId, file }invalidiert danach ["documents", tenant, fileId]
useCompleteActivity(tenant?)Mutation activityIdinvalidiert ["workListActivities"] und ["workListActivity"]

Die Services und ihre Endpunkte

ServiceMethodeAufruf
FilesServicegetFiles(tenant)GET …/Files/Items?$top=100
getFileById(tenant, id)GET …/Files/Items/{id}
saveFileById(tenant, id, file)PATCH …/Files/Items/{id}
DocumentsServicegetDocumentsByFile(tenant, fileId)GET …/Files/Items/{fileId}/Documents/Items
uploadDocument(tenant, fileId, file)zwei Schritte, siehe unten
getDefaultDocumentTypeId(tenant)GET …/DocumentClassificationTypes/ItemReferences
getDefaultLeadingGroupId()GET /Me/Groups/Items (erste Gruppe)
GroupServicegetGroups(tenant)GET …/Groups/Items
UserServicegetMe()GET /Me
getUsers(tenant)GET …/Users/Items
getMyGroups()GET /Me/Groups/Items
PersonsServicegetPersons(tenant)GET …/Persons/Items/
getPersonById(tenant, id)GET …/Persons/Items/{id}
AnnotationsServicegetAnnotations(tenant, fileId)GET …/Files/Items/{fileId}/Annotations/Items
ActivitiesServicecompleteActivity(tenant, id)POST …/Activities/Items/{id}/Complete

Die Services normalisieren die Antworten: die übliche { data: [...] }-Hülle wird ausgepackt, Gruppen und User auf die Form Group (name, $apiId, referencedApiType) gebracht, Dokumente auf DocumentSummary (name, size in Bytes, url — die zeigt auf den aktiven Inhalt aus $apiLinks.getActiveContent).

Der Upload ist zweistufig

ActaNova nimmt eine Datei nicht in einem Rutsch an. uploadDocument erledigt beides hintereinander — du rufst trotzdem nur eine Methode auf:

0. Vorgabe-Ids auflösen DocumentType + Leading Group (gecacht)
1. POST …/ContentObjects/CreateTemporary → temporäres ContentObject
2. POST …/Documents/Items → Document, das ContentObject + Akte verknüpft

Schritt 0 läuft absichtlich zuerst und parallel: schlägt ein Lookup fehl, entsteht kein verwaistes temporäres ContentObject. Zwei Ids werden gebraucht:

IdWoher
DocumentTypeGET …/DocumentClassificationTypes/ItemReferences, daraus der Eintrag mit referenceId === "!DocumentClassificationType!CommonDocumentType" — so bleibt die tenant-spezifische $apiId unhartkodiert
Leading Grouperste Gruppe aus GET /Me/Groups/Items

Gecacht wird das Promise, nicht der Wert: parallele Uploads teilen sich denselben laufenden Request. Fehlschläge landen nicht im Cache, der nächste Versuch fragt also neu. Der DocumentType-Cache gilt pro Tenant, die Leading Group pro Session (/Me/… ist user-, nicht mandantenbezogen). Wer den Lookup vom ersten Upload entkoppeln will, ruft getDefaultDocumentTypeId(tenant) schon beim Mount der App auf — nötig ist das nicht.

Schritt 1 schickt die Datei als FormData (Feld formFile) an CreateTemporary. Schritt 2 legt das Document an und verknüpft darin parentObject (die Akte), contentObject (aus Schritt 1), documentType und leadingGroup; name ist der Dateiname, physicallyPresent ist false.

Fehler aus diesem Ablauf, die im error der Mutation landen:

MeldungBedeutet
No DocumentClassificationType with referenceId "…" found for tenant "…"Schritt 0: der Standard-Dokumenttyp fehlt für diesen Mandanten
No group with $apiId returned from /Me/Groups/Items — cannot determine leading groupSchritt 0: der User ist in keiner Gruppe
No $apiId returned from CreateTemporary endpointSchritt 1: das Backend hat kein temporäres ContentObject geliefert

Im Mock-Modus entfallen beide Requests; die Datei landet in einem In-Memory-Bestand und taucht beim nächsten Laden der Liste tatsächlich auf.

Eigene Endpunkte ohne eigenen Service

Für alles, was die Library nicht abdeckt, gibt es makeCall auf jedem Service. Base-URL, Tenant-Prefix und Auth-Header kommen automatisch — du bestimmst nur den Pfad dahinter:

import { BaseService } from '@gentics/cockpit/core';

const api = new BaseService();

// GET
const akten = await api.makeCall('/Files/Items?$top=100');

// PATCH mit Body
await api.makeCall(`/Files/Items/${id}`, {
method: 'PATCH',
body: { remark: 'geprüft' },
headers: { 'Content-Type': 'application/merge-patch+json' },
});

Den Tenant musst du nicht mitgeben: makeCall nimmt den aktiven Tenant der laufenden App — denselben, den useTenant() liefert. Nur wenn du bewusst gegen einen anderen Mandanten willst, setzt du ihn:

await api.makeCall('/Files/Items', { tenant: 'anderer-mandant' });

await api.makeCall(tenant, '/Files/Items'); // ältere Form, weiterhin gültig

Läuft der Aufruf außerhalb jeder App — also ohne TenantProvider, etwa auf der Sachbearbeiter-Cockpit-Übersicht —, gibt es keinen aktiven Tenant. Dann wirft makeCall, statt gegen einen kaputten Pfad zu laufen:

Error: Kein Tenant verfügbar: weder übergeben noch aus der laufenden App.

FormData als Body wird erkannt und ohne Content-Type gesendet, damit der Browser die Boundary setzt.

In einer Komponente wickelst du das in React Query — dann verhält sich dein Aufruf wie die eingebauten Hooks. Den Tenant holst du dir hier trotzdem per Hook, weil er in den Query-Key gehört:

import { useQuery } from '@tanstack/react-query';
import { BaseService, useTenant } from '@gentics/cockpit/core';

const api = new BaseService();

export function useAktenTypen() {
const tenant = useTenant();
return useQuery({
queryKey: ['fileTypes', tenant],
queryFn: () => api.makeCall<{ data: unknown[] }>('/FileTypes/Items'),
});
}

Braucht dein Aufruf mehr als einen Endpunkt oder eigene Normalisierung, leite stattdessen von BaseService ab — die geschützten Helfer apiGet, apiPost, apiPatch und apiPostFormData stehen dann zur Verfügung. Willst du dort denselben Automatismus, nimm this.resolveTenant(tenant?): gibt den übergebenen Tenant zurück oder den aktiven der App. Außerhalb von React geht auch getActiveTenant() — das liefert null, wenn keine App läuft.

Eigene Stores anbinden

Zwei Widgets arbeiten nicht mit einer einzelnen Query, sondern mit einem Bündel aus Lesen und Schreiben. Das kannst du ersetzen — damit lassen sich die Widgets auf andere Objekttypen als File richten.

ObjectStore für Assignment

import type { ObjectStore } from '@gentics/cockpit/core';

const antraegeStore: ObjectStore = {
useOne: (id, tenant) => useAntrag(id, tenant),
useList: (tenant) => useAntraege(tenant),
useSave: (tenant) => useSaveAntrag(tenant),
};

<Assignment type="group" id={antragId} store={antraegeStore} />

Das Speichern kommt immer aus demselben Bündel — eine separate Save-Prop gibt es nicht. Ohne store nutzt Assignment den fertigen filesStore (die drei Files-Hooks). Die drei Member müssen diese Signaturen erfüllen:

MemberSignatur
useOne(id: string, tenant?: string) => UseQueryResult<JsonObject, Error>
useList(tenant?: string) => UseQueryResult<JsonArray, Error>
useSave(tenant?: string) => UseMutationResult<boolean, Error, { id: string; file: JsonObject }>

Assignment schreibt die Auswahl je nach type nach leadingGroup bzw. leadingUser — dein Objekt muss diese Felder also kennen.

DocumentsStore für den FileManager

const store = {
useByFile: (fileId, options, tenant) => useMeineDokumente(fileId, options, tenant),
useUpload: (tenant) => useMeinUpload(tenant),
};

<FileManager dataSource={quelle} documentsStore={store} />

Default ist DEFAULT_DOCUMENTS_STORE (useDocumentsByFile + useUploadDocument). Welche Felder die Bündel liefern müssen, steht unter DocumentsStore.

Fehler

BaseService wirft bei jeder Antwort, die nicht ok ist:

Error: API Error: 404 Not Found

Über die Hooks landet das im error des Query-Ergebnisses — behandle es also wie bei jedem React-Query-Hook (isError, error). 204 No Content wird als true zurückgegeben, andere leere Antworten als undefined.

Weiter