diff --git a/DEVLOG.md b/DEVLOG.md index 99e084f..93c9dfd 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -718,3 +718,131 @@ Keine Commits in dieser Session. - 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 + + +--- diff --git a/backend/app/routers/absence.py b/backend/app/routers/absence.py new file mode 100644 index 0000000..5d0bf77 --- /dev/null +++ b/backend/app/routers/absence.py @@ -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 diff --git a/backend/app/routers/absence_service.py b/backend/app/routers/absence_service.py new file mode 100644 index 0000000..a98fae1 --- /dev/null +++ b/backend/app/routers/absence_service.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index 5870ae8..698b038 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -23,3 +23,4 @@ pytest>=8.0.0 pytest-asyncio>=0.23.0 pytest-httpx>=0.30.0 aiosqlite>=0.20.0 +weasyprint>=61.0 diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py index 90ab6ce..2f1c8b6 100644 --- a/backend/tests/test_reports.py +++ b/backend/tests/test_reports.py @@ -308,3 +308,47 @@ def test_to_xlsx_with_data(): result = svc.to_xlsx(rows, sheet_name="Test") assert isinstance(result, bytes) 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 diff --git a/frontend/src/pages/TimeTrackingPage.tsx b/frontend/src/pages/TimeTrackingPage.tsx index 9b3c788..a12968f 100644 --- a/frontend/src/pages/TimeTrackingPage.tsx +++ b/frontend/src/pages/TimeTrackingPage.tsx @@ -25,9 +25,9 @@ interface TimeEntryOut { correction_note: string | null status: string source: string + break_start?: string | null // ISO-Timestamp wenn Pause läuft } - interface TimeEntryWithWarnings { entry: TimeEntryOut warnings: string[] @@ -53,6 +53,8 @@ interface TodayStatus { today_open: boolean today_start: string | null today_hours_so_far: number | null + break_start?: string | null + break_minutes?: number } const STATUS_LABELS: Record = { @@ -73,7 +75,6 @@ const STATUS_COLORS: Record = { function fmt(iso: string | null): string { 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) const d = new Date(iso) if (isNaN(d.getTime())) return iso.slice(0, 5) @@ -87,11 +88,33 @@ function fmtH(h: number | null): string { 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() { const [user, setUser] = useState(null) const [dashboard, setDashboard] = useState(null) - const [balance, setBalance] = useState(null) + const [weekBalance, setWeekBalance] = useState(null) const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) @@ -100,6 +123,15 @@ export function TimeTrackingPage() { const [warnings, setWarnings] = useState([]) const [error, setError] = useState('') + // Approval queue (Manager+) + const [activeTab, setActiveTab] = useState<'mine' | 'approval'>('mine') + const [pendingEntries, setPendingEntries] = useState([]) + const [pendingTotal, setPendingTotal] = useState(0) + const [pendingLoading, setPendingLoading] = useState(false) + const [rejectId, setRejectId] = useState(null) + const [rejectNote, setRejectNote] = useState('') + const [approvalError, setApprovalError] = useState('') + // Edit modal const [editEntry, setEditEntry] = useState(null) const [editStart, setEditStart] = useState('') @@ -130,21 +162,25 @@ export function TimeTrackingPage() { const [newSaving, setNewSaving] = useState(false) const [newError, setNewError] = useState('') + // Live tickers const [liveSeconds, setLiveSeconds] = useState(0) + const [breakSeconds, setBreakSeconds] = useState(0) const tickerRef = useRef | null>(null) + const breakTickerRef = useRef | null>(null) const load = useCallback(async () => { setLoading(true) try { + const monday = getMondayOfCurrentWeek() const [me, dash, bal, list] = await Promise.all([ api.get('/auth/me'), api.get('/dashboard/me'), - api.get('/time/balance/me'), + api.get(`/time/balance/me?period_start=${monday}`), api.get('/time/entries?limit=20'), ]) setUser(me) setDashboard(dash) - setBalance(bal) + setWeekBalance(bal) setEntries(list.items) setTotal(list.total) } catch (e: unknown) { @@ -154,25 +190,61 @@ export function TimeTrackingPage() { } }, []) + const loadPending = useCallback(async () => { + setPendingLoading(true) + setApprovalError('') + try { + const res = await api.get('/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]) - // 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(() => { if (tickerRef.current) clearInterval(tickerRef.current) - if (dashboard?.today_open && dashboard.today_start) { - // Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen + if (dashboard?.today_open && dashboard.today_start && !dashboard.break_start) { const startStr = dashboard.today_start const today = new Date() const [h, m, s] = startStr.split(':').map(Number) 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() tickerRef.current = setInterval(update, 1000) } else { setLiveSeconds(0) } 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 () => { setStamping(true); setError(''); setWarnings([]) @@ -212,6 +284,26 @@ export function TimeTrackingPage() { const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '') 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) => { if (!confirm('Eintrag wirklich löschen?')) return @@ -224,7 +316,7 @@ export function TimeTrackingPage() { const openDuplicate = (entry: TimeEntryOut) => { setDupEntry(entry) - setDupDate(new Date().toISOString().slice(0, 10)) // heute als Standard + setDupDate(new Date().toISOString().slice(0, 10)) setDupStart(fmt(entry.start_time)) setDupEnd(entry.end_time ? fmt(entry.end_time) : '') setDupBreak(entry.break_minutes) @@ -335,30 +427,67 @@ export function TimeTrackingPage() {
{error}
)} - {/* Stempeluhr */} -
-

