fix: agent-08 Kiosk-Härtung + 24h-Zeiteintrag-Bug
- fix: worked_minutes nutzt jetzt Sekunden statt Minuten für Overnight-Vergleich (end < start statt end <= start) – verhindert 24h-Anzeige bei Schnell-Stempel in derselben Minute (z.B. 23:34:46 → 23:34:48) - fix: _check_arbzg() gleicher Sec-basierter Fix - fix: KioskDeviceStatus Enum values_callable → kiosk list crasht nicht mehr - feat: kiosk rotate-key CLI-Kommando (Status→pending, Re-Enrollment) - feat: Kiosk-Settings in CompanyOut/CompanyUpdate Schema (require_approval, track_current_user, heartbeat_interval_sec) - feat: Kiosk-Terminal-Einstellungsblock in CompanySettingsPage (🖥️) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1256,3 +1256,81 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/routers/special_assignments.py | 10 +++++-----
|
- 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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class KioskDevice(Base):
|
|||||||
|
|
||||||
# ── Ed25519-Auth (löst token_hash + is_active ab) ─────────────────────────
|
# ── Ed25519-Auth (löst token_hash + is_active ab) ─────────────────────────
|
||||||
status: Mapped[KioskDeviceStatus] = mapped_column(
|
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,
|
nullable=False,
|
||||||
default=KioskDeviceStatus.REVOKED,
|
default=KioskDeviceStatus.REVOKED,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,11 +66,18 @@ class TimeEntry(Base):
|
|||||||
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
|
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
|
||||||
if self.end_time is None:
|
if self.end_time is None:
|
||||||
return None
|
return None
|
||||||
start_total = self.start_time.hour * 60 + self.start_time.minute
|
# Sekunden einbeziehen um Sub-Minuten-Einträge korrekt zu behandeln
|
||||||
end_total = self.end_time.hour * 60 + self.end_time.minute
|
# (z.B. Einstemp+Ausstemp in derselben Minute → soll 0h zeigen, nicht 24h)
|
||||||
if end_total <= start_total:
|
start_secs = (self.start_time.hour * 3600
|
||||||
end_total += 24 * 60 # overnight shift
|
+ self.start_time.minute * 60
|
||||||
return max(0, end_total - start_total - self.break_minutes)
|
+ 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
|
@property
|
||||||
def worked_hours(self) -> float | None:
|
def worked_hours(self) -> float | None:
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class CompanyOut(BaseModel):
|
|||||||
mobile_stamping_enabled: bool = True
|
mobile_stamping_enabled: bool = True
|
||||||
overtime_overdraft_allowed: bool = True
|
overtime_overdraft_allowed: bool = True
|
||||||
overtime_warning_threshold_hours: int = 0
|
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):
|
class CompanyUpdate(BaseModel):
|
||||||
@@ -36,6 +39,9 @@ class CompanyUpdate(BaseModel):
|
|||||||
mobile_stamping_enabled: bool | None = None
|
mobile_stamping_enabled: bool | None = None
|
||||||
overtime_overdraft_allowed: bool | None = None
|
overtime_overdraft_allowed: bool | None = None
|
||||||
overtime_warning_threshold_hours: int | None = Field(None, ge=0)
|
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):
|
class DepartmentOut(BaseModel):
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ from app.schemas.time_entry import (
|
|||||||
|
|
||||||
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
|
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
|
||||||
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
|
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
|
||||||
start_mins = start.hour * 60 + start.minute
|
start_secs = start.hour * 3600 + start.minute * 60 + start.second
|
||||||
end_mins = end.hour * 60 + end.minute
|
end_secs = end.hour * 3600 + end.minute * 60 + end.second
|
||||||
if end_mins <= start_mins:
|
if end_secs < start_secs:
|
||||||
end_mins += 24 * 60 # Nachtschicht
|
end_secs += 24 * 3600 # Nachtschicht
|
||||||
total_mins = end_mins - start_mins
|
total_mins = (end_secs - start_secs) // 60
|
||||||
worked_mins = total_mins - break_minutes
|
worked_mins = total_mins - break_minutes
|
||||||
worked_hours = worked_mins / 60
|
worked_hours = worked_mins / 60
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────
|
# ── Subcommand: kiosk info ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@kiosk_app.command("info")
|
@kiosk_app.command("info")
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export function CompanySettingsPage() {
|
|||||||
const [pnNext, setPnNext] = useState(1)
|
const [pnNext, setPnNext] = useState(1)
|
||||||
// Mobile
|
// Mobile
|
||||||
const [mobileStamping, setMobileStamping] = useState(true)
|
const [mobileStamping, setMobileStamping] = useState(true)
|
||||||
|
// Kiosk
|
||||||
|
const [kioskRequireApproval, setKioskRequireApproval] = useState(true)
|
||||||
|
const [kioskTrackCurrentUser, setKioskTrackCurrentUser] = useState(true)
|
||||||
|
const [kioskHeartbeatIntervalSec, setKioskHeartbeatIntervalSec] = useState(30)
|
||||||
// Freizeitausgleich
|
// Freizeitausgleich
|
||||||
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
|
||||||
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
|
||||||
@@ -81,10 +85,13 @@ export function CompanySettingsPage() {
|
|||||||
setPnRequired(c.personnel_number_required ?? false)
|
setPnRequired(c.personnel_number_required ?? false)
|
||||||
setPnMode(c.personnel_number_mode ?? 'manual')
|
setPnMode(c.personnel_number_mode ?? 'manual')
|
||||||
setPnNext(c.personnel_number_next ?? 1)
|
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)
|
setMobileStamping(cc.mobile_stamping_enabled ?? true)
|
||||||
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
|
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
|
||||||
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
|
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(() => {})
|
}).catch(() => {})
|
||||||
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
||||||
.then(setBlStatus)
|
.then(setBlStatus)
|
||||||
@@ -155,6 +162,9 @@ export function CompanySettingsPage() {
|
|||||||
mobile_stamping_enabled: mobileStamping,
|
mobile_stamping_enabled: mobileStamping,
|
||||||
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
overtime_overdraft_allowed: fzaOverdraftAllowed,
|
||||||
overtime_warning_threshold_hours: fzaWarningThreshold,
|
overtime_warning_threshold_hours: fzaWarningThreshold,
|
||||||
|
kiosk_require_approval: kioskRequireApproval,
|
||||||
|
kiosk_track_current_user: kioskTrackCurrentUser,
|
||||||
|
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
|
||||||
})
|
})
|
||||||
setCompany(updated)
|
setCompany(updated)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
@@ -562,6 +572,82 @@ export function CompanySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kiosk-Terminal-Einstellungen */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🖥️</span>
|
||||||
|
<h2 className="font-semibold text-gray-700">Kiosk-Terminals</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* kiosk_require_approval */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Neue Geräte benötigen Admin-Freigabe</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Wenn aktiv, muss jedes neue Kiosk-Gerät erst durch einen Admin freigeschaltet werden, bevor es Stempelungen akzeptiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
onClick={() => setKioskRequireApproval(v => !v)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
kioskRequireApproval ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
|
||||||
|
kioskRequireApproval ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* kiosk_track_current_user */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Aktuellen Benutzer am Terminal speichern (DSGVO)</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Wenn aktiv, wird der zuletzt eingestempelte Mitarbeiter am Gerät gespeichert. Deaktivieren für DSGVO-konformeren Betrieb.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
onClick={() => setKioskTrackCurrentUser(v => !v)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
kioskTrackCurrentUser ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
|
||||||
|
kioskTrackCurrentUser ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* kiosk_heartbeat_interval_sec */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Heartbeat-Intervall (Sekunden)</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Wie oft Kiosk-Terminals ihren Status an den Server melden. Minimum 10s, Maximum 120s.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
value={kioskHeartbeatIntervalSec}
|
||||||
|
onChange={e => setKioskHeartbeatIntervalSec(Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
|
||||||
|
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Freizeitausgleich-Einstellungen */}
|
{/* Freizeitausgleich-Einstellungen */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user