Zum Inhalt

Detail-Tabellen (1:n) — Referenz

kc:childTable macht aus einem JSON-Schema-Feld vom Typ array eine echte relationale 1:n-Beziehung mit eigener Datenbank-Tabelle. Typische Anwendungen: Bestellpositionen unter einer Bestellung, Belege unter einer Reisekostenabrechnung, Ansprechpartner unter einem Kunden.

Einstieg: Klick-Tutorial · JSON-Tutorial.

Schema-Grundform

{
  "positionen": {
    "type": "array",
    "title": "Positionen",
    "kc:colspan": "full",
    "kc:childTable": {
      "initialRows": 1,
      "properties": {
        "artikel": { "type": "string", "title": "Artikel" },
        "menge":   { "type": "number", "title": "Menge", "kc:decimals": 0 },
        "preis":   { "type": "number", "title": "Preis", "kc:decimals": 2 },
        "summe":   { "type": "number", "title": "Summe", "kc:decimals": 2,
                     "kc:calc": { "type": "arithmetic", "op": "multiply",
                                  "operands": ["menge", "preis"] } }
      }
    }
  }
}

Die Spalten verstehen im Wesentlichen denselben Eigenschaftssatz wie normale Felder: kc:decimals, kc:readonly, kc:required, kc:lookup, kc:picker, kc:calc, format, pattern/kc:patternMode, kc:align usw.

Im Formular-Editor pflegst du die Spalten in den Eigenschaften der Detail-Tabelle (Bereich Felder → Detailzeilen-Spalten, + Spalte); ein Doppelklick auf eine Spalte in der Vorschau öffnet deren eigene Eigenschaften.

Einstellungen der Detail-Tabelle

Schlüssel (in kc:childTable) Bedeutung
properties die Spalten — JSON-Schema-Form, auch verschachtelte Detail-Tabellen
initialRows so viele leere Zeilen erhält ein neuer Datensatz (Standard 0)
keepEmptyRows true = komplett leere Zeilen beim Speichern behalten (Standard: verwerfen)
layout "block" zeigt jede Zeile als eigenen Formularblock statt als Tabellenzeile
blockTitle Überschrift je Block, z. B. "Position ${index}" (1-basiert)
blockLayout Raster im Block: vertical, horizontal-2, horizontal-3
blockCollapsible true = Blöcke lassen sich auf- und zuklappen (Akkordeon)
collapsibleSubRows true = verschachtelte Unter-Tabellen je Zeile einklappbar

Am Feld selbst (neben kc:childTable) wirken zusätzlich die üblichen Feld-Eigenschaften, z. B. kc:colspan: "full" für die volle Breite. Einzelne Spalten lassen sich mit kc:colWidthFactor breiter oder schmaler stellen (relativ zur gleichmäßigen Verteilung).

Block-Modus auf schmalen Bildschirmen

Unter 768 px Breite (Telefon, schmales Fenster) zeigt KCForms jede Detail-Tabelle automatisch im Block-Modus. Das Schema ändert sich dabei nicht.

Wie das in der Datenbank liegt

Beim Speichern der Formular-Definition (und bei jedem Backend-Start) legt KCForms für jede Detail-Tabelle eine eigene Tabelle an:

form_<formid>_<feldname>
   parent_id   FK auf form_<formid>.id, ON DELETE CASCADE
   lfd_nr      laufende Nummer innerhalb des Datensatzes
   <je Spalte eine eigene Spalte>
   PRIMARY KEY (parent_id, lfd_nr)
Formular-ID Feldname Tabelle
bestellung positionen form_bestellung_positionen
reisekosten belege form_reisekosten_belege

Bindestriche und andere Sonderzeichen der Formular-ID werden zu Unterstrichen (kosten-im-wirtschaftsjahr → form_kosten_im_wirtschaftsjahr).

Die Spaltentypen leitet KCForms aus der Spalte ab:

Spalte Datenbank (MySQL-Schreibweise)
type: integer BIGINT
type: number DECIMAL mit kc:decimals Nachkommastellen (Standard 4)
type: boolean BOOLEAN
kc:widget: textarea LONGTEXT
Text mit kc:maxLength VARCHAR(n), über 4 000 Zeichen LONGTEXT
format: date DATE
format: date-time TIMESTAMP
sonstiger Text VARCHAR(255)

