Berechnungsfelder – Twig-Referenz
Berechnungsfelder sind spezielle Formularfelder, die ihren Wert automatisch berechnen – auf Basis anderer Felder, Schichten, Verträge, Tickets und vieler weiterer Daten. Du hinterlegst einmalig eine Formel, und festiware berechnet den Wert bei jeder Speicherung neu.
Das klingt technisch – ist es aber nur auf den ersten Blick. Für viele Anwendungsfälle reicht es, ein fertiges Beispiel aus diesem Artikel zu kopieren und nur den Feldnamen anzupassen.
Wozu sind Berechnungsfelder nützlich?
Berechnungsfelder lösen ein häufiges Problem: Informationen, die du brauchst, stecken an verschiedenen Stellen – und du willst sie nicht jedes Mal manuell zusammensuchen.
Einige typische Einsatzfälle:
- Übersicht auf einen Blick: Ein Feld „Status" zeigt direkt in der Listenansicht
VIP · Ticket #A2F1 · 3 Schichten, ohne dass du jedes Profil einzeln aufklappen musst. - Automatisch aktuelle Kennzahlen: Sobald eine neue Schicht eingetragen wird, zeigt das Feld „Schichtstunden gesamt" den aktuellen Stand.
- Berechnungen: Die Dauer zwischen zwei Datumsfeldern, die Summe aller Zahlungseingänge, die Anzahl der Verträge.
- Bedingte Texte: „VIP" oder „Standard" – je nachdem, welcher Ticket-Typ hinterlegt ist.
- Kombinierte Informationen: Vor- und Nachname in einem Feld, kommagetrennte Liste aller Teammitglieder.
Berechnungsfelder basieren auf Twig, einer einfachen Formelsprache. Du musst Twig nicht von Grund auf lernen – für die meisten Fälle reicht es, ein Beispiel aus diesem Artikel anzupassen.
Ein Berechnungsfeld anlegen
Berechnung ist ein Feldtyp im Formular-Editor – du findest ihn in der Auswahlliste, wenn du ein neues Feld hinzufügst. Wähle dort einfach Berechnung aus.
Sobald du Berechnung gewählt hast, erscheint darunter die Vorlage-Auswahl. Dort kannst du aus fertigen Vorlagen wählen – festiware befüllt den Twig-Code dann automatisch. Du musst also für viele Fälle gar nichts selbst schreiben:

Willst du eine Formel selbst schreiben oder eine Vorlage anpassen, wähle ohne Vorlage und trage den Twig-Code manuell ein.
Schnellstart
Einfache Ausdrücke benötigen keine Klammern:
self.firstname ~ " " ~ self.name
Komplexere Logik kann vollständige Twig-Templates enthalten:
{% if self.vip == "yes" %}
VIP · {{ tickets_owned|first.code }}
{% else %}
Standard
{% endif %}
Feldnamen werden direkt über ihren Kurznamen angesprochen (z.B. self.firma , self.ankunftsdatum ). Den Kurznamen siehst du im Formular-Editor beim jeweiligen Feld – er ist üblicherweise kleingeschrieben.
Wie du ein Berechnungsfeld im Formular anlegst, beschreibt der Artikel Fenster „Feld hinzufügen / Feld bearbeiten".
Wenn du Twig-Formeln in Verträgen, PDFs oder E-Mails einsetzen möchtest, beachte den Artikel Widgets: Twig-Formeln in Vorlagen und PDFs richtig einsetzen – der Umgang mit dem WYSIWYG-Editor unterscheidet sich.
Funktionsweise
- Automatische Neuberechnung beim Speichern von Personen/Gruppen sowie bei jeder Änderung verknüpfter Daten
- Werte bleiben gespeichert bis zur nächsten Berechnung
- Gegenseitige Referenzierung möglich zwischen Berechnungsfeldern
- Fehlerbehandlung: Fehlerhafte Felder zeigen
error: <Meldung> - Kaskadische Updates bei Änderungen abhängiger Datensätze (z.B. neue Schicht, neuer Vertrag)
- Massenberechnung bei Formularänderungen – den Status siehst du in der jeweiligen Formulardefinition (Detail- oder Bearbeitungsansicht)
Feldtypen
Der Hauptfeldtyp ist immer Berechnung. Daneben gibt es einen Sub-Feldtyp, der festlegt, wie der berechnete Wert gespeichert und in Filtern oder Übersichten verwendet wird. Du wählst ihn im Formular-Editor direkt neben der Vorlage-Auswahl.
| Sub-Feldtyp (UI) | Format | Verwendung |
|---|---|---|
| Texteingabe | Zeichenkette | HTML-sicher gerendert |
| Zahl | Numerische Zeichenkette | Summen, Zählwerte, Dauern |
| Datum | JJJJ-MM-TT | Datumswerte |
| Datum und Uhrzeit | ISO-Format | Zeitstempel |
| Auswahl-Feld | Optionsschlüssel | Aus definierten Optionen |
| Mehrfach-Auswahl-Feld | JSON-Array | Mehrfachauswahl |
Die Variable self
self steht für die aktuelle Person bzw. Gruppe, in deren Profil das Berechnungsfeld ausgewertet wird. Über self.{feldname} greifst du auf jedes Formularfeld dieses Profils zu – z.B. self.firma oder self.ankunftsdatum. Den passenden Kurznamen findest du im Formular-Editor beim jeweiligen Feld.
Darüber hinaus stellt self die folgenden System-Eigenschaften und Relationen bereit:
System-Eigenschaften
self.id— vollständige UUIDself.id_partial— Kurz-ID (nur Person)self.created_at— Erstellungszeitself.updated_at— Änderungszeit
Relationen (allgemein für Person und Gruppe)
contracts — Verträge
contracts|first.contractType.id // Vertragstyp-ID contracts|length // Anzahl
events — Schichtplanungs-Events
events|first.start_at // Startzeit events|filter(e => e.schedulerEventType.name == "Aufbau")|first.start_at // Nach Typ filtern
actions — Aktionsprotokoll
last_action.name // Name der letzten Aktion last_action.created_at // Zeitstempel
teams — Teams (hauptsächlich für Orga-Personentypen; für Volunteer-Typen bitte teams_suggested / teams_assigned / teams_confirmed verwenden)
teams|map(t => t.name)|join(", ") // Liste der Teamnamen
Nur für Personen
tickets_owned — Tickets
tickets_owned|first.code // Ticket-Code tickets_owned|length // Anzahl tickets_owned|filter(e => e.ticket_type_id == 3)|first.code // Nach Typ filtern
orders — Shop-Bestellungen
orders|first.current_order_status.order_status_type.name orders|length
jobs — Schichten (zeitabhängig)
jobs|first.name // Schichtname
jobs|map(j => j.durationInMinutes("Event"))|sum|as_hours // Geplante Stunden
jobs|map(j => j.durationInMinutes("Job"))|sum|as_hours // Tatsächliche Stunden
jobs|first.data.details // Details-Freitext der Schicht
jobs|first.frozenHourlyWage() // Stundenlohn: eingefroren, sonst aktuell gültig
jobs|first.data.hourly_wage // Stundenlohn: nur der eingefrorene Rohwert
Hinweis: details und der Stundenlohn gehören zum Zeiterfassungs-Modul (nur im Professional-Tarif). data.details und data.hourly_wage lesen den rohen gespeicherten Wert; frozenHourlyWage() liefert den eingefrorenen Lohn und fällt sonst auf den aktuell gültigen zurück. Leere Werte solltest du mit ?? '' bzw. ?? 0 abfangen, z. B. jobs|first.frozenHourlyWage() ?? 0.
guests — Gastpersonen
guests|map(p => "#{p.firstname} #{p.name}")|join(", ")
meal_orders — Essensbestellungen | vouchers_received — Essens-/Getränke-Gutscheine | coupons — Shop-Coupons
meal_orders|length // Anzahl
transactions — Geldeingänge
transactions|map(t => t.balance)|sum // Gesamtsumme
focus_people — Betreute Personen
focus_people|map(p => "#{p.firstname} #{p.name}")|join(", ")
applicationsAsMember / applicationsAsOwner — Gruppenmitgliedschaften
applicationsAsMember|map(a => a.name)|join(", ")
applicationsAsMember|filter(t => t.applicationType.id == 7)|first.name
teams_suggested , teams_assigned , teams_confirmed — Volunteer-Teamzuweisungen (vorgeschlagen / zugeteilt / bestätigt)
teams_confirmed|map(t => t.name)|join(", ")
Nur für Gruppen
owner — Ansprechperson
owner.firstname ~ " " ~ owner.name
members — Mitglieder
members|map(p => "#{p.firstname} #{p.name}")|join(", ")
members|length
members|filter(e => "Catering" in e.pivot.roleArray)
opuses — Werke/Darbietungen
opuses|map(o => o.pivot.duration_minutes|default(o.duration_minutes))|sum
subordinatedApplications — Untergeordnete Gruppen
subordinatedApplications|map(a => a.name)|join(", ")
festiware-spezifische Funktionen & Filter
Funktionen
diff_minutes(t1, t2) — Minuten zwischen zwei Zeitpunkten. Reihenfolge beachten: früherer Zeitpunkt zuerst, späterer als zweites (diff_minutes(start, ende)). Vertauscht man die beiden, ist das Ergebnis negativ.
diff_minutes(self.aufbau_start, self.aufbau_ende) // in Minuten diff_minutes(self.aufbau_start, self.aufbau_ende)|as_hours // als HH:MM
getFormattedEventDate(event, mitBereich = false) — Kompaktes Datumsformat
getFormattedEventDate(events|first) // "10.8. 14:00 - 16:30" getFormattedEventDate(jobs|first, true) // mit Bereichsangabe
projectsetting('Klassenname') — Projekteinstellungen abrufen
projectsetting('ContractIntroText')
collect_push(name, wert) / collect_get(name) — Hilfsfunktion für PDF-Vorlagen: Beim Durchlauf über mehrere Ressourcen können mit collect_push Werte gesammelt und anschließend mit collect_get im PDF-Layout ausgegeben werden. Nicht für Berechnungsfelder im Formular geeignet.
collect_push('gesamtsumme', self.teilsumme_a|force_to_int)
collect_get('gesamtsumme')|sum
Filter
| Filter | Funktion |
|---|---|
\|as_hours |
Minuten zu „HH:MM" — immer als positiver Betrag (-90 → 01:30) |
\|sum |
Summiert numerische Werte |
\|to_int_list |
String-Array zu Ganzzahlen |
\|force_to_int / \|force_to_float |
Typumwandlung |
\|get('schlüssel') |
Dotted-Notation mit Null-Sicherheit |
Test
{% if self.budget is numeric %}
{{ self.budget|force_to_float * 1.19 }}
{% endif %}
Standard-Twig-Referenz
Häufige Filter
| Filter | Funktion |
|---|---|
\|date('d.m.Y') |
Datumsformatierung |
\|length |
Elemente zählen |
\|join(', ') |
Array verbinden |
\|map(x => ...) |
Transformieren |
\|filter(x => ...) |
Filtern |
\|first / \|last |
Erstes/letztes Element |
\|default('–') |
Fallback-Wert |
\|upper / \|lower |
Groß-/Kleinschreibung |
\|trim |
Leerzeichen entfernen |
\|json_encode |
Als JSON kodieren |
\|round(2) |
Runden |
\|abs |
Absolutwert |
\|replace({'alt': 'neu'}) |
Ersetzen |
Häufige Funktionen
max(a, b) / min(a, b)
range(1, 5) // [1, 2, 3, 4, 5]
date('Y') // Aktuelles Jahr
Kontrollstrukturen
{# Bedingung #}
{% if self.bestaetigt == "yes" %}
bestätigt
{% elseif self.bestaetigt == "maybe" %}
ausstehend
{% else %}
nein
{% endif %}
{# Ternär #}
{{ self.vip == "yes" ? "VIP" : "Standard" }}
{# Schleife #}
{% for event in events %}
{{ event.start_at|date('j.n.') }} – {{ event.area.name }}
{% endfor %}
{# Variable #}
{% set gesamt = jobs|map(j => j.durationInMinutes("Event"))|sum %}
Twig-Vorlagen
Unter Projekt → Einstellungen → Berechnungsfeld-Vorlagen kannst du wiederverwendbare Ausdrücke einmalig speichern und in beliebigen Formularen einsetzen. Vorlagen werden beim Berechnen aufgelöst – eine Anpassung wirkt sich also beim nächsten Speichern auf alle Felder aus, die sie verwenden.
Praktische Beispiele
Die folgenden Beispiele kannst du direkt übernehmen – passe nur die Feldnamen (z.B. self.firma ) an die Kurznamen deiner eigenen Felder an.
1. Vollständiger Name
Was du siehst: Den kombinierten Vor- und Nachnamen, z.B. Max Mustermann . Nützlich als Suchfeld in der Übersicht, wenn du nach dem vollen Namen filtern möchtest.
self.firstname ~ " " ~ self.name
2. Events nach Typ zählen
Was du siehst: Wie oft ein bestimmter Event-Typ (hier: „Aufbau") vorkommt, z.B. 3 .
events|filter(e => e.schedulerEventType.name == "Aufbau")|length
3. Geplante Schichtstunden
Was du siehst: Die Gesamtdauer aller geplanten Schichten im Format HH:MM , z.B. 14:30 . So siehst du auf einen Blick, wie viele Stunden jemand eingeteilt ist – ohne alle Schichten manuell zusammenzuzählen.
jobs|map(j => j.durationInMinutes("Event"))|sum|as_hours
4. Tatsächliche Schichtstunden (nur abgeschlossen)
Was du siehst: Wie viele Stunden tatsächlich geleistet wurden – Schichten ohne „abgeschlossen"-Zeitstempel werden nicht gezählt. Nützlich für die Auswertung nach dem Festival.
jobs|filter(e => e["accomplished_at"] is not null)
|map(j => j.durationInMinutes("Job"))|sum|as_hours
5. Vertragsstatus mit Fallback
Was du siehst: Den aktuellen Status des Vertrags, z.B. Unterschrieben . Wenn kein Vertrag vorhanden ist, erscheint – . So erkennt das Vertragsteam in der Listenansicht sofort, wer noch keinen Vertrag hat.
{% set c = contracts|filter(e => e.contract_type_id == 3)|first %}
{{ c ? c.current_state : "–" }}
6. Dauer zwischen zwei Feldern
Was du siehst: Die Differenz zwischen zwei Zeitfeldern als Minuten oder als HH:MM . Nützlich z.B. um die geplante Aufbaudauer automatisch auszurechnen.
{{ diff_minutes(self.aufbau_start, self.aufbau_ende) }} min
{{ diff_minutes(self.aufbau_start, self.aufbau_ende)|as_hours }}
7. Bedingter Ticket-Code
Was du siehst: Je nach Feldwert entweder VIP · A2F1-XK7 oder Standard . Nützlich für eine kompakte Statusspalte in der Übersicht.
{% if self.vip == "yes" %}
VIP · {{ tickets_owned|filter(e => e.ticket_type_id == 1)|first.code|default("kein Ticket") }}
{% else %}
Standard
{% endif %}
8. Mitglieder nach Rolle
Was du siehst: Eine kommagetrennte Liste der Gruppenmitglieder mit einer bestimmten Rolle, z.B. Max Mustermann, Anna Beispiel .
members|filter(e => "Catering" in e.pivot.roleArray)
|map(p => "#{p.firstname} #{p.name}")|join(", ")
9. Events als formatierte Liste
Was du siehst: Eine mehrzeilige Auflistung aller Events – nützlich als Zusammenfassung in der Übersicht oder im PDF-Export.
events|map(e => "#{e.start_at|date('j.n.Y H:i')} – #{e.end_at|date('H:i')} #{e.name} (#{e.area.name})")
|join("<br>")
10. Summe der Geldeingänge
Was du siehst: Den Gesamtbetrag aller Transaktionen für diese Person, z.B. 250 . Nützlich um zu prüfen, ob eine Kaution vollständig eingegangen ist.
transactions|map(t => t.balance)|sum
Fehlersuche
| Problem | Lösung |
|---|---|
error: ... angezeigt |
Code prüfen, Datensatz erneut speichern |
| Feld ist leer | Feldkurznamen prüfen (Groß-/Kleinschreibung beachten), Relation auf Einträge prüfen |
\|first liefert nichts |
Mit \|default('–') absichern |
| Falscher Wert nach Vorlagenänderung | Datensatz erneut speichern |
=== funktioniert nicht |
== verwenden |
Leere Felder entstehen am häufigsten durch Tippfehler im Kurznamen.