diff --git a/DEVLOG.md b/DEVLOG.md index a0fa198..98b2267 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1256,3 +1256,81 @@ Keine Commits in dieser Session. - backend/app/routers/special_assignments.py | 10 +++++----- --- +## 2026-05-25 01:06 – 01:13 (7m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 5049747 feat: Sondervertretungen als eigene HR-Seite (/hr/special-assignments) + +### Geänderte Dateien +- DEVLOG.md | 14 + +- frontend/src/App.tsx | 2 + +- frontend/src/components/Layout.tsx | 5 +- +- frontend/src/pages/SpecialAssignmentsPage.tsx | 480 ++++++++++++++++++++++++++ +- frontend/src/pages/UsersPage.tsx | 106 ------ + +--- +## 2026-05-25 01:15 – 01:15 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 14 + +- frontend/src/App.tsx | 2 + +- frontend/src/components/Layout.tsx | 5 +- +- frontend/src/pages/SpecialAssignmentsPage.tsx | 480 ++++++++++++++++++++++++++ +- frontend/src/pages/UsersPage.tsx | 106 ------ + +--- +## 2026-05-25 01:17 – 01:17 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 14 + +- frontend/src/App.tsx | 2 + +- frontend/src/components/Layout.tsx | 5 +- +- frontend/src/pages/SpecialAssignmentsPage.tsx | 480 ++++++++++++++++++++++++++ +- frontend/src/pages/UsersPage.tsx | 106 ------ + +--- +## 2026-05-25 01:18 – 01:22 (4m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- eae0f6f docs: ROADMAP.md angelegt – alle Features in Planung + +### Geänderte Dateien +- ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-05-25 01:28 – 01:36 (8m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-05-25 01:37 – 01:40 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/models/kiosk_device.py b/backend/app/models/kiosk_device.py index bad2924..4e8dd2e 100644 --- a/backend/app/models/kiosk_device.py +++ b/backend/app/models/kiosk_device.py @@ -40,7 +40,7 @@ class KioskDevice(Base): # ── Ed25519-Auth (löst token_hash + is_active ab) ───────────────────────── status: Mapped[KioskDeviceStatus] = mapped_column( - Enum(KioskDeviceStatus, name="kioskdevicestatus"), + Enum(KioskDeviceStatus, name="kioskdevicestatus", values_callable=lambda x: [e.value for e in x]), nullable=False, default=KioskDeviceStatus.REVOKED, ) diff --git a/backend/app/models/time_entry.py b/backend/app/models/time_entry.py index 27bb677..0cee538 100644 --- a/backend/app/models/time_entry.py +++ b/backend/app/models/time_entry.py @@ -66,11 +66,18 @@ class TimeEntry(Base): """Gearbeitete Minuten (ohne Pausen), None wenn noch offen.""" if self.end_time is None: return None - start_total = self.start_time.hour * 60 + self.start_time.minute - end_total = self.end_time.hour * 60 + self.end_time.minute - if end_total <= start_total: - end_total += 24 * 60 # overnight shift - return max(0, end_total - start_total - self.break_minutes) + # Sekunden einbeziehen um Sub-Minuten-Einträge korrekt zu behandeln + # (z.B. Einstemp+Ausstemp in derselben Minute → soll 0h zeigen, nicht 24h) + start_secs = (self.start_time.hour * 3600 + + self.start_time.minute * 60 + + self.start_time.second) + end_secs = (self.end_time.hour * 3600 + + self.end_time.minute * 60 + + self.end_time.second) + if end_secs < start_secs: + end_secs += 24 * 3600 # Nachtschicht (Ende am nächsten Tag) + total_mins = (end_secs - start_secs) // 60 + return max(0, total_mins - self.break_minutes) @property def worked_hours(self) -> float | None: diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index f40229b..2a53624 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -24,6 +24,9 @@ class CompanyOut(BaseModel): mobile_stamping_enabled: bool = True overtime_overdraft_allowed: bool = True overtime_warning_threshold_hours: int = 0 + kiosk_require_approval: bool = True + kiosk_track_current_user: bool = True + kiosk_heartbeat_interval_sec: int = 30 class CompanyUpdate(BaseModel): @@ -36,6 +39,9 @@ class CompanyUpdate(BaseModel): mobile_stamping_enabled: bool | None = None overtime_overdraft_allowed: bool | None = None overtime_warning_threshold_hours: int | None = Field(None, ge=0) + kiosk_require_approval: bool | None = None + kiosk_track_current_user: bool | None = None + kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120) class DepartmentOut(BaseModel): diff --git a/backend/app/services/time_service.py b/backend/app/services/time_service.py index 321a538..f47e661 100644 --- a/backend/app/services/time_service.py +++ b/backend/app/services/time_service.py @@ -19,11 +19,11 @@ from app.schemas.time_entry import ( def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]: """ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht.""" - start_mins = start.hour * 60 + start.minute - end_mins = end.hour * 60 + end.minute - if end_mins <= start_mins: - end_mins += 24 * 60 # Nachtschicht - total_mins = end_mins - start_mins + start_secs = start.hour * 3600 + start.minute * 60 + start.second + end_secs = end.hour * 3600 + end.minute * 60 + end.second + if end_secs < start_secs: + end_secs += 24 * 3600 # Nachtschicht + total_mins = (end_secs - start_secs) // 60 worked_mins = total_mins - break_minutes worked_hours = worked_mins / 60 diff --git a/backend/cli.py b/backend/cli.py index 244bb3b..5340090 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -451,6 +451,84 @@ def kiosk_revoke( ) +# ── Subcommand: kiosk rotate-key ───────────────────────────────────────────── + +@kiosk_app.command("rotate-key") +def kiosk_rotate_key( + device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"), + pubkey: Optional[Path] = typer.Option(None, "--pubkey", help="Pfad zur neuen Public-Key-Datei (optional)"), + yes: bool = typer.Option(False, "--yes", "-y", help="Ohne Bestätigung fortfahren"), +): + """Ed25519-Schlüssel eines Kiosk-Geräts rotieren (Status → pending, Re-Enrollment erforderlich).""" + + # Neuen Public Key einlesen falls angegeben + new_pubkey_str: Optional[str] = None + if pubkey is not None: + if not pubkey.exists(): + err_console.print(f"[bold red]Fehler:[/bold red] Public-Key-Datei nicht gefunden: {pubkey}") + raise typer.Exit(1) + new_pubkey_str = pubkey.read_text().strip() + if not new_pubkey_str: + err_console.print("[bold red]Fehler:[/bold red] Public-Key-Datei ist leer.") + raise typer.Exit(1) + + # Gerät vorab laden für Bestätigungsdialog + if not yes: + async def _peek(): + session = await _open_session() + try: + return await _find_device(session, device_id) + finally: + await session.close() + device_peek = _run(_peek()) + console.print( + f"[yellow]Achtung:[/yellow] Gerät [bold]{device_peek.name}[/bold] " + f"({str(device_peek.id)[:8]}…) wird auf [yellow]pending[/yellow] gesetzt.\n" + "Der aktuelle Schlüssel wird gelöscht. Re-Enrollment ist erforderlich." + ) + confirm = typer.confirm("Schlüssel wirklich rotieren?") + if not confirm: + console.print("[dim]Abgebrochen.[/dim]") + raise typer.Exit(0) + + async def _rotate(): + session = await _open_session() + try: + device = await _find_device(session, device_id) + device.public_key = new_pubkey_str + device.enrollment_token_hash = None + device.enrollment_expires_at = None + device.status = KioskDeviceStatus.PENDING + await session.commit() + await session.refresh(device) + return device + except Exception: + await session.rollback() + raise + finally: + await session.close() + + device = _run(_rotate()) + + console.print() + if new_pubkey_str: + fingerprint = _pubkey_fingerprint(new_pubkey_str) + console.print( + f"[bold green]✓[/bold green] Schlüssel für Gerät [bold]{device.name}[/bold] " + f"({str(device.id)[:8]}…) rotiert.\n" + f" [bold]Neuer Fingerprint:[/bold] {fingerprint}\n" + f" [bold]Status:[/bold] {_status_icon(device.status)}" + ) + else: + console.print( + f"[bold green]✓[/bold green] Public Key für Gerät [bold]{device.name}[/bold] " + f"({str(device.id)[:8]}…) wurde gelöscht.\n" + f" [bold]Status:[/bold] {_status_icon(device.status)}\n" + " [yellow]Hinweis:[/yellow] Bitte Public Key per Enrollment-Flow neu setzen." + ) + console.print() + + # ── Subcommand: kiosk info ──────────────────────────────────────────────────── @kiosk_app.command("info") diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index 3797685..e9d05d6 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -55,6 +55,10 @@ export function CompanySettingsPage() { const [pnNext, setPnNext] = useState(1) // Mobile const [mobileStamping, setMobileStamping] = useState(true) + // Kiosk + const [kioskRequireApproval, setKioskRequireApproval] = useState(true) + const [kioskTrackCurrentUser, setKioskTrackCurrentUser] = useState(true) + const [kioskHeartbeatIntervalSec, setKioskHeartbeatIntervalSec] = useState(30) // Freizeitausgleich const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true) const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0) @@ -81,10 +85,13 @@ export function CompanySettingsPage() { setPnRequired(c.personnel_number_required ?? false) setPnMode(c.personnel_number_mode ?? 'manual') setPnNext(c.personnel_number_next ?? 1) - const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number } + const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number } setMobileStamping(cc.mobile_stamping_enabled ?? true) setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true) setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0) + setKioskRequireApproval(cc.kiosk_require_approval ?? true) + setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true) + setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30) }).catch(() => {}) api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) @@ -155,6 +162,9 @@ export function CompanySettingsPage() { mobile_stamping_enabled: mobileStamping, overtime_overdraft_allowed: fzaOverdraftAllowed, overtime_warning_threshold_hours: fzaWarningThreshold, + kiosk_require_approval: kioskRequireApproval, + kiosk_track_current_user: kioskTrackCurrentUser, + kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec, }) setCompany(updated) setSaved(true) @@ -562,6 +572,82 @@ export function CompanySettingsPage() { + {/* Kiosk-Terminal-Einstellungen */} +
Neue Geräte benötigen Admin-Freigabe
++ Wenn aktiv, muss jedes neue Kiosk-Gerät erst durch einen Admin freigeschaltet werden, bevor es Stempelungen akzeptiert. +
+Aktuellen Benutzer am Terminal speichern (DSGVO)
++ Wenn aktiv, wird der zuletzt eingestempelte Mitarbeiter am Gerät gespeichert. Deaktivieren für DSGVO-konformeren Betrieb. +
+Heartbeat-Intervall (Sekunden)
++ Wie oft Kiosk-Terminals ihren Status an den Server melden. Minimum 10s, Maximum 120s. +
+