Ältere Datumsspalten

Vor dem Stand vom 19.09.2026 legte KCForms Datumsspalten in Detail-Tabellen als Text (VARCHAR) an. Solche bestehenden Spalten bleiben unverändert und funktionieren weiter; nur neu hinzukommende Datumsspalten werden DATE bzw. TIMESTAMP. Wer eine alte Spalte umstellen will, macht das per SQL (Werte stehen dort als JJJJ-MM-TT).

Neue Spalten legt KCForms beim Speichern im Formular-Editor und beim Start automatisch per ALTER TABLE … ADD an.

Laden und Speichern

Beim Laden eines Datensatzes liest KCForms die Kopfzeile aus form_<formid> und für jede Detail-Tabelle die zugehörigen Zeilen. Im Formular und in ctx.form stehen sie als Liste unter dem Feldnamen.

Beim Speichern schreibt KCForms die Kopfzeile und ersetzt dann für jede Detail-Tabelle alle Zeilen des Datensatzes: alte löschen, aktuelle in Reihenfolge neu einfügen (lfd_nr = Position).

Keine Fremdschlüssel auf einzelne Zeilen

Weil die Zeilen bei jedem Speichern neu geschrieben werden, lohnt sich ein Fremdschlüssel von außen auf eine einzelne Zeile nicht. Wer auf Zeilen verweisen muss, verweist auf den Kopf-Datensatz.

Leere Zeilen

Komplett leere Zeilen verwirft KCForms beim Speichern — etwa per initialRows vorbelegte, die niemand ausgefüllt hat. Als leer gilt eine Zeile, wenn keine Eingabespalte einen Wert hat und keine Unter-Tabelle eine gefüllte Zeile; berechnete und schreibgeschützte Spalten zählen dabei nicht mit, ein nicht angehaktes Kästchen auch nicht. Sollen leere Zeilen erhalten bleiben: keepEmptyRows: true in kc:childTable (im Formular-Editor: Dataset-Eigenschaften der Tabelle → Leere Zeilen speichern).

Über die REST-Schnittstelle des Formulars gilt dasselbe Format: POST /api/forms/{id}/submit nimmt die Zeilen als Liste unter dem Feldnamen entgegen, GET /api/forms/{id}/instances/{instanceId} liefert sie so zurück.

Vorbelegung: initialRows

initialRows füllt einen neuen Datensatz mit der angegebenen Anzahl leerer Zeilen, damit der Anwender gleich lostippen kann. 0 (Standard) bedeutet: Die Tabelle startet leer, der Anwender klickt + Zeile hinzufügen. Gespeicherte Datensätze behalten ihre tatsächlichen Zeilen.

Skript-Zugriff

Die Zeilen stehen in ctx.form.<feldname> als Liste von Objekten. Lesen und Ändern bestehender Zeilen funktioniert mit den üblichen Schleifen:

// onValidate: mindestens eine Position mit Menge
var pos = ctx.form.positionen || [];
var mitMenge = pos.filter(function (p) { return kc.num(p.menge) > 0; });
if (mitMenge.length === 0) {
  ctx.addError('positionen', 'Mindestens eine Position mit Menge erforderlich.');
}
// onFieldChange: Zeilenwerte ändern
var pos = ctx.form.positionen || [];
pos.forEach(function (p) {
  if (!p) return;
  p.hinweis = kc.num(p.menge) > 100 ? 'Großmenge' : '';
});

Einfache Rechnungen (Zeile = Menge × Preis, Summe über alle Zeilen) brauchen kein Skript — dafür gibt es Berechnungsfelder.

Neue Zeilen per Skript

Zeilen hinzufügen (ctx.form.positionen.push({...})) oder die Liste durch ein neues JavaScript-Array ersetzen funktioniert derzeit nicht zuverlässig. Bestehende Zeilen ändern geht dagegen problemlos.

Eine objektorientierte Sicht mit Zeilen, Elementen und Unter-Tabellen bietet ctx.model.

Picker in Spalten

Eine Spalte kann einen Picker haben. In der Zelle erscheint dann ein …-Knopf:

"positionen": {
  "type": "array",
  "kc:childTable": {
    "properties": {
      "artikelnr": {
        "type": "string", "title": "Artikel-Nr.",
        "kc:picker": {
          "table": "bueromaterial",
          "search":  ["artikelnr", "bezeichnung"],
          "columns": [
            { "column": "artikelnr",   "title": "Nr." },
            { "column": "bezeichnung", "title": "Bezeichnung" },
            { "column": "preis",       "title": "Preis" }
          ],
          "set": {
            "artikelnr":   "artikelnr",
            "bezeichnung": "bezeichnung",
            "preis":       "preis"
          }
        }
      },
      "bezeichnung": { "type": "string", "kc:readonly": true },
      "menge":       { "type": "number", "kc:decimals": 0 },
      "preis":       { "type": "number", "kc:decimals": 2, "kc:readonly": true }
    },
    "initialRows": 1
  }
}

set zielt auf Spalten derselben Zeile — wählt der Anwender in der 3. Zeile einen Artikel, werden bezeichnung und preis in der 3. Zeile gefüllt.

Auswahllisten in Spalten

"belege": {
  "type": "array",
  "kc:childTable": {
    "properties": {
      "kategorie": {
        "type": "string", "title": "Kategorie",
        "kc:lookupOptions": [
          { "key": "FAHRT",       "value": "Fahrtkosten" },
          { "key": "VERPFLEGUNG", "value": "Verpflegung" },
          { "key": "UEBERNACHT",  "value": "Übernachtung" }
        ]
      },
      "betrag": { "type": "number", "kc:decimals": 2 }
    }
  }
}

Auswahllisten in Spalten werden je Zeile ausgewertet; abhängige Listen innerhalb derselben Zeile funktionieren ebenfalls. Details: Lookups.

Spalten ein- und ausblenden, Zellen sperren

Eine Spalte kann verschwinden oder gesperrt sein — der Unterschied liegt darin, worauf sich die Bedingung bezieht.

Ganze Spalte: kc:visible: false blendet sie immer aus, kc:visibleIf je nach Bedingung. Beide werden gegen die Werte des Formularkopfs ausgewertet, nicht gegen die Zeile — eine Spalte ist entweder da oder nicht, eine je Zeile wechselnde Antwort ergäbe eine Spalte mit Löchern. Der Wert einer ausgeblendeten Spalte bleibt gespeichert und lässt sich weiterhin als kc:calc-Operand verwenden.

Einzelne Zelle: kc:readonlyIf und kc:requiredIf entscheiden je Zeile. Sie sehen zuerst die Werte dieser Zeile und darunter die des Formularkopfs — „Pflicht, wenn in dieser Zeile die Menge über 100 liegt" lässt sich damit genauso schreiben wie „Pflicht, solange oben die Rabattaktion läuft". Heißen eine Spalte und ein Feld im Kopf gleich, gewinnt die Spalte, weil sie die nähere Ebene ist. Ein kc:picker an einer gesperrten Zelle ist mit gesperrt.

"positionen": {
  "type": "array",
  "kc:childTable": {
    "properties": {
      "artikel":  { "type": "string", "title": "Artikel" },
      "menge":    { "type": "integer", "title": "Menge" },
      "rabatt":   { "type": "number",  "title": "Rabatt %",
                    "kc:visibleIf": "rabattaktion === true" },
      "begruendung": { "type": "string", "title": "Begründung",
                       "kc:readonlyIf": "menge < 10" }
    }
  }
}

Die Spalte rabatt erscheint nur, wenn im Formularkopf das Feld rabattaktion angekreuzt ist. Das Feld begruendung lässt sich nur in den Zeilen ausfüllen, deren menge mindestens 10 beträgt.

Was womit zusammenhängt

kc:visible/kc:visibleIf betreffen die Anzeige im Formular. Ob eine Spalte gedruckt wird, steuert davon unabhängig kc:printable/kc:printableIf (siehe Druck) — eine Spalte, die auf dem Bildschirm ausgeblendet ist, erscheint also weiterhin im PDF, solange sie nicht auch dort abgeschaltet wird. Das ist Absicht und gilt für Felder im Kopf genauso.

In der Block-Darstellung (layout: "block", und automatisch auf schmalen Schirmen) darf kc:visibleIf zusätzlich Spalten derselben Zeile vergleichen, weil dort jeder Datensatz ein eigenes kleines Formular ist. In der Tabellen-Darstellung geht das nicht — schreibe solche Bedingungen also so, dass sie sich auf den Formularkopf beziehen, wenn das Formular beide Darstellungen benutzen soll.

