diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8b567e4..6492b47 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.work_schedule import WorkSchedule from app.models.time_entry import TimeEntry, EntryStatus, EntrySource from app.models.absence_type import AbsenceType from app.models.absence import Absence, AbsenceStatus +from app.models.absence_comment import AbsenceComment from app.models.vacation_balance import VacationBalance from app.models.overtime_balance import OvertimeBalance from app.models.public_holiday import PublicHoliday @@ -32,6 +33,7 @@ __all__ = [ "AbsenceType", "Absence", "AbsenceStatus", + "AbsenceComment", "VacationBalance", "OvertimeBalance", "PublicHoliday", diff --git a/backend/app/models/absence.py b/backend/app/models/absence.py index 2527ba7..8474ecb 100644 --- a/backend/app/models/absence.py +++ b/backend/app/models/absence.py @@ -16,10 +16,13 @@ if TYPE_CHECKING: class AbsenceStatus(str, enum.Enum): - PENDING = "pending" - APPROVED = "approved" - REJECTED = "rejected" - CANCELLED = "cancelled" + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + CANCELLED = "cancelled" + # Mitarbeiter hat für einen bereits genehmigten Antrag eine Stornierung + # beantragt – Manager muss zustimmen (→ cancelled) oder ablehnen (→ approved). + CANCELLATION_REQUESTED = "cancellation_requested" class Absence(Base): diff --git a/backend/app/models/absence_comment.py b/backend/app/models/absence_comment.py new file mode 100644 index 0000000..e27bad9 --- /dev/null +++ b/backend/app/models/absence_comment.py @@ -0,0 +1,44 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, DateTime, ForeignKey, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class AbsenceComment(Base): + """Kommentar-Thread an einem Abwesenheitsantrag. + + `company_id` wird redundant gespeichert, damit die Tabelle über die normale + company_id-RLS-Policy gefenced werden kann (kein Join nötig). System-Kommentare + (`is_system=True`) werden bei Statuswechseln automatisch erzeugt. + """ + __tablename__ = "absence_comments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + absence_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("absences.id", ondelete="CASCADE"), nullable=False, index=True + ) + company_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True + ) + author_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") + ) + body: Mapped[str] = mapped_column(Text, nullable=False) + is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + author: Mapped["User | None"] = relationship( + "User", primaryjoin="AbsenceComment.author_id == User.id", + foreign_keys="[AbsenceComment.author_id]", lazy="noload", + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 059c403..88da6f3 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -12,12 +12,15 @@ from app.schemas.absence import ( AbsenceCreate, AbsenceListResponse, AbsenceOut, + AbsenceCommentCreate, + AbsenceCommentOut, AbsenceReject, AbsenceUpdate, AbsenceTypeCreate, AbsenceTypeOut, AbsenceTypeUpdate, CalendarEntry, + CancellationRequest, CertificateMarkIn, OvertimeBalanceOut, PublicHolidayCreate, @@ -256,11 +259,13 @@ async def list_absences( type_id: UUID | None = Query(None), status: AbsenceStatus | None = Query(None), year: int | None = Query(None), + as_substitute: bool = Query(False), db: AsyncSession = Depends(get_db), ): total, absences = await absence_service.list_absences( current_user.company_id, current_user, db, user_id=user_id, type_id=type_id, status=status, year=year, + as_substitute=as_substitute, ) return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences]) @@ -325,6 +330,44 @@ async def cancel_absence( return AbsenceOut.model_validate(absence) +@router.post("/absences/{absence_id}/request-cancellation", response_model=AbsenceOut) +async def request_cancellation( + absence_id: UUID, + data: CancellationRequest, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Mitarbeiter beantragt Stornierung eines genehmigten Antrags (Manager genehmigt/lehnt ab). + + HR/Admin storniert weiterhin direkt über DELETE.""" + absence = await absence_service.request_cancellation(absence_id, data.reason, current_user, db) + await db.commit() + return AbsenceOut.model_validate(absence) + + +# ── Kommentare ──────────────────────────────────────────────────────────────── + +@router.get("/absences/{absence_id}/comments", response_model=list[AbsenceCommentOut]) +async def list_absence_comments( + absence_id: UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + return await absence_service.list_comments(absence_id, current_user, db) + + +@router.post("/absences/{absence_id}/comments", response_model=AbsenceCommentOut, status_code=201) +async def add_absence_comment( + absence_id: UUID, + data: AbsenceCommentCreate, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + comment = await absence_service.add_comment(absence_id, data.body, current_user, db) + await db.commit() + return comment + + class AbsenceApproveOut(AbsenceOut): warnings: list[str] = [] diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 2c161d4..7e7bc2f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -53,6 +53,27 @@ async def invite_user( return UserOut.model_validate(user) +@router.get("/colleagues") +async def list_colleagues( + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Schlanke Kollegenliste (id + Name) für alle Mitarbeiter – z.B. zur + Vertreter-Auswahl. Nur aktive User der eigenen Firma (RLS-gefenced), + ohne sensible Felder.""" + from sqlalchemy import select + rows = await db.scalars( + select(User) + .where( + User.company_id == current_user.company_id, + User.is_active.is_(True), + User.id != current_user.id, + ) + .order_by(User.last_name, User.first_name) + ) + return [{"id": str(u.id), "full_name": u.full_name} for u in rows.all()] + + @router.get("/me", response_model=UserOut) async def get_me(current_user: CurrentUser): return UserOut.model_validate(current_user) diff --git a/backend/app/schemas/absence.py b/backend/app/schemas/absence.py index 6c63504..9a3903b 100644 --- a/backend/app/schemas/absence.py +++ b/backend/app/schemas/absence.py @@ -122,6 +122,28 @@ class AbsenceReject(BaseModel): rejection_reason: str = Field(min_length=1) +class CancellationRequest(BaseModel): + reason: str | None = Field(None, max_length=1000) + + +# ── Kommentare ──────────────────────────────────────────────────────────────── + +class AbsenceCommentOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + absence_id: uuid.UUID + author_id: uuid.UUID | None + author_name: str | None = None + body: str + is_system: bool + created_at: datetime + + +class AbsenceCommentCreate(BaseModel): + body: str = Field(min_length=1, max_length=2000) + + class AbsenceListResponse(BaseModel): total: int items: list[AbsenceOut] diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index 0eeb02f..a59121f 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -108,13 +108,17 @@ class AbsenceService: type_id: UUID | None = None, status: AbsenceStatus | None = None, year: int | None = None, + as_substitute: bool = False, ) -> 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: + if as_substitute: + # Anträge, in denen der aktuelle User als Vertretung eingetragen ist + q = q.where(Absence.substitute_id == current_user.id) + elif 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) @@ -185,6 +189,28 @@ class AbsenceService: if overlap: warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.") + # Vertreter prüfen: gleiche Firma + im Zeitraum selbst nicht abwesend (nur Warnung) + if data.substitute_id: + substitute = await db.get(User, data.substitute_id) + if substitute is None or substitute.company_id != current_user.company_id: + raise HTTPException(status_code=404, detail="Vertretung nicht gefunden.") + if substitute.id == current_user.id: + raise HTTPException(status_code=400, detail="Man kann sich nicht selbst vertreten.") + sub_overlap = await db.scalar( + select(Absence).where( + and_( + Absence.user_id == data.substitute_id, + Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]), + Absence.start_date <= data.end_date, + Absence.end_date >= data.start_date, + ) + ) + ) + if sub_overlap: + warnings.append( + f"Gewählte Vertretung ({substitute.full_name}) ist im Zeitraum selbst abwesend." + ) + status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED approved_by = None if absence_type.requires_approval else current_user.id @@ -215,9 +241,12 @@ class AbsenceService: 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) + # Bei automatischer Genehmigung Konto abziehen + Vertretung benachrichtigen + if not absence_type.requires_approval: + if absence_type.deducts_vacation: + await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db) + if absence.substitute_id: + await self._notify_substitute(absence, db) return absence, warnings @@ -347,7 +376,7 @@ class AbsenceService: )) from app.services.caldav_service import caldav_service - asyncio.create_task(caldav_service.sync_removed(absence, db)) + asyncio.create_task(caldav_service.sync_removed_bg(absence.id)) return absence @@ -368,6 +397,12 @@ class AbsenceService: status_code=409, detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden." ) + + # Storno-Anfrage genehmigen → Antrag tatsächlich stornieren + Rückbuchung + if absence.status == AbsenceStatus.CANCELLATION_REQUESTED: + await self._apply_cancellation(absence, current_user, db, from_request=True) + return absence, [] + if absence.status != AbsenceStatus.PENDING: raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.") @@ -408,7 +443,11 @@ class AbsenceService: # 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)) + asyncio.create_task(caldav_service.sync_approved_bg(absence.id)) + + # Vertretung benachrichtigen + if absence.substitute_id: + await self._notify_substitute(absence, db) return absence, fza_warnings @@ -424,6 +463,26 @@ class AbsenceService: 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.") + + # Storno-Anfrage ablehnen → Antrag bleibt genehmigt + if absence.status == AbsenceStatus.CANCELLATION_REQUESTED: + absence.status = AbsenceStatus.APPROVED + db.add(AuditLog( + company_id=current_user.company_id, + user_id=current_user.id, + action="absence_cancellation_rejected", + entity_type="absence", + entity_id=absence.id, + old_value={"status": "cancellation_requested"}, + new_value={"status": "approved", "rejection_reason": data.rejection_reason, + "absence_user_id": str(absence.user_id)}, + )) + await self._add_system_comment( + absence, current_user.company_id, current_user.id, + f"Stornierung abgelehnt von {current_user.full_name}: {data.rejection_reason}", db, + ) + return absence + if absence.status != AbsenceStatus.PENDING: raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.") @@ -452,7 +511,7 @@ class AbsenceService: )) from app.services.caldav_service import caldav_service - asyncio.create_task(caldav_service.sync_removed(absence, db)) + asyncio.create_task(caldav_service.sync_removed_bg(absence.id)) return absence @@ -845,5 +904,176 @@ class AbsenceService: return list(by_user.values()) + # ── Stornierung mit Re-Genehmigung ────────────────────────────────────────── + + async def request_cancellation( + self, absence_id: UUID, reason: str | None, current_user: User, db: AsyncSession + ) -> Absence: + """Mitarbeiter beantragt die Stornierung eines bereits GENEHMIGTEN Antrags. + + HR/Admin storniert weiterhin direkt (über cancel_absence). Diese Anfrage + setzt den Status auf CANCELLATION_REQUESTED → Manager muss zustimmen. + """ + 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.APPROVED: + raise HTTPException( + status_code=409, + detail="Nur genehmigte Anträge können zur Stornierung eingereicht werden.", + ) + + absence.status = AbsenceStatus.CANCELLATION_REQUESTED + + db.add(AuditLog( + company_id=current_user.company_id, + user_id=current_user.id, + action="absence_cancellation_requested", + entity_type="absence", + entity_id=absence.id, + old_value={"status": "approved"}, + new_value={"status": "cancellation_requested", "reason": reason, + "absence_user_id": str(absence.user_id)}, + )) + body = "Stornierung beantragt" + (f": {reason}" if reason else ".") + await self._add_system_comment(absence, current_user.company_id, current_user.id, body, db) + return absence + + async def _apply_cancellation( + self, absence: Absence, actor: User, db: AsyncSession, from_request: bool, + ) -> None: + """Genehmigten Antrag tatsächlich stornieren inkl. Rückbuchung (Urlaub + FZA).""" + absence_type = await db.get(AbsenceType, absence.type_id) + if absence_type and absence_type.deducts_vacation: + await self._refund_vacation( + absence.user_id, absence.start_date.year, int(absence.working_days), db + ) + if absence_type and absence_type.affects_overtime_balance: + await self._refund_overtime( + absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours + ) + + absence.status = AbsenceStatus.CANCELLED + + db.add(AuditLog( + company_id=actor.company_id, + user_id=actor.id, + action="absence_cancellation_approved" if from_request else "absence_cancelled", + entity_type="absence", + entity_id=absence.id, + old_value={"status": "cancellation_requested" if from_request else "approved"}, + new_value={ + "status": "cancelled", + "cancelled_by": str(actor.id), + "cancelled_by_name": actor.full_name, + "absence_user_id": str(absence.user_id), + "working_days": float(absence.working_days), + }, + )) + await self._add_system_comment( + absence, actor.company_id, actor.id, + f"Stornierung genehmigt von {actor.full_name}.", db, + ) + + from app.services.caldav_service import caldav_service + asyncio.create_task(caldav_service.sync_removed_bg(absence.id)) + + async def _refund_vacation( + self, user_id: UUID, year: int, days: int, db: AsyncSession + ) -> None: + balance = await db.scalar( + select(VacationBalance).where( + VacationBalance.user_id == user_id, VacationBalance.year == year + ) + ) + if balance is not None: + balance.used_days = max(0, balance.used_days - days) + + async def _notify_substitute(self, absence: Absence, db: AsyncSession) -> None: + """Eingetragene Vertretung über die genehmigte Abwesenheit informieren.""" + if not absence.substitute_id: + return + substitute = await db.get(User, absence.substitute_id) + requester = await db.get(User, absence.user_id) + if substitute is None or requester is None or not substitute.email: + return + from app.services.email_service import email_service + try: + await email_service.send_substitute_notification(substitute, requester, absence, db) + except Exception as exc: # Mailfehler dürfen die Genehmigung nicht blockieren + print(f"Vertreter-Benachrichtigung fehlgeschlagen: {exc}") + + # ── Kommentare ────────────────────────────────────────────────────────────── + + async def _add_system_comment( + self, absence: Absence, company_id: UUID, author_id: UUID | None, body: str, db: AsyncSession + ) -> None: + from app.models.absence_comment import AbsenceComment + db.add(AbsenceComment( + absence_id=absence.id, company_id=company_id, + author_id=author_id, body=body, is_system=True, + )) + + async def _assert_comment_access( + self, absence: Absence, current_user: User, db: AsyncSession + ) -> None: + """Sichtbar für: Antragsteller, eingetragene Vertretung, Manager-Rollen der Firma.""" + if current_user.role in _manager_roles: + 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.") + return + if current_user.id in (absence.user_id, absence.substitute_id): + return + raise HTTPException(status_code=403, detail="Keine Berechtigung.") + + async def list_comments( + self, absence_id: UUID, current_user: User, db: AsyncSession + ) -> list: + from app.models.absence_comment import AbsenceComment + absence = await db.get(Absence, absence_id) + if absence is None: + raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.") + await self._assert_comment_access(absence, current_user, db) + rows = (await db.execute( + select(AbsenceComment, User) + .outerjoin(User, AbsenceComment.author_id == User.id) + .where(AbsenceComment.absence_id == absence_id) + .order_by(AbsenceComment.created_at) + )).all() + result = [] + for comment, author in rows: + result.append({ + "id": comment.id, "absence_id": comment.absence_id, + "author_id": comment.author_id, + "author_name": author.full_name if author else None, + "body": comment.body, "is_system": comment.is_system, + "created_at": comment.created_at, + }) + return result + + async def add_comment( + self, absence_id: UUID, body: str, current_user: User, db: AsyncSession + ) -> dict: + from app.models.absence_comment import AbsenceComment + absence = await db.get(Absence, absence_id) + if absence is None: + raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.") + await self._assert_comment_access(absence, current_user, db) + comment = AbsenceComment( + absence_id=absence_id, company_id=current_user.company_id, + author_id=current_user.id, body=body.strip(), is_system=False, + ) + db.add(comment) + await db.flush() + return { + "id": comment.id, "absence_id": comment.absence_id, + "author_id": comment.author_id, "author_name": current_user.full_name, + "body": comment.body, "is_system": comment.is_system, + "created_at": comment.created_at, + } + absence_service = AbsenceService() diff --git a/backend/app/services/caldav_service.py b/backend/app/services/caldav_service.py index 87ec6cf..3c22cfd 100644 --- a/backend/app/services/caldav_service.py +++ b/backend/app/services/caldav_service.py @@ -346,6 +346,34 @@ class CalDavService: select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id) ) + # ── Hintergrund-Sync (eigene Session) ───────────────────────────────────── + # Fire-and-forget aus Request-Handlern darf NICHT die Request-Session + # weiterverwenden (wird nach der Response geschlossen; in Tests sogar + # sessionweit geteilt → "another operation in progress"). Diese Wrapper + # öffnen eine eigene Session, laden die Abwesenheit frisch und committen. + + async def sync_approved_bg(self, absence_id: uuid.UUID) -> None: + await self._run_bg(absence_id, self.sync_approved) + + async def sync_removed_bg(self, absence_id: uuid.UUID) -> None: + await self._run_bg(absence_id, self.sync_removed) + + async def _run_bg(self, absence_id: uuid.UUID, fn) -> None: + from sqlalchemy import text + from app.core.database import AsyncSessionLocal + + try: + async with AsyncSessionLocal() as db: + # Interner Job ohne Tenant-Kontext → RLS-Bypass nötig + await db.execute(text("SET LOCAL app.bypass_rls = 'on'")) + absence = await db.get(Absence, absence_id) + if absence is None: + return + await fn(absence, db) + await db.commit() + except Exception as exc: # darf den Request niemals beeinflussen + log.warning("CalDAV background sync failed for absence %s: %s", absence_id, exc) + # ── Sync-Operationen ────────────────────────────────────────────────────── async def sync_approved(self, absence: Absence, db: AsyncSession) -> None: diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index ce4bbd5..f0dd439 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -141,6 +141,28 @@ class EmailService: """ await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg) + async def send_substitute_notification( + self, substitute: "User", requester: "User", absence, db: AsyncSession + ) -> None: + """Informiert die eingetragene Vertretung über eine genehmigte Abwesenheit.""" + cfg = await self._load_smtp(substitute.company_id, db) + start = absence.start_date.strftime("%d.%m.%Y") + end = absence.end_date.strftime("%d.%m.%Y") + zeitraum = start if start == end else f"{start} – {end}" + body = f""" +

Du wurdest als Vertretung eingetragen

+

Hallo {substitute.first_name},

+

{requester.full_name} ist im Zeitraum {zeitraum} + abwesend und hat dich als Vertretung benannt.

+ Abwesenheiten ansehen + """ + await self._send( + substitute.email, + f"Vertretung für {requester.full_name} ({zeitraum})", + _html_wrapper("Vertretung", body), + cfg, + ) + async def send_test(self, cfg: SmtpConfig, to: str) -> None: """Test-E-Mail direkt mit übergebenem Konfigurationsobjekt.""" body = f""" diff --git a/backend/migrations/versions/0035_absence_comments.py b/backend/migrations/versions/0035_absence_comments.py new file mode 100644 index 0000000..22f4b22 --- /dev/null +++ b/backend/migrations/versions/0035_absence_comments.py @@ -0,0 +1,61 @@ +"""Absence comments + cancellation-request status (agent-11 PR1) + +Revision ID: 0035 +Revises: 0034 +Create Date: 2026-06-23 + +- Neuer Enum-Wert AbsenceStatus.CANCELLATION_REQUESTED (Mitarbeiter-Stornoantrag + für genehmigte Anträge → Manager genehmigt/lehnt ab) +- Neue Tabelle absence_comments (Kommentar-Thread + System-Kommentare bei Statuswechsel) + mit company_id-Spalte → reguläre company_id-RLS-Policy (analog 0024). +""" +from alembic import op +from sqlalchemy import text + +revision = "0035" +down_revision = "0034" +branch_labels = None +depends_on = None + +_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'" +_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid" +_USING = f"({_BYPASS} OR {_CID})" + + +def _exec(sql: str) -> None: + op.execute(text(sql)) + + +def upgrade() -> None: + # 1) Enum-Wert ergänzen (idempotent) + _exec("ALTER TYPE absencestatus ADD VALUE IF NOT EXISTS 'cancellation_requested'") + + # 2) Tabelle anlegen + _exec(""" + CREATE TABLE IF NOT EXISTS absence_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + absence_id UUID NOT NULL REFERENCES absences(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + author_id UUID REFERENCES users(id) ON DELETE SET NULL, + body TEXT NOT NULL, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + _exec("CREATE INDEX IF NOT EXISTS ix_absence_comments_absence_id ON absence_comments(absence_id)") + _exec("CREATE INDEX IF NOT EXISTS ix_absence_comments_company_id ON absence_comments(company_id)") + + # 3) RLS (company_id-gefenced, analog 0024) + _exec("ALTER TABLE absence_comments ENABLE ROW LEVEL SECURITY") + _exec("ALTER TABLE absence_comments FORCE ROW LEVEL SECURITY") + for cmd in ("select", "insert", "update", "delete"): + _exec(f"DROP POLICY IF EXISTS rls_absence_comments_{cmd} ON absence_comments") + _exec(f"CREATE POLICY rls_absence_comments_select ON absence_comments FOR SELECT USING {_USING}") + _exec(f"CREATE POLICY rls_absence_comments_insert ON absence_comments FOR INSERT WITH CHECK {_USING}") + _exec(f"CREATE POLICY rls_absence_comments_update ON absence_comments FOR UPDATE USING {_USING} WITH CHECK {_USING}") + _exec(f"CREATE POLICY rls_absence_comments_delete ON absence_comments FOR DELETE USING {_USING}") + + +def downgrade() -> None: + # Enum-Wert kann in PostgreSQL nicht entfernt werden – Tabelle wird gedroppt. + _exec("DROP TABLE IF EXISTS absence_comments") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 426b522..1aba07a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,7 +33,7 @@ def _rls_using_join(): return ( ) _COMPANY_COL_TABLES = [ - "absence_types", "audit_logs", "caldav_company_configs", "departments", + "absence_comments", "absence_types", "audit_logs", "caldav_company_configs", "departments", "kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs", "special_assignments", "users", "work_schedules", ] diff --git a/backend/tests/test_absences.py b/backend/tests/test_absences.py index c7eded4..f8609fe 100644 --- a/backend/tests/test_absences.py +++ b/backend/tests/test_absences.py @@ -481,3 +481,147 @@ async def test_sick_stats_bradford_factor(client: AsyncClient, abs_headers): # Bradford-Formel verifizieren expected = float(row["episodes"]) ** 2 * row["total_days"] assert abs(row["bradford_factor"] - expected) < 0.001 + + +# ── agent-11 PR1: Vertretung · Stornierung · Kommentare ──────────────────────── + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def substitute_user_id(client: AsyncClient, abs_headers): + """Mitarbeiter, der als Vertretung eingetragen werden kann.""" + resp = await client.post("/api/v1/users/invite", json={ + "first_name": "Sub", "last_name": "Stitute", + "email": "sub@absenceag.de", "role": "EMPLOYEE", + "initial_password": "Secret123", + }, headers=abs_headers) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def _future_monday(weeks: int) -> date: + return date.today() + timedelta(days=(7 - date.today().weekday()) + 7 * weeks) + + +@pytest.mark.asyncio +async def test_cancellation_request_flow( + client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id +): + """Genehmigten Antrag → Stornoantrag → Manager genehmigt → cancelled + Urlaub zurück.""" + start = _future_monday(7) + create = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=4)), + }, headers=abs_headers) + aid = create.json()["id"] + working_days = create.json()["working_days"] + + approve = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + assert approve.status_code == 200 + used_after_approve = (await client.get( + "/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers + )).json()["used_days"] + + # Stornoantrag durch Mitarbeiter (Owner) + req = await client.post( + f"/api/v1/absences/{aid}/request-cancellation", + json={"reason": "Plan geaendert"}, headers=abs_headers, + ) + assert req.status_code == 200, req.text + assert req.json()["status"] == "cancellation_requested" + + # Manager genehmigt die Stornierung + ok = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + assert ok.status_code == 200, ok.text + assert ok.json()["status"] == "cancelled" + + used_after_cancel = (await client.get( + "/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers + )).json()["used_days"] + assert used_after_cancel == used_after_approve - int(working_days) + + +@pytest.mark.asyncio +async def test_cancellation_request_rejected_keeps_approved( + client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id +): + start = _future_monday(9) + create = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=2)), + }, headers=abs_headers) + aid = create.json()["id"] + await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers) + + rej = await client.post( + f"/api/v1/absences/{aid}/reject", + json={"rejection_reason": "Vertretung fehlt"}, headers=abs_approver_headers, + ) + assert rej.status_code == 200, rej.text + assert rej.json()["status"] == "approved" + + +@pytest.mark.asyncio +async def test_request_cancellation_requires_approved( + client: AsyncClient, abs_headers, vacation_type_id +): + """PENDING-Antrag kann nicht zur Stornierung eingereicht werden (nur direkt löschen).""" + start = _future_monday(11) + create = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=1)), + }, headers=abs_headers) + aid = create.json()["id"] + req = await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers) + assert req.status_code == 409 + + +@pytest.mark.asyncio +async def test_substitute_filter_and_notification( + client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id, substitute_user_id +): + """Antrag mit Vertretung → Vertreter sieht ihn unter ?as_substitute=true.""" + start = _future_monday(13) + create = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=2)), + "substitute_id": substitute_user_id, + }, headers=abs_headers) + assert create.status_code == 201, create.text + aid = create.json()["id"] + assert create.json()["substitute_id"] == substitute_user_id + await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + + sub_login = await client.post("/api/v1/auth/login", json={ + "email": "sub@absenceag.de", "password": "Secret123", + }) + sub_headers = {"Authorization": f"Bearer {sub_login.json()['access_token']}"} + lst = await client.get("/api/v1/absences/?as_substitute=true", headers=sub_headers) + assert lst.status_code == 200, lst.text + assert any(a["id"] == aid for a in lst.json()["items"]) + + +@pytest.mark.asyncio +async def test_absence_comments( + client: AsyncClient, abs_headers, vacation_type_id +): + """Kommentar posten + System-Kommentar bei Stornoantrag erscheint im Thread.""" + start = _future_monday(15) + create = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=1)), + "note": "Brueckentag", + }, headers=abs_headers) + aid = create.json()["id"] + + add = await client.post( + f"/api/v1/absences/{aid}/comments", + json={"body": "Bitte zuegig pruefen"}, headers=abs_headers, + ) + assert add.status_code == 201, add.text + assert add.json()["is_system"] is False + assert add.json()["author_name"] + + lst = await client.get(f"/api/v1/absences/{aid}/comments", headers=abs_headers) + assert lst.status_code == 200 + bodies = [c["body"] for c in lst.json()] + assert "Bitte zuegig pruefen" in bodies diff --git a/frontend/src/components/absences/AbsenceCommentsModal.tsx b/frontend/src/components/absences/AbsenceCommentsModal.tsx new file mode 100644 index 0000000..e424605 --- /dev/null +++ b/frontend/src/components/absences/AbsenceCommentsModal.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react' +import { api } from '../../api/client' +import type { AbsenceComment } from '../../types/absence' + +interface Props { + absenceId: string + onClose: () => void +} + +export function AbsenceCommentsModal({ absenceId, onClose }: Props) { + const [comments, setComments] = useState([]) + const [body, setBody] = useState('') + const [loading, setLoading] = useState(true) + const [sending, setSending] = useState(false) + const [error, setError] = useState('') + + const load = async () => { + setLoading(true) + try { + setComments(await api.get(`/absences/${absenceId}/comments`)) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden') + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, [absenceId]) // eslint-disable-line react-hooks/exhaustive-deps + + const send = async () => { + if (!body.trim()) return + setSending(true) + setError('') + try { + await api.post(`/absences/${absenceId}/comments`, { body: body.trim() }) + setBody('') + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Senden') + } finally { + setSending(false) + } + } + + return ( +
+
e.stopPropagation()}> +
+

Kommentare & Verlauf

+ +
+ +
+ {loading &&

Lädt…

} + {!loading && comments.length === 0 &&

Noch keine Kommentare.

} + {comments.map(c => ( +
+
+ {c.is_system ? '⚙ System' : (c.author_name ?? 'Unbekannt')} + {new Date(c.created_at).toLocaleString('de-DE')} +
+

{c.body}

+
+ ))} +
+ + {error &&

{error}

} +
+ setBody(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') send() }} + placeholder='Kommentar schreiben…' + className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400' + /> + +
+
+
+ ) +} diff --git a/frontend/src/components/absences/AbsenceModals.tsx b/frontend/src/components/absences/AbsenceModals.tsx index 50bc5ad..597e595 100644 --- a/frontend/src/components/absences/AbsenceModals.tsx +++ b/frontend/src/components/absences/AbsenceModals.tsx @@ -238,6 +238,7 @@ interface CreateAbsenceModalProps { half_day_end: boolean note: string for_user_id: string + substitute_id: string } setForm: React.Dispatch> types: AbsenceTypeOut[] colleagues: UserListItem[] @@ -300,10 +302,23 @@ export function CreateAbsenceModal({ className={inputClass} > - {colleagues.map(c => )} + {colleagues.map(c => )} )} +
+ + +
setRejectReason(e.target.value)} + setRejectReason(e.target.value)} className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-400' />
)} + {showCancelReq === a.id && ( +
+ setCancelReason(e.target.value)} + className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400' /> + + +
+ )} ))} @@ -800,7 +821,7 @@ export function AbsencesPage() { onClose={() => { setShowCreate(false) setError('') - setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) + setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '', substitute_id: '' }) setFzaMode('days') setFzaHours(4) }} @@ -818,6 +839,13 @@ export function AbsencesPage() { /> )} + {commentsFor && ( + setCommentsFor(null)} + /> + )} + ) diff --git a/frontend/src/types/absence.ts b/frontend/src/types/absence.ts index 594caa8..d02e770 100644 --- a/frontend/src/types/absence.ts +++ b/frontend/src/types/absence.ts @@ -56,6 +56,16 @@ export interface AbsenceOut { created_at: string } +export interface AbsenceComment { + id: string + absence_id: string + author_id: string | null + author_name: string | null + body: string + is_system: boolean + created_at: string +} + export interface SickStatsRow { user_id: string user_name: string @@ -74,7 +84,7 @@ export interface AbsenceListResponse { export interface UserListItem { id: string full_name: string - email: string + email?: string } export interface VacationBalanceOut { diff --git a/frontend/src/utils/calendar.ts b/frontend/src/utils/calendar.ts index c0b882b..622829d 100644 --- a/frontend/src/utils/calendar.ts +++ b/frontend/src/utils/calendar.ts @@ -17,6 +17,7 @@ export const STATUS_LABELS: Record = { approved: 'Genehmigt', rejected: 'Abgelehnt', cancelled: 'Storniert', + cancellation_requested: 'Storno beantragt', } export const STATUS_COLORS: Record = { @@ -24,6 +25,7 @@ export const STATUS_COLORS: Record = { approved: 'bg-green-100 text-green-700', rejected: 'bg-red-100 text-red-700', cancelled: 'bg-gray-100 text-gray-500', + cancellation_requested: 'bg-orange-100 text-orange-700', } export const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']