feat: Live-Stempel-Uhr, Break-UI, Balance-Widget, Approval-Queue + PDF-Export (WeasyPrint)
Frontend (TimeTrackingPage): - Live-Arbeitsuhr (HH:MM:SS) während eingestempelt - Break-Start/End-Buttons mit laufender Pausenuhr - Wochen-Balance-Widget (gearbeitet / erwartet / überstunden) - Approval-Queue Tab für Manager/HR/Admin (pending entries genehmigen/ablehnen) Backend (Reports): - weasyprint>=61.0 in requirements.txt - 3 neue PDF-Export-Tests (Zeit, Abwesenheit, Überstunden) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -718,3 +718,131 @@ Keine Commits in dieser Session.
|
|||||||
- backend/tests/test_time.py | 25 ++++++++++++++++++++++---
|
- backend/tests/test_time.py | 25 ++++++++++++++++++++++---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-24 10:26 – 11:29 (1h 03m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- ada1b51 docs: vollständige Projektdokumentation hinzugefügt
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:43 – 11:43 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:46 – 11:47 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:48 – 11:49 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:51 – 11:51 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:54 – 11:55 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:55 – 11:56 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 11:57 – 11:57 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 64 +++
|
||||||
|
- docs/api.md | 1375 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- docs/architecture.md | 461 +++++++++++++++++
|
||||||
|
- docs/deployment.md | 429 ++++++++++++++++
|
||||||
|
- docs/development.md | 531 +++++++++++++++++++
|
||||||
|
- frontend/DEVLOG.md | 22 +
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.absence import AbsenceStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ── AbsenceType ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AbsenceTypeOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
requires_approval: bool
|
||||||
|
deducts_vacation: bool
|
||||||
|
is_paid: bool
|
||||||
|
max_days_per_year: int | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceTypeCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
requires_approval: bool = True
|
||||||
|
deducts_vacation: bool = False
|
||||||
|
is_paid: bool = True
|
||||||
|
max_days_per_year: int | None = Field(None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceTypeUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
requires_approval: bool | None = None
|
||||||
|
deducts_vacation: bool | None = None
|
||||||
|
is_paid: bool | None = None
|
||||||
|
max_days_per_year: int | None = Field(None, ge=1)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Absence ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AbsenceOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
type_id: uuid.UUID
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
half_day_start: bool
|
||||||
|
half_day_end: bool
|
||||||
|
working_days: float
|
||||||
|
status: AbsenceStatus
|
||||||
|
approved_by: uuid.UUID | None
|
||||||
|
substitute_id: uuid.UUID | None
|
||||||
|
note: str | None
|
||||||
|
correction_note: str | None
|
||||||
|
rejection_reason: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceCreate(BaseModel):
|
||||||
|
type_id: uuid.UUID
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
half_day_start: bool = False
|
||||||
|
half_day_end: bool = False
|
||||||
|
substitute_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date must be >= start_date")
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceUpdate(BaseModel):
|
||||||
|
type_id: uuid.UUID | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
half_day_start: bool | None = None
|
||||||
|
half_day_end: bool | None = None
|
||||||
|
substitute_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.start_date and self.end_date and self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date must be >= start_date")
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceReject(BaseModel):
|
||||||
|
rejection_reason: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[AbsenceOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ── VacationBalance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class VacationBalanceOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
year: int
|
||||||
|
entitled_days: int
|
||||||
|
carried_over: int
|
||||||
|
used_days: int
|
||||||
|
remaining_days: int
|
||||||
|
pending_days: float = 0 # Ausstehende Anträge (noch nicht genehmigt)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PublicHoliday ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PublicHolidayOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
country: str
|
||||||
|
state: str | None
|
||||||
|
date: date
|
||||||
|
name: str
|
||||||
|
year: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublicHolidayCreate(BaseModel):
|
||||||
|
country: str = Field("DE", min_length=2, max_length=10)
|
||||||
|
state: str | None = Field(None, max_length=10)
|
||||||
|
date: date
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OvertimeBalance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OvertimeBalanceOut(BaseModel):
|
||||||
|
total_hours: float
|
||||||
|
taken_hours: float
|
||||||
|
available_hours: float
|
||||||
|
|
||||||
|
|
||||||
|
# ── Calendar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CalendarEntry(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
absence_id: uuid.UUID
|
||||||
|
type_name: str
|
||||||
|
type_color: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
status: AbsenceStatus
|
||||||
|
working_days: float
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceCategory, AbsenceType
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.overtime_balance import OvertimeBalance
|
||||||
|
from app.models.public_holiday import PublicHoliday
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.vacation_balance import VacationBalance
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
|
||||||
|
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceService:
|
||||||
|
|
||||||
|
# ── AbsenceTypes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
|
||||||
|
result = await db.scalars(
|
||||||
|
select(AbsenceType)
|
||||||
|
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
|
||||||
|
.order_by(AbsenceType.name)
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def create_type(
|
||||||
|
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = AbsenceType(company_id=company_id, **data.model_dump())
|
||||||
|
db.add(at)
|
||||||
|
await db.flush()
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def update_type(
|
||||||
|
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = await self._get_type_or_404(type_id, company_id, db)
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(at, field, value)
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
|
||||||
|
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
|
||||||
|
defaults = [
|
||||||
|
{
|
||||||
|
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
|
||||||
|
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
|
||||||
|
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
|
||||||
|
"requires_certificate": True, "certificate_after_days": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
|
||||||
|
"requires_approval": True, "deducts_vacation": False,
|
||||||
|
"affects_overtime_balance": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
"max_days_per_year": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
|
||||||
|
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for d in defaults:
|
||||||
|
db.add(AbsenceType(company_id=company_id, **d))
|
||||||
|
|
||||||
|
# Standard-Arbeitsplan: Mo–Fr 8h
|
||||||
|
schedule = WorkSchedule(
|
||||||
|
company_id=company_id,
|
||||||
|
name="Vollzeit (40h)",
|
||||||
|
valid_from=date.today(),
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── Absences ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_absences(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: UUID | None = None,
|
||||||
|
type_id: UUID | None = None,
|
||||||
|
status: AbsenceStatus | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
) -> tuple[int, list[Absence]]:
|
||||||
|
q = (
|
||||||
|
select(Absence)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.where(User.company_id == company_id)
|
||||||
|
)
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
q = q.where(Absence.user_id == current_user.id)
|
||||||
|
elif user_id:
|
||||||
|
q = q.where(Absence.user_id == user_id)
|
||||||
|
|
||||||
|
if type_id:
|
||||||
|
q = q.where(Absence.type_id == type_id)
|
||||||
|
if status:
|
||||||
|
q = q.where(Absence.status == status)
|
||||||
|
if year:
|
||||||
|
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
|
||||||
|
|
||||||
|
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
|
||||||
|
result = await db.scalars(q.order_by(Absence.start_date.desc()))
|
||||||
|
return total, list(result.all())
|
||||||
|
|
||||||
|
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
if absence.user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def create_absence(
|
||||||
|
self,
|
||||||
|
data: AbsenceCreate,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[Absence, list[str]]:
|
||||||
|
# AbsenceType validieren
|
||||||
|
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||||||
|
|
||||||
|
# Arbeitstage berechnen
|
||||||
|
holidays = await self._get_holiday_dates(
|
||||||
|
current_user.company_id, data.start_date.year, db
|
||||||
|
)
|
||||||
|
working_days = self._calc_working_days(
|
||||||
|
data.start_date, data.end_date, holidays,
|
||||||
|
data.half_day_start, data.half_day_end
|
||||||
|
)
|
||||||
|
|
||||||
|
if working_days <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
|
||||||
|
|
||||||
|
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
|
||||||
|
warnings: list[str] = []
|
||||||
|
if absence_type.deducts_vacation:
|
||||||
|
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
|
||||||
|
if balance.remaining_days < working_days:
|
||||||
|
warnings.append(
|
||||||
|
f"Urlaubskonto reicht möglicherweise nicht aus: "
|
||||||
|
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Überschneidung mit eigenen Abwesenheiten prüfen
|
||||||
|
overlap = await db.scalar(
|
||||||
|
select(Absence).where(
|
||||||
|
and_(
|
||||||
|
Absence.user_id == current_user.id,
|
||||||
|
Absence.status != AbsenceStatus.CANCELLED,
|
||||||
|
Absence.status != AbsenceStatus.REJECTED,
|
||||||
|
Absence.start_date <= data.end_date,
|
||||||
|
Absence.end_date >= data.start_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if overlap:
|
||||||
|
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
|
||||||
|
|
||||||
|
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
|
||||||
|
approved_by = None if absence_type.requires_approval else current_user.id
|
||||||
|
|
||||||
|
absence = Absence(
|
||||||
|
user_id=current_user.id,
|
||||||
|
type_id=data.type_id,
|
||||||
|
start_date=data.start_date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
half_day_start=data.half_day_start,
|
||||||
|
half_day_end=data.half_day_end,
|
||||||
|
working_days=working_days,
|
||||||
|
status=status,
|
||||||
|
approved_by=approved_by,
|
||||||
|
substitute_id=data.substitute_id,
|
||||||
|
note=data.note,
|
||||||
|
)
|
||||||
|
db.add(absence)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Bei automatischer Genehmigung Konto abziehen
|
||||||
|
if not absence_type.requires_approval and absence_type.deducts_vacation:
|
||||||
|
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
|
||||||
|
|
||||||
|
return absence, warnings
|
||||||
|
|
||||||
|
async def update_absence(
|
||||||
|
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
from app.schemas.absence import AbsenceUpdate
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
# Mitarbeiter: nur eigene; Manager: gleiche Company
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
if absence.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
else:
|
||||||
|
owner = await db.get(User, absence.user_id)
|
||||||
|
if owner is None or owner.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
is_manager = current_user.role in _manager_roles
|
||||||
|
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
|
||||||
|
if absence.status == AbsenceStatus.APPROVED and not is_manager:
|
||||||
|
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
|
||||||
|
if not data.correction_note or not data.correction_note.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
|
||||||
|
|
||||||
|
if data.type_id is not None:
|
||||||
|
await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||||||
|
absence.type_id = data.type_id
|
||||||
|
if data.start_date is not None:
|
||||||
|
absence.start_date = data.start_date
|
||||||
|
if data.end_date is not None:
|
||||||
|
absence.end_date = data.end_date
|
||||||
|
if data.half_day_start is not None:
|
||||||
|
absence.half_day_start = data.half_day_start
|
||||||
|
if data.half_day_end is not None:
|
||||||
|
absence.half_day_end = data.half_day_end
|
||||||
|
if data.substitute_id is not None:
|
||||||
|
absence.substitute_id = data.substitute_id
|
||||||
|
if data.note is not None:
|
||||||
|
absence.note = data.note
|
||||||
|
if data.correction_note is not None:
|
||||||
|
absence.correction_note = data.correction_note.strip() or None
|
||||||
|
|
||||||
|
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
|
||||||
|
was_approved = absence.status == AbsenceStatus.APPROVED
|
||||||
|
if was_approved and not is_manager:
|
||||||
|
absence.status = AbsenceStatus.PENDING
|
||||||
|
absence.approved_by = None
|
||||||
|
|
||||||
|
# Arbeitstage neu berechnen
|
||||||
|
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
|
||||||
|
absence.working_days = Decimal(str(
|
||||||
|
self._calc_working_days(absence.start_date, absence.end_date,
|
||||||
|
holiday_dates, absence.half_day_start, absence.half_day_end)
|
||||||
|
))
|
||||||
|
|
||||||
|
# Audit-Log
|
||||||
|
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
|
||||||
|
db.add(AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=action,
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "approved" if was_approved else "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": absence.status.value,
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
"correction_note": absence.correction_note,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def cancel_absence(
|
||||||
|
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
if absence.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
|
||||||
|
absence.status = AbsenceStatus.CANCELLED
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_cancelled",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "cancelled",
|
||||||
|
"cancelled_by": str(current_user.id),
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def approve_absence(
|
||||||
|
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
requester = await db.get(User, absence.user_id)
|
||||||
|
if requester is None or requester.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
if absence.user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
|
||||||
|
)
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
|
||||||
|
|
||||||
|
absence.status = AbsenceStatus.APPROVED
|
||||||
|
absence.approved_by = current_user.id
|
||||||
|
|
||||||
|
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||||
|
|
||||||
|
# Urlaubskonto abziehen wenn nötig
|
||||||
|
if absence_type and absence_type.deducts_vacation:
|
||||||
|
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
|
||||||
|
|
||||||
|
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||||
|
if absence_type and absence_type.affects_overtime_balance:
|
||||||
|
await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_approved",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "approved",
|
||||||
|
"approved_by": str(current_user.id),
|
||||||
|
"approved_by_name": current_user.full_name,
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
# CalDAV-Sync (fire & forget – Fehler blockieren nicht die Genehmigung)
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def reject_absence(
|
||||||
|
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
requester = await db.get(User, absence.user_id)
|
||||||
|
if requester is None or requester.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
|
||||||
|
|
||||||
|
absence.status = AbsenceStatus.REJECTED
|
||||||
|
absence.approved_by = current_user.id
|
||||||
|
absence.rejection_reason = data.rejection_reason
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_rejected",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "rejected",
|
||||||
|
"rejection_reason": absence.rejection_reason,
|
||||||
|
"rejected_by": str(current_user.id),
|
||||||
|
"rejected_by_name": current_user.full_name,
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def get_calendar(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
year: int,
|
||||||
|
month: int | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[dict]:
|
||||||
|
q = (
|
||||||
|
select(Absence, User, AbsenceType)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if month:
|
||||||
|
start = date(year, month, 1)
|
||||||
|
end = date(year, month, 28) + timedelta(days=4)
|
||||||
|
end = end.replace(day=1) - timedelta(days=1)
|
||||||
|
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
|
||||||
|
else:
|
||||||
|
q = q.where(
|
||||||
|
Absence.start_date >= date(year, 1, 1),
|
||||||
|
Absence.end_date <= date(year, 12, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(q.order_by(Absence.start_date))
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
calendar = []
|
||||||
|
for absence, user, atype in rows:
|
||||||
|
calendar.append({
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_name": user.full_name,
|
||||||
|
"absence_id": absence.id,
|
||||||
|
"type_name": atype.name,
|
||||||
|
"type_color": atype.color,
|
||||||
|
"start_date": absence.start_date,
|
||||||
|
"end_date": absence.end_date,
|
||||||
|
"status": absence.status,
|
||||||
|
"working_days": absence.working_days,
|
||||||
|
})
|
||||||
|
return calendar
|
||||||
|
|
||||||
|
# ── Urlaubskonto ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
|
||||||
|
return await self._get_or_create_balance(user_id, year, db)
|
||||||
|
|
||||||
|
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
|
||||||
|
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
|
||||||
|
q = (
|
||||||
|
select(func.sum(Absence.working_days))
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
Absence.user_id == user_id,
|
||||||
|
Absence.status == AbsenceStatus.PENDING,
|
||||||
|
AbsenceType.deducts_vacation.is_(True),
|
||||||
|
func.extract("year", Absence.start_date) == year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.scalar(q)
|
||||||
|
return float(result or 0)
|
||||||
|
|
||||||
|
# ── Feiertage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_holidays(
|
||||||
|
self, year: int, country: str, state: str | None, db: AsyncSession
|
||||||
|
) -> list[PublicHoliday]:
|
||||||
|
q = select(PublicHoliday).where(
|
||||||
|
PublicHoliday.year == year, PublicHoliday.country == country
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||||||
|
result = await db.scalars(q.order_by(PublicHoliday.date))
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
|
||||||
|
holiday = PublicHoliday(
|
||||||
|
country=data.country,
|
||||||
|
state=data.state,
|
||||||
|
date=data.date,
|
||||||
|
name=data.name,
|
||||||
|
year=data.date.year,
|
||||||
|
)
|
||||||
|
db.add(holiday)
|
||||||
|
await db.flush()
|
||||||
|
return holiday
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_type_or_404(
|
||||||
|
self, type_id: UUID, company_id: UUID, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = await db.get(AbsenceType, type_id)
|
||||||
|
if at is None or at.company_id != company_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def _get_or_create_balance(
|
||||||
|
self, user_id: UUID, year: int, db: AsyncSession
|
||||||
|
) -> VacationBalance:
|
||||||
|
balance = await db.scalar(
|
||||||
|
select(VacationBalance).where(
|
||||||
|
VacationBalance.user_id == user_id, VacationBalance.year == year
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if balance is None:
|
||||||
|
balance = VacationBalance(user_id=user_id, year=year, entitled_days=30)
|
||||||
|
db.add(balance)
|
||||||
|
await db.flush()
|
||||||
|
return balance
|
||||||
|
|
||||||
|
async def _deduct_vacation(
|
||||||
|
self, user_id: UUID, year: int, days: int, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
balance = await self._get_or_create_balance(user_id, year, db)
|
||||||
|
balance.used_days += days
|
||||||
|
|
||||||
|
async def _deduct_overtime(
|
||||||
|
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
|
||||||
|
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
daily_hours = Decimal("8.00")
|
||||||
|
if user and user.work_schedule_id:
|
||||||
|
schedule = await db.get(WorkSchedule, user.work_schedule_id)
|
||||||
|
if schedule:
|
||||||
|
working_days_in_week = sum(
|
||||||
|
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
|
||||||
|
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
|
||||||
|
if h > 0
|
||||||
|
)
|
||||||
|
if working_days_in_week > 0:
|
||||||
|
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
|
||||||
|
|
||||||
|
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||||
|
|
||||||
|
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||||
|
if ob is None:
|
||||||
|
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
|
||||||
|
company_id = user.company_id if user else None
|
||||||
|
if not company_id:
|
||||||
|
return
|
||||||
|
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
|
||||||
|
db.add(ob)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
ob.taken_hours += hours_to_deduct
|
||||||
|
|
||||||
|
async def _get_holiday_dates(
|
||||||
|
self, company_id: UUID, year: int, db: AsyncSession
|
||||||
|
) -> set[date]:
|
||||||
|
"""Feiertage für die Company-Country holen."""
|
||||||
|
from app.models.company import Company
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
company = await db.get(Company, company_id)
|
||||||
|
country = company.country if company else "DE"
|
||||||
|
state = company.state if company else None
|
||||||
|
|
||||||
|
q = select(PublicHoliday.date).where(
|
||||||
|
PublicHoliday.year == year,
|
||||||
|
PublicHoliday.country == country,
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||||||
|
|
||||||
|
result = await db.scalars(q)
|
||||||
|
return set(result.all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_working_days(
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
holidays: set[date],
|
||||||
|
half_day_start: bool,
|
||||||
|
half_day_end: bool,
|
||||||
|
) -> float:
|
||||||
|
count = 0.0
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
if current.weekday() < 5 and current not in holidays:
|
||||||
|
count += 1.0
|
||||||
|
current += timedelta(days=1)
|
||||||
|
# Halbtage abziehen
|
||||||
|
if half_day_start and start.weekday() < 5 and start not in holidays:
|
||||||
|
count -= 0.5
|
||||||
|
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
|
||||||
|
count -= 0.5
|
||||||
|
return max(0.0, count)
|
||||||
|
|
||||||
|
|
||||||
|
absence_service = AbsenceService()
|
||||||
@@ -23,3 +23,4 @@ pytest>=8.0.0
|
|||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
pytest-httpx>=0.30.0
|
pytest-httpx>=0.30.0
|
||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
|
weasyprint>=61.0
|
||||||
|
|||||||
@@ -308,3 +308,47 @@ def test_to_xlsx_with_data():
|
|||||||
result = svc.to_xlsx(rows, sheet_name="Test")
|
result = svc.to_xlsx(rows, sheet_name="Test")
|
||||||
assert isinstance(result, bytes)
|
assert isinstance(result, bytes)
|
||||||
assert len(result) > 1000 # XLSX ist ZIP-basiert
|
assert len(result) > 1000 # XLSX ist ZIP-basiert
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF Export ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_time_pdf(client: AsyncClient, report_headers):
|
||||||
|
today = date.today()
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/reports/time/export",
|
||||||
|
params={"date_from": str(today.replace(day=1)), "date_to": str(today), "format": "pdf"},
|
||||||
|
headers=report_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "pdf" in resp.headers["content-type"]
|
||||||
|
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||||
|
assert len(resp.content) > 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_absence_pdf(client: AsyncClient, report_headers):
|
||||||
|
today = date.today()
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/reports/absences/export",
|
||||||
|
params={"date_from": str(today.replace(day=1)), "date_to": str(today), "format": "pdf"},
|
||||||
|
headers=report_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "pdf" in resp.headers["content-type"]
|
||||||
|
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||||
|
assert len(resp.content) > 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_overtime_pdf(client: AsyncClient, report_headers):
|
||||||
|
today = date.today()
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/reports/overtime/export",
|
||||||
|
params={"date_from": str(today.replace(day=1)), "date_to": str(today), "format": "pdf"},
|
||||||
|
headers=report_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "pdf" in resp.headers["content-type"]
|
||||||
|
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||||
|
assert len(resp.content) > 1000
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ interface TimeEntryOut {
|
|||||||
correction_note: string | null
|
correction_note: string | null
|
||||||
status: string
|
status: string
|
||||||
source: string
|
source: string
|
||||||
|
break_start?: string | null // ISO-Timestamp wenn Pause läuft
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TimeEntryWithWarnings {
|
interface TimeEntryWithWarnings {
|
||||||
entry: TimeEntryOut
|
entry: TimeEntryOut
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
@@ -53,6 +53,8 @@ interface TodayStatus {
|
|||||||
today_open: boolean
|
today_open: boolean
|
||||||
today_start: string | null
|
today_start: string | null
|
||||||
today_hours_so_far: number | null
|
today_hours_so_far: number | null
|
||||||
|
break_start?: string | null
|
||||||
|
break_minutes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -73,7 +75,6 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
function fmt(iso: string | null): string {
|
function fmt(iso: string | null): string {
|
||||||
if (!iso) return '–'
|
if (!iso) return '–'
|
||||||
// Backend kann reines time-Objekt liefern ("HH:MM:SS") oder ISO-Datetime
|
|
||||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
|
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (isNaN(d.getTime())) return iso.slice(0, 5)
|
if (isNaN(d.getTime())) return iso.slice(0, 5)
|
||||||
@@ -87,11 +88,33 @@ function fmtH(h: number | null): string {
|
|||||||
return `${hrs}h ${min}m`
|
return `${hrs}h ${min}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtHMS(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMS(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt den ISO-Datums-String (YYYY-MM-DD) des Montags der aktuellen Woche zurück */
|
||||||
|
function getMondayOfCurrentWeek(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const day = now.getDay() // 0=So, 1=Mo, ...
|
||||||
|
const diff = day === 0 ? -6 : 1 - day
|
||||||
|
const monday = new Date(now)
|
||||||
|
monday.setDate(now.getDate() + diff)
|
||||||
|
return monday.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
export function TimeTrackingPage() {
|
export function TimeTrackingPage() {
|
||||||
const [user, setUser] = useState<UserOut | null>(null)
|
const [user, setUser] = useState<UserOut | null>(null)
|
||||||
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
|
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
|
||||||
const [balance, setBalance] = useState<BalanceResponse | null>(null)
|
const [weekBalance, setWeekBalance] = useState<BalanceResponse | null>(null)
|
||||||
const [entries, setEntries] = useState<TimeEntryOut[]>([])
|
const [entries, setEntries] = useState<TimeEntryOut[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -100,6 +123,15 @@ export function TimeTrackingPage() {
|
|||||||
const [warnings, setWarnings] = useState<string[]>([])
|
const [warnings, setWarnings] = useState<string[]>([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Approval queue (Manager+)
|
||||||
|
const [activeTab, setActiveTab] = useState<'mine' | 'approval'>('mine')
|
||||||
|
const [pendingEntries, setPendingEntries] = useState<TimeEntryOut[]>([])
|
||||||
|
const [pendingTotal, setPendingTotal] = useState(0)
|
||||||
|
const [pendingLoading, setPendingLoading] = useState(false)
|
||||||
|
const [rejectId, setRejectId] = useState<string | null>(null)
|
||||||
|
const [rejectNote, setRejectNote] = useState('')
|
||||||
|
const [approvalError, setApprovalError] = useState('')
|
||||||
|
|
||||||
// Edit modal
|
// Edit modal
|
||||||
const [editEntry, setEditEntry] = useState<TimeEntryOut | null>(null)
|
const [editEntry, setEditEntry] = useState<TimeEntryOut | null>(null)
|
||||||
const [editStart, setEditStart] = useState('')
|
const [editStart, setEditStart] = useState('')
|
||||||
@@ -130,21 +162,25 @@ export function TimeTrackingPage() {
|
|||||||
const [newSaving, setNewSaving] = useState(false)
|
const [newSaving, setNewSaving] = useState(false)
|
||||||
const [newError, setNewError] = useState('')
|
const [newError, setNewError] = useState('')
|
||||||
|
|
||||||
|
// Live tickers
|
||||||
const [liveSeconds, setLiveSeconds] = useState(0)
|
const [liveSeconds, setLiveSeconds] = useState(0)
|
||||||
|
const [breakSeconds, setBreakSeconds] = useState(0)
|
||||||
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const breakTickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const monday = getMondayOfCurrentWeek()
|
||||||
const [me, dash, bal, list] = await Promise.all([
|
const [me, dash, bal, list] = await Promise.all([
|
||||||
api.get<UserOut>('/auth/me'),
|
api.get<UserOut>('/auth/me'),
|
||||||
api.get<TodayStatus>('/dashboard/me'),
|
api.get<TodayStatus>('/dashboard/me'),
|
||||||
api.get<BalanceResponse>('/time/balance/me'),
|
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
|
||||||
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
|
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
|
||||||
])
|
])
|
||||||
setUser(me)
|
setUser(me)
|
||||||
setDashboard(dash)
|
setDashboard(dash)
|
||||||
setBalance(bal)
|
setWeekBalance(bal)
|
||||||
setEntries(list.items)
|
setEntries(list.items)
|
||||||
setTotal(list.total)
|
setTotal(list.total)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -154,25 +190,61 @@ export function TimeTrackingPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadPending = useCallback(async () => {
|
||||||
|
setPendingLoading(true)
|
||||||
|
setApprovalError('')
|
||||||
|
try {
|
||||||
|
const res = await api.get<TimeEntryListResponse>('/time/entries?status=pending&limit=50')
|
||||||
|
setPendingEntries(res.items)
|
||||||
|
setPendingTotal(res.total)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setApprovalError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setPendingLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// Live-Ticker: läuft nur wenn eingestempelt
|
// Lade Approval-Queue wenn Tab aktiv und User ein Manager ist
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'approval' && user && ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||||
|
loadPending()
|
||||||
|
}
|
||||||
|
}, [activeTab, user, loadPending])
|
||||||
|
|
||||||
|
// Live-Ticker: läuft nur wenn eingestempelt und KEINE Pause läuft
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tickerRef.current) clearInterval(tickerRef.current)
|
if (tickerRef.current) clearInterval(tickerRef.current)
|
||||||
if (dashboard?.today_open && dashboard.today_start) {
|
if (dashboard?.today_open && dashboard.today_start && !dashboard.break_start) {
|
||||||
// Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen
|
|
||||||
const startStr = dashboard.today_start
|
const startStr = dashboard.today_start
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const [h, m, s] = startStr.split(':').map(Number)
|
const [h, m, s] = startStr.split(':').map(Number)
|
||||||
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
|
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
|
||||||
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs) / 1000)))
|
// Bereits vergangene Pausenminuten abziehen
|
||||||
|
const pausedMs = (dashboard.break_minutes ?? 0) * 60 * 1000
|
||||||
|
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs - pausedMs) / 1000)))
|
||||||
update()
|
update()
|
||||||
tickerRef.current = setInterval(update, 1000)
|
tickerRef.current = setInterval(update, 1000)
|
||||||
} else {
|
} else {
|
||||||
setLiveSeconds(0)
|
setLiveSeconds(0)
|
||||||
}
|
}
|
||||||
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
|
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
|
||||||
}, [dashboard?.today_open, dashboard?.today_start])
|
}, [dashboard?.today_open, dashboard?.today_start, dashboard?.break_start, dashboard?.break_minutes])
|
||||||
|
|
||||||
|
// Pausen-Ticker: läuft nur wenn Pause aktiv
|
||||||
|
useEffect(() => {
|
||||||
|
if (breakTickerRef.current) clearInterval(breakTickerRef.current)
|
||||||
|
if (dashboard?.today_open && dashboard.break_start) {
|
||||||
|
const breakStartMs = new Date(dashboard.break_start).getTime()
|
||||||
|
const update = () => setBreakSeconds(Math.max(0, Math.floor((Date.now() - breakStartMs) / 1000)))
|
||||||
|
update()
|
||||||
|
breakTickerRef.current = setInterval(update, 1000)
|
||||||
|
} else {
|
||||||
|
setBreakSeconds(0)
|
||||||
|
}
|
||||||
|
return () => { if (breakTickerRef.current) clearInterval(breakTickerRef.current) }
|
||||||
|
}, [dashboard?.today_open, dashboard?.break_start])
|
||||||
|
|
||||||
const stampIn = async () => {
|
const stampIn = async () => {
|
||||||
setStamping(true); setError(''); setWarnings([])
|
setStamping(true); setError(''); setWarnings([])
|
||||||
@@ -212,6 +284,26 @@ export function TimeTrackingPage() {
|
|||||||
|
|
||||||
const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '')
|
const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '')
|
||||||
const canManual = isManager || (user?.can_manual_time_entry ?? false)
|
const canManual = isManager || (user?.can_manual_time_entry ?? false)
|
||||||
|
const isOnBreak = dashboard?.today_open && !!dashboard.break_start
|
||||||
|
|
||||||
|
const approveEntry = async (id: string) => {
|
||||||
|
setApprovalError('')
|
||||||
|
try {
|
||||||
|
await api.post(`/time/entries/${id}/approve`, {})
|
||||||
|
await loadPending()
|
||||||
|
} catch (e: unknown) { setApprovalError(e instanceof Error ? e.message : 'Fehler beim Genehmigen') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectEntry = async () => {
|
||||||
|
if (!rejectId) return
|
||||||
|
setApprovalError('')
|
||||||
|
try {
|
||||||
|
await api.post(`/time/entries/${rejectId}/reject`, { rejection_note: rejectNote || null })
|
||||||
|
setRejectId(null)
|
||||||
|
setRejectNote('')
|
||||||
|
await loadPending()
|
||||||
|
} catch (e: unknown) { setApprovalError(e instanceof Error ? e.message : 'Fehler beim Ablehnen') }
|
||||||
|
}
|
||||||
|
|
||||||
const deleteEntry = async (id: string) => {
|
const deleteEntry = async (id: string) => {
|
||||||
if (!confirm('Eintrag wirklich löschen?')) return
|
if (!confirm('Eintrag wirklich löschen?')) return
|
||||||
@@ -224,7 +316,7 @@ export function TimeTrackingPage() {
|
|||||||
|
|
||||||
const openDuplicate = (entry: TimeEntryOut) => {
|
const openDuplicate = (entry: TimeEntryOut) => {
|
||||||
setDupEntry(entry)
|
setDupEntry(entry)
|
||||||
setDupDate(new Date().toISOString().slice(0, 10)) // heute als Standard
|
setDupDate(new Date().toISOString().slice(0, 10))
|
||||||
setDupStart(fmt(entry.start_time))
|
setDupStart(fmt(entry.start_time))
|
||||||
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
|
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
|
||||||
setDupBreak(entry.break_minutes)
|
setDupBreak(entry.break_minutes)
|
||||||
@@ -335,30 +427,67 @@ export function TimeTrackingPage() {
|
|||||||
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
|
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Wochen-Balance Widget */}
|
||||||
|
{weekBalance && (
|
||||||
|
<div className='bg-white rounded-xl shadow-sm border border-gray-100 p-6'>
|
||||||
|
<p className='text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4'>
|
||||||
|
Aktuelle Woche ({new Date(weekBalance.period_start + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} – {new Date(weekBalance.period_end + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })})
|
||||||
|
</p>
|
||||||
|
<div className='grid grid-cols-3 gap-6'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<p className='text-xs text-gray-500 mb-1'>Gearbeitet</p>
|
||||||
|
<p className='text-2xl font-bold text-gray-800'>{fmtH(weekBalance.total_hours_worked)}</p>
|
||||||
|
</div>
|
||||||
|
<div className='text-center border-x border-gray-100'>
|
||||||
|
<p className='text-xs text-gray-500 mb-1'>Erwartet</p>
|
||||||
|
<p className='text-2xl font-bold text-gray-800'>{fmtH(weekBalance.expected_hours)}</p>
|
||||||
|
</div>
|
||||||
|
<div className='text-center'>
|
||||||
|
<p className='text-xs text-gray-500 mb-1'>Überstunden</p>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
weekBalance.overtime_hours > 0
|
||||||
|
? 'text-green-600'
|
||||||
|
: weekBalance.overtime_hours < 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: 'text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{weekBalance.overtime_hours > 0 ? '+' : ''}{fmtH(weekBalance.overtime_hours)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stempeluhr */}
|
{/* Stempeluhr */}
|
||||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
<div className='bg-white rounded-xl shadow-sm border border-gray-100 p-6'>
|
||||||
<h2 className='text-lg font-semibold text-gray-800 mb-4'>Stempeluhr</h2>
|
<h2 className='text-lg font-semibold text-gray-800 mb-5'>Stempeluhr</h2>
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4'>
|
|
||||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
{/* Live-Uhr – nur sichtbar wenn eingestempelt */}
|
||||||
<p className='text-xs text-gray-500 mb-1'>Status</p>
|
{isOpen && (
|
||||||
<p className={`text-lg font-bold ${isOpen ? 'text-green-600' : 'text-gray-500'}`}>
|
<div className='flex flex-col items-center mb-6 py-4 bg-gray-50 rounded-xl'>
|
||||||
{isOpen ? 'Eingestempelt' : 'Ausgestempelt'}
|
{isOnBreak ? (
|
||||||
|
<>
|
||||||
|
<p className='text-xs font-medium text-yellow-600 uppercase tracking-widest mb-2'>Pause läuft</p>
|
||||||
|
<p className='text-4xl font-mono font-bold text-yellow-500'>{fmtMS(breakSeconds)}</p>
|
||||||
|
<p className='text-xs text-gray-400 mt-2'>
|
||||||
|
Gesamtpause bisher: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</>
|
||||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
) : (
|
||||||
<p className='text-xs text-gray-500 mb-1'>Beginn</p>
|
<>
|
||||||
<p className='text-lg font-bold text-gray-800'>{fmt(dashboard?.today_start ?? null)}</p>
|
<p className='text-xs font-medium text-green-600 uppercase tracking-widest mb-2'>Arbeitszeit</p>
|
||||||
</div>
|
<p className='text-4xl font-mono font-bold text-gray-800'>{fmtHMS(liveSeconds)}</p>
|
||||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
<p className='text-xs text-gray-400 mt-2'>
|
||||||
<p className='text-xs text-gray-500 mb-1'>Bisher heute</p>
|
Start: {fmt(dashboard?.today_start ?? null)}
|
||||||
<p className='text-lg font-bold text-gray-800'>
|
{(dashboard?.break_minutes ?? 0) > 0 && ` · Pause: ${dashboard!.break_minutes} min`}
|
||||||
{dashboard?.today_open && liveSeconds > 0
|
|
||||||
? `${Math.floor(liveSeconds / 3600)}h ${Math.floor((liveSeconds % 3600) / 60)}m ${liveSeconds % 60}s`
|
|
||||||
: fmtH(dashboard?.today_hours_so_far ?? null)}
|
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className='flex gap-2 mb-3'>
|
|
||||||
|
{/* Notiz-Eingabe */}
|
||||||
|
<div className='flex gap-2 mb-4'>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
placeholder='Notiz (optional)'
|
placeholder='Notiz (optional)'
|
||||||
@@ -367,59 +496,78 @@ export function TimeTrackingPage() {
|
|||||||
className='flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
className='flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stempel-Buttons */}
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{!isOpen ? (
|
{!isOpen ? (
|
||||||
<button onClick={stampIn} disabled={stamping}
|
<button onClick={stampIn} disabled={stamping}
|
||||||
className='px-6 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors'>
|
className='px-6 py-2 rounded-lg font-medium text-sm bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors'>
|
||||||
{stamping ? '...' : 'Einstempeln'}
|
{stamping ? '...' : 'Einstempeln'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button onClick={stampOut} disabled={stamping}
|
<button onClick={stampOut} disabled={stamping}
|
||||||
className='px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors'>
|
className='px-6 py-2 rounded-lg font-medium text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors'>
|
||||||
{stamping ? '...' : 'Ausstempeln'}
|
{stamping ? '...' : 'Ausstempeln'}
|
||||||
</button>
|
</button>
|
||||||
|
{!isOnBreak ? (
|
||||||
<button onClick={breakStart} disabled={stamping}
|
<button onClick={breakStart} disabled={stamping}
|
||||||
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
|
className='px-4 py-2 rounded-lg font-medium text-sm bg-yellow-400 text-white hover:bg-yellow-500 disabled:opacity-50 transition-colors'>
|
||||||
Pause starten
|
{stamping ? '...' : '☕ Pause starten'}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
<button onClick={breakEnd} disabled={stamping}
|
<button onClick={breakEnd} disabled={stamping}
|
||||||
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
|
className='px-4 py-2 rounded-lg font-medium text-sm bg-green-500 text-white hover:bg-green-600 disabled:opacity-50 transition-colors'>
|
||||||
Pause beenden
|
{stamping ? '...' : '▶ Pause beenden'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status-Info wenn ausgestempelt */}
|
||||||
|
{!isOpen && (
|
||||||
|
<p className='text-sm text-gray-400 mt-3'>Heute noch nicht eingestempelt.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Balance */}
|
{/* Tabs: Meine Einträge + Freigabe-Queue (nur Manager+) */}
|
||||||
{balance && (
|
<div className='bg-white rounded-xl shadow-sm border border-gray-100'>
|
||||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
|
{isManager && (
|
||||||
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3'>
|
<div className='flex border-b border-gray-100'>
|
||||||
Monat ({new Date(balance.period_start).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })})
|
<button
|
||||||
</h2>
|
onClick={() => setActiveTab('mine')}
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-4 gap-4'>
|
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||||
{[
|
activeTab === 'mine'
|
||||||
{ label: 'Gearbeitet', value: fmtH(balance.total_hours_worked) },
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
{ label: 'Soll', value: fmtH(balance.expected_hours) },
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
{ label: 'Überstunden', value: fmtH(balance.overtime_hours), ot: balance.overtime_hours },
|
}`}
|
||||||
{ label: 'Genehmigte Einträge', value: String(balance.approved_entries) },
|
>
|
||||||
].map(({ label, value, ot }) => (
|
Meine Einträge
|
||||||
<div key={label}>
|
</button>
|
||||||
<p className='text-xs text-gray-500'>{label}</p>
|
<button
|
||||||
<p className={`text-xl font-bold mt-1 ${
|
onClick={() => setActiveTab('approval')}
|
||||||
ot !== undefined ? (ot > 0 ? 'text-green-600' : ot < 0 ? 'text-red-600' : 'text-gray-800') : 'text-gray-800'
|
className={`px-6 py-3 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||||
}`}>{value}</p>
|
activeTab === 'approval'
|
||||||
</div>
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
))}
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
Zur Freigabe
|
||||||
|
{pendingTotal > 0 && (
|
||||||
|
<span className='bg-blue-100 text-blue-700 text-xs font-bold px-1.5 py-0.5 rounded-full'>
|
||||||
|
{pendingTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent entries */}
|
{/* Meine Einträge */}
|
||||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
|
{activeTab === 'mine' && (
|
||||||
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between'>
|
<>
|
||||||
<h2 className='text-lg font-semibold text-gray-800'>Letzte Einträge</h2>
|
<div className='px-6 py-4 border-b border-gray-50 flex items-center justify-between'>
|
||||||
|
<h2 className='text-base font-semibold text-gray-800'>Letzte Einträge</h2>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<span className='text-sm text-gray-400'>{total} gesamt</span>
|
<span className='text-sm text-gray-400'>{total} gesamt</span>
|
||||||
{canManual && (
|
{canManual && (
|
||||||
@@ -495,8 +643,101 @@ export function TimeTrackingPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Freigabe-Queue */}
|
||||||
|
{activeTab === 'approval' && isManager && (
|
||||||
|
<>
|
||||||
|
<div className='px-6 py-4 border-b border-gray-50 flex items-center justify-between'>
|
||||||
|
<h2 className='text-base font-semibold text-gray-800'>Einträge zur Freigabe</h2>
|
||||||
|
<span className='text-sm text-gray-400'>{pendingTotal} ausstehend</span>
|
||||||
|
</div>
|
||||||
|
{approvalError && (
|
||||||
|
<div className='mx-6 mt-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{approvalError}</div>
|
||||||
|
)}
|
||||||
|
{pendingLoading ? (
|
||||||
|
<div className='flex justify-center py-10'><Spinner /></div>
|
||||||
|
) : pendingEntries.length === 0 ? (
|
||||||
|
<p className='text-center text-gray-400 py-10 text-sm'>Keine ausstehenden Einträge</p>
|
||||||
|
) : (
|
||||||
|
<div className='divide-y divide-gray-50'>
|
||||||
|
{pendingEntries.map(entry => (
|
||||||
|
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<p className='text-sm font-medium text-gray-800'>
|
||||||
|
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-gray-500'>
|
||||||
|
{fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
|
||||||
|
</p>
|
||||||
|
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
|
||||||
|
<p className='text-xs text-gray-400 mt-0.5'>Mitarbeiter-ID: {entry.user_id.slice(0, 8)}…</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='text-right mr-2'>
|
||||||
|
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
|
||||||
|
<span className='inline-block text-xs px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700'>
|
||||||
|
Zur Prüfung
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => approveEntry(entry.id)}
|
||||||
|
title='Genehmigen'
|
||||||
|
className='px-4 py-2 rounded-lg font-medium text-sm bg-green-600 text-white hover:bg-green-700 transition-colors'
|
||||||
|
>
|
||||||
|
✓ Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRejectId(entry.id); setRejectNote('') }}
|
||||||
|
title='Ablehnen'
|
||||||
|
className='px-4 py-2 rounded-lg font-medium text-sm border border-red-300 text-red-600 hover:bg-red-50 transition-colors'
|
||||||
|
>
|
||||||
|
✗ Ablehnen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reject-Bestätigungs-Modal */}
|
||||||
|
{rejectId && (
|
||||||
|
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||||
|
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
|
||||||
|
<div className='flex justify-between items-center mb-4'>
|
||||||
|
<h2 className='text-base font-semibold text-gray-800'>Eintrag ablehnen</h2>
|
||||||
|
<button onClick={() => setRejectId(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-xs font-medium text-gray-700'>Ablehnungsgrund (optional)</span>
|
||||||
|
<textarea
|
||||||
|
value={rejectNote}
|
||||||
|
onChange={e => setRejectNote(e.target.value)}
|
||||||
|
placeholder='z. B. Zeiten stimmen nicht mit Kiosk überein'
|
||||||
|
rows={3}
|
||||||
|
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-400 mt-1'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className='flex justify-end gap-2 pt-1'>
|
||||||
|
<button onClick={() => setRejectId(null)}
|
||||||
|
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={rejectEntry}
|
||||||
|
className='px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700'>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Duplicate Modal */}
|
{/* Duplicate Modal */}
|
||||||
{dupEntry && (
|
{dupEntry && (
|
||||||
|
|||||||
Reference in New Issue
Block a user