Validierung

Pflichtfelder, Muster und Formatregeln gelten in jeder Zeile wie im Formularkopf. Ein Pflichtfeld „mindestens eine Zeile" gibt es auf der Detail-Tabelle nicht — das prüfst du im onValidate-Hook (Beispiel oben).

Pflichtspalten

Eine Spalte wird zur Pflichtspalte über die required-Liste der Tabelle (im Studio der Haken Pflicht am Spalten-Editor) oder über kc:requiredIf, wenn sie es nur unter einer Bedingung sein soll:

"positionen": {
  "type": "array",
  "kc:childTable": {
    "required": ["artikel"],
    "properties": {
      "artikel": { "type": "string",  "title": "Artikel" },
      "menge":   { "type": "integer", "title": "Menge" },
      "grund":   { "type": "string",  "title": "Grund",
                   "kc:requiredIf": "menge > 100" }
    }
  }
}

Wo der Stern steht, folgt daraus, für wen die Pflicht gilt: die statische Pflichtspalte trägt ihn im Spaltenkopf, weil sie für jede Zeile gilt; die bedingte Pflicht trägt ihn in der Zelle, und nur solange diese leer ist.

Geprüft wird beim Klick auf Validieren und beim Speichern — also serverseitig, nicht nur im Browser. Die Meldung nennt Zeilennummer und Spaltentitel und erscheint an der Tabelle.

Wann eine Pflichtspalte bewusst schweigt

  • Leere Zeilen werden nicht angemahnt. Eine angelegte, nie ausgefüllte Zeile ist keine Lücke, sondern eine Zeile zu viel — sie wird beim Speichern ohnehin verworfen (siehe keepEmptyRows).
  • Unerfüllbare Zellen ebenso wenig: ausgeblendete Spalten (kc:visible: false, kc:visibleIf), per kc:readonly/ kc:readonlyIf gesperrte Zellen und Rechenspalten (kc:calc) lassen sich nicht ausfüllen. Eine Pflicht darauf wäre eine Sackgasse: das Formular ließe sich nicht mehr speichern, und niemand könnte etwas dagegen tun.

Druck (PDF)

