Skip to content

08 – Zeiterfassung

Reifegrad: 🟢 produktiv · Schicht: time_logs, src/lib/time-entries/*, src/app/zeiterfassung, src/app/api/time-entries/*, src/app/api/cron/personal-time-auto-close, src/components/time/*

Zweck

Erfassung von Arbeitszeit in zwei fachlichen Ausprägungen:

  1. Personalzeit (entry_type='personal') – Anwesenheit/Kommen-Gehen je Mitarbeiter und Tag.
  2. Projektzeit (entry_type='project') – auf ein Projekt gebuchte Leistungsstunden, die in Rapporte und damit in die Abrechnung fließen.

time_logs ist die zentrale Zeitquelle für beide (siehe adr-0004-zeiterfassung-time-logs).

Datenmodell (time_logs, Migrationen 040 + 041)

Aufbauend auf der Ur-Tabelle (Migration 001) wurde time_logs erweitert zur zentralen Quelle:

SpalteBedeutung
company_idMandant (Tenancy)
user_idMitarbeiter, dessen Zeit erfasst wird
entry_typepersonal | project (CHECK)
project_idnur bei project
work_dateArbeitstag (DATE) – Aggregationsschlüssel
hoursStunden NUMERIC(5,2)
duration_minutesDauer in Minuten
start_time / end_timebei Live-Timer / Kommen-Gehen
is_activeoffener (laufender) Eintrag
sourcemanual | whatsapp (CHECK)
capture_modemanual_hours | live_timer | manual_range | calendar_pair (CHECK)
recorded_by_user_idwer erfasst hat (z. B. Vorarbeiter für anderen)
source_appointment_id / close_appointment_idcome/go-Termine bei calendar_pair
rapport_entry_idVerknüpfung zur Rapportzeile (Projektzeit)
job_categoryTätigkeitskategorie (für Lohnsatz/Abrechnung)
descriptionNotiz

Wichtige Constraints/Indizes:

  • Eindeutige Personalzeit pro Tag wurde durch eindeutigen aktiven Personal-Timer ersetzt: idx_time_logs_active_personal → max. ein laufender Personal-Timer pro (company_id, user_id).
  • idx_time_logs_project_day – eine Projektzeit je (company, user, project, day).
  • idx_time_logs_rapport_entry – 1:1 Zeile ↔ Rapport-Entry.
  • Idempotenter Backfill (Migration 040) erzeugt time_logs aus bestehenden rapport_entries (Upgrade-Pfad für Altdaten).

Erfassungsmodi (capture_mode)

ModusBeschreibungErzeugung
manual_hoursStunden direkt eintragen (z. B. „3,5 h")Standard-Eingabe
live_timerStart/Stop-StoppuhrstartPersonalTimer / stopPersonalTimer
manual_rangeKommen–Gehen-Zeitraum nachtragencreateManualPersonalRange
calendar_paircome/go-Termine aus Kalender koppelnprocessCalendarCome / processCalendarGo

UI-Labels (captureModeLabel): Live / Manuell / Kalender / Stunden.

Live in /zeiterfassung (Personalzeit) nutzt nur noch Kalender-Kommen/Gehen (calendar_pair) — kein separater live_timer-Button mehr in der UI.

Personalzeit – Geschäftslogik (personal-time-service.ts)

  • Live-Timer: startPersonalTimer legt offenen Eintrag (is_active=true, hours=0) an; scheitert mit TIMER_ALREADY_ACTIVE, wenn schon einer läuft. stopPersonalTimer schließt exakt ab (resolvePersonalDuration – exakte Dauer, kein 0,25-Minimum).
  • 12-Stunden-Auto-Close: PERSONAL_TIME_MAX_HOURS = 12. Offene Personalzeit, die älter als 12 h ist, wird automatisch begrenzt und beendet; der Mitarbeiter bekommt eine Notification („Personalzeit automatisch beendet"). Gilt für Live-Timer und offenes Kalender-Kommen. Bei Kalender-Kommen (source_appointment_id / calendar_pair) wird zusätzlich ein Gehen-Termin zum Cutoff-Zeitpunkt angelegt (Beschreibung inkl. Begründung + Zeitstempel, close_appointment_id gesetzt) — damit der Kalender nicht den ganzen Tag nur „Kommen“ zeigt.
  • Cron: autoCloseExpiredPersonalTimers() schließt alle abgelaufenen Timer. Endpunkt POST /api/cron/personal-time-auto-close (geschützt per Cron-Secret) wird durch den lokalen/Helm-Runner scripts/lib/personal-time-cron-runner.mjs (aus start-all.js) getaktet.
  • Manueller Zeitraum: createManualPersonalRange (come/go-Zeiten, Validierung „Gehen nach Kommen", 0,25-Schritte).
  • Kalender-Pairing: processCalendarCome öffnet, processCalendarGo schließt einen Eintrag – ausgelöst über appointments vom Typ come/go (siehe 06-termine-kalender-plantafel).
  • Tätigkeits-Canonicalisierung (Projektzeit): Schreibweisen wie „Fliesen legen“ / „fliesenlegen“ gelten projektweit als dieselbe Tätigkeit (activityMatchKey). Beim Buchen wird die etablierte Schreibweise wiederverwendet; gleicher Tag addiert die Stunden.

Projektzeit – Geschäftslogik (time-entries-service.ts)

  • listTimeEntries / summarizeTimeEntries – Liste & Summen mit Filtern (Zeitraum, User, Projekt, Typ). Sichtbarkeit: Nicht-Manager sehen nur eigene Einträge; Manager (Rolle 1/2) sehen alle bzw. gefiltert nach userId (isManagerRole).
  • CRUD über POST/PUT/DELETE /api/time-entries[/id].
  • Import in Rapport: importProjectTimesToRapport (nur Manager/Admin) übernimmt Projektzeiten eines Zeitraums als Rapportzeilen → Brücke zur Abrechnung (time-entries/import-to-rapport). Fehlerfälle: FORBIDDEN, RAPPORT_NOT_FOUND, RAPPORT_LOCKED, PROJECT_REQUIRED.

Validierungsregeln (time-entries-utils.ts)

  • Stunden in 0,25-Schritten, 0 < h ≤ 24 (validateHours); Parsing akzeptiert Komma (parseHours, "3,5" → 3.5).
  • work_date strikt YYYY-MM-DD.
  • Monatsfilter (neu): currentMonthValue, monthBounds, formatMonthLabel (Locale de-AT) für Monats-Navigation in UI/URL.
  • Manager-Rollen: isManagerRole(role) → Rolle 1 oder 2.

API-Endpunkte (vollständig)

EndpointFunktion
GET/POST /api/time-entriesListe (Filter) / Eintrag anlegen
PUT/DELETE /api/time-entries/[id]ändern / löschen
GET /api/time-entries/summarySummen (Filter Zeitraum/User/Projekt/Typ)
GET /api/time-entries/personal/activeaktiven Personal-Timer abfragen
POST /api/time-entries/personal/manual-rangeKommen–Gehen nachtragen
POST /api/time-entries/personal/from-calendar-eventcome/go aus Termin koppeln
POST /api/time-entries/import-to-rapportProjektzeiten → Rapport (Manager)
POST /api/cron/personal-time-auto-close12 h-Auto-Close (Cron-Secret)

UI

  • /zeiterfassung – Erfassung & Übersicht; Tab/Filter ?type=personal|project, Monatsnavigation. Projektzeit: zusätzliche Filter Projekt und Tätigkeit (API: projectId, activity).
  • Komponenten: src/components/time/*.

Tests

npm run test:time-entriessrc/lib/time-entries/time-entries-utils.test.ts (Stunden-Parsing/-Validierung, Monatsgrenzen). Teil des Gesamt-npm test.

Ende-zu-Ende-Fluss (Beispiel)

Monteur tippt im WhatsApp "2,5h Elektrik Projekt Müller"
  → Agent-Tool schreibt time_logs(entry_type=project, source=whatsapp)
Vorarbeiter importiert Projektzeiten der Woche in den Rapport
  → rapport_entries + time_logs.rapport_entry_id gesetzt
Rapport wird abgeschlossen, Rechnung aus Rapport erzeugt
  → Stunden × labor_rates(job_category) = Rechnungsposten

Bekannte Befunde / offene Punkte

  • Zwei Zeitwelten in einer Tabelle (personal vs. project) – bewusst, aber Queries müssen immer entry_type filtern.
  • time_logs enthält Altspalten aus Migration 001 (break_minutes, activity_type, approved_by_id, tags, position_id), die im neuen Flow kaum genutzt werden.
  • Auto-Close-Cron hängt am start-all.js-Runner; in reinem next start ohne Runner läuft er nicht – im Helm-Setup über das App-Image abgedeckt.

Verwandte Notes

Work7 · Software für Handwerksbetriebe