Stempeluhr

-
-
-

Status

-

- {isOpen ? 'Eingestempelt' : 'Ausgestempelt'} -

-
-
-

Beginn

-

{fmt(dashboard?.today_start ?? null)}

-
-
-

Bisher heute

-

- {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)} -

+ {/* Wochen-Balance Widget */} + {weekBalance && ( +
+

+ 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' })}) +

+
+
+

Gearbeitet

+

{fmtH(weekBalance.total_hours_worked)}

+
+
+

Erwartet

+

{fmtH(weekBalance.expected_hours)}

+
+
+

Überstunden

+

0 + ? 'text-green-600' + : weekBalance.overtime_hours < 0 + ? 'text-red-600' + : 'text-gray-800' + }`}> + {weekBalance.overtime_hours > 0 ? '+' : ''}{fmtH(weekBalance.overtime_hours)} +

+
-
+ )} + + {/* Stempeluhr */} +
+

Stempeluhr

+ + {/* Live-Uhr – nur sichtbar wenn eingestempelt */} + {isOpen && ( +
+ {isOnBreak ? ( + <> +

Pause läuft

+

{fmtMS(breakSeconds)}

+

+ Gesamtpause bisher: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min +

+ + ) : ( + <> +

Arbeitszeit

+

{fmtHMS(liveSeconds)}

+

+ Start: {fmt(dashboard?.today_start ?? null)} + {(dashboard?.break_minutes ?? 0) > 0 && ` · Pause: ${dashboard!.break_minutes} min`} +

+ + )} +
+ )} + + {/* Notiz-Eingabe */} +
+ + {/* Stempel-Buttons */}
{!isOpen ? ( ) : ( <> - - + {!isOnBreak ? ( + + ) : ( + + )} )}
+ + {/* Status-Info wenn ausgestempelt */} + {!isOpen && ( +

Heute noch nicht eingestempelt.

+ )}
- {/* Balance */} - {balance && ( -
-

- Monat ({new Date(balance.period_start).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}) -

-
- {[ - { label: 'Gearbeitet', value: fmtH(balance.total_hours_worked) }, - { label: 'Soll', value: fmtH(balance.expected_hours) }, - { label: 'Überstunden', value: fmtH(balance.overtime_hours), ot: balance.overtime_hours }, - { label: 'Genehmigte Einträge', value: String(balance.approved_entries) }, - ].map(({ label, value, ot }) => ( -
-

{label}

-

0 ? 'text-green-600' : ot < 0 ? 'text-red-600' : 'text-gray-800') : 'text-gray-800' - }`}>{value}

-
- ))} + {/* Tabs: Meine Einträge + Freigabe-Queue (nur Manager+) */} +
+ {isManager && ( +
+ +
-
- )} + )} - {/* Recent entries */} -
-
-

Letzte Einträge

-
- {total} gesamt - {canManual && ( - - )} -
-
-
- {entries.length === 0 ? ( -

Noch keine Einträge

- ) : ( - entries.map(entry => ( -
-
-

- {new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })} -

-

- {fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min -

- {entry.note &&

{entry.note}

} - {entry.correction_note &&

✏️ {entry.correction_note}

} -
-
-
-

{fmtH(entry.worked_hours)}

- - {STATUS_LABELS[entry.status] ?? entry.status} - -
- {(entry.status !== 'approved' || isManager) && ( - - )} + {/* Meine Einträge */} + {activeTab === 'mine' && ( + <> +
+

Letzte Einträge

+
+ {total} gesamt + {canManual && ( - {(entry.status !== 'approved' || isManager) && ( - - )} -
+ )}
- )) - )} -
+
+
+ {entries.length === 0 ? ( +

Noch keine Einträge

+ ) : ( + entries.map(entry => ( +
+
+

+ {new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })} +

+

+ {fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min +

+ {entry.note &&

{entry.note}

} + {entry.correction_note &&

✏️ {entry.correction_note}

} +
+
+
+

{fmtH(entry.worked_hours)}

+ + {STATUS_LABELS[entry.status] ?? entry.status} + +
+ {(entry.status !== 'approved' || isManager) && ( + + )} + + {(entry.status !== 'approved' || isManager) && ( + + )} +
+
+ )) + )} +
+ + )} + + {/* Freigabe-Queue */} + {activeTab === 'approval' && isManager && ( + <> +
+

Einträge zur Freigabe

+ {pendingTotal} ausstehend +
+ {approvalError && ( +
{approvalError}
+ )} + {pendingLoading ? ( +
+ ) : pendingEntries.length === 0 ? ( +

Keine ausstehenden Einträge

+ ) : ( +
+ {pendingEntries.map(entry => ( +
+
+

+ {new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })} +

+

+ {fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min +

+ {entry.note &&

{entry.note}

} +

Mitarbeiter-ID: {entry.user_id.slice(0, 8)}…

+
+
+
+

{fmtH(entry.worked_hours)}

+ + Zur Prüfung + +
+ + +
+
+ ))} +
+ )} + + )}
+ {/* Reject-Bestätigungs-Modal */} + {rejectId && ( +
+
+
+

Eintrag ablehnen

+ +
+
+