Detail-Tabellen erscheinen im PDF als eigene Tabelle mit Kopfzeile, im Block-Modus als Blöcke. Die Spaltenreihenfolge folgt properties; Spalten mit kc:printable: false fehlen, ebenso solche, deren kc:printableIf nicht zutrifft — ausgewertet gegen die Werte des Formularkopfs (bei einer Unter-Tabelle gegen die Master-Zeile). kc:align steuert die Ausrichtung in der Tabelle. Läuft eine Tabelle über mehrere Seiten, wiederholen sich Titel („Fortsetzung") und Kopfzeile. Einstellungen wie Umbruch oder Abschneiden je Spalte: Druck.

Unter-Tabellen in Unter-Tabellen (1:n:m)

kc:childTable ist rekursiv: Eine Spalte darf selbst wieder eine Detail-Tabelle sein — in beliebiger Tiefe. Typische Fälle: Kostenstellen mit Kostenpositionen, Positionen mit Seriennummern, Belege mit mehreren Anlagen.

Beispiel-Schema

{
  "kostenstellen": {
    "type": "array",
    "title": "Kostenstellen",
    "kc:childTable": {
      "initialRows": 3,
      "collapsibleSubRows": true,
      "properties": {
        "titel": { "type": "string", "title": "Titel" },
        "summe": { "type": "number", "title": "Summe",
                   "kc:decimals": 2, "kc:readonly": true },
        "kostenpositionen": {
          "type": "array",
          "title": "Kostenpositionen",
          "kc:childTable": {
            "initialRows": 2,
            "properties": {
              "wert": { "type": "number", "title": "Wert",
                        "kc:decimals": 2, "kc:numberSuffix": " EUR" }
            }
          }
        }
      }
    }
  }
}

Tabellennamen je Ebene

Jede Ebene erhält eine eigene Tabelle; der Name reiht die Feldnamen aneinander (Formular-ID kosten-im-wirtschaftsjahr):

Ebene Tabelle
Kopf form_kosten_im_wirtschaftsjahr
Kostenstellen form_kosten_im_wirtschaftsjahr_kostenstellen
Kostenpositionen form_kosten_im_wirtschaftsjahr_kostenstellen_kostenpositionen

Tabellennamen sind auf 64 Zeichen begrenzt. Wird ein Name länger, kürzt KCForms ihn auf 56 Zeichen und hängt _ plus sieben Zeichen eines Hashwerts aus dem vollen Pfad an — so bleiben auch gekürzte Namen eindeutig.

Die Verknüpfung zur Elternebene:

parent_id           → FK auf form_<formid>.id (immer)
parent_lfd_nr_1     → lfd_nr der Zeile auf Ebene 1
parent_lfd_nr_2     → analog für Ebene 2 …
PRIMARY KEY (parent_id, parent_lfd_nr_1, …, lfd_nr)

Zusammengesetzte Fremdschlüssel sorgen für ON DELETE CASCADE über alle Ebenen.

Datenformat — Unter-Listen in der Zeile

Die Liste der nächsten Ebene steckt in der jeweiligen Zeile:

{
  "kostenstellen": [
    {
      "lfd_nr": 1,
      "titel": "Marketing",
      "summe": 12500.00,
      "kostenpositionen": [
        { "lfd_nr": 1, "wert": 5000.00 },
        { "lfd_nr": 2, "wert": 7500.00 }
      ]
    },
    { "lfd_nr": 2, "titel": "IT", "summe": 0.00, "kostenpositionen": [] }
  ]
}

Das gilt beim Lesen wie beim Schreiben.

Skript-Zugriff über mehrere Ebenen

Geschrieben wird immer direkt in ctx.form — die Unter-Listen stecken in den Zeilen:

// Summe je Kostenstelle und Gesamtsumme
var stellen = ctx.form.kostenstellen || [];
var gesamt = 0;
for (var i = 0; i < stellen.length; i++) {
  var stelle = stellen[i];
  if (!stelle) continue;
  var sub = 0;
  var positionen = stelle.kostenpositionen || [];
  for (var j = 0; j < positionen.length; j++) {
    if (positionen[j]) sub += kc.num(positionen[j].wert);
  }
  stelle.summe = sub;
  gesamt += sub;
}
ctx.form.gesamt = gesamt;

Lesend erreichst du jede Ebene auch über ctx.model.children.<feld>.rows[i].subordinates.<unterfeld>.rows[j].

ctx.model ist eine Lese-Sicht

Werte, die ein Skript über ctx.model (….elements.<x>.value) schreibt, landen nicht in ctx.form und werden nicht gespeichert. Details: ctx.model.

collapsibleSubRows

collapsibleSubRows: true zeigt die Unter-Tabelle jeder Zeile einklappbar an — eine kompakte Liste, ein Klick klappt die Unter-Tabelle auf. Das betrifft nur das Web-Formular; im PDF erscheinen Unter-Tabellen immer vollständig.

Druck bei 1:n:m

Unter-Tabellen erscheinen im PDF nach ihrer Elternzeile als eingerückter Block mit eigenem Titel und Kopf. Die Kopfzeile der Elterntabelle zeigt nur die einfachen Spalten; verschachtelte Spalten wandern in den eingerückten Block. Passt der Block auf eine Seite, hält KCForms Elternzeile und Block zusammen.

Beispiel: „Kosten im Wirtschaftsjahr"

Das Beispielformular Kosten im Wirtschaftsjahr (Katalog Service, Ordner Demos/1zunzum) zeigt alle Ebenen samt rekursiver Summierung in onFieldChange. Installieren unter System-Administration → Formular-Bausteine → Beispiele.

Stolperfallen

  • type: "array" ohne kc:childTable ergibt ein normales Mehrfachwert-Feld (z. B. eine Mehrfachauswahl), keine Tabelle.
  • Spalten umbenennen: Die neue Spalte kommt beim Speichern dazu, die alte bleibt in der Datenbank stehen. Bei Bedarf manuell aufräumen, z. B. im DB-Client: ALTER TABLE form_xxx_yyy DROP COLUMN ….
  • Sehr breite Tabellen (mehr als etwa 20 Spalten) werden unübersichtlich. Besser gruppieren oder den Block-Modus (layout: "block") nutzen.
  • initialRows großzügig gewählt: Leere Zeilen werden mitgespeichert und lösen Pflichtfeld-Fehler aus, wenn eine Spalte Pflicht ist. Nicht benötigte Zeilen mit × entfernen oder initialRows senken.