feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):
- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
Session + RLS-Bypass.
Migration 0035. 178/178 Tests grün. Deployed auf 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -20,6 +20,9 @@ class AbsenceStatus(str, enum.Enum):
|
||||
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):
|
||||
|
||||
@@ -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"<AbsenceComment {self.absence_id} by {self.author_id}>"
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
# 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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
<h1>Du wurdest als Vertretung eingetragen</h1>
|
||||
<p>Hallo {substitute.first_name},</p>
|
||||
<p><strong>{requester.full_name}</strong> ist im Zeitraum <strong>{zeitraum}</strong>
|
||||
abwesend und hat dich als Vertretung benannt.</p>
|
||||
<a href="{settings.frontend_url}/absences" class="btn">Abwesenheiten ansehen</a>
|
||||
"""
|
||||
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"""
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AbsenceComment[]>([])
|
||||
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<AbsenceComment[]>(`/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 (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4' onClick={onClose}>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col' onClick={e => e.stopPropagation()}>
|
||||
<div className='flex items-center justify-between px-5 py-3 border-b'>
|
||||
<h3 className='font-semibold text-gray-800'>Kommentare & Verlauf</h3>
|
||||
<button onClick={onClose} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-3'>
|
||||
{loading && <p className='text-sm text-gray-400'>Lädt…</p>}
|
||||
{!loading && comments.length === 0 && <p className='text-sm text-gray-400'>Noch keine Kommentare.</p>}
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className={`text-sm rounded-lg px-3 py-2 ${c.is_system ? 'bg-gray-50 text-gray-500 italic' : 'bg-blue-50 text-gray-700'}`}>
|
||||
<div className='flex justify-between gap-2 mb-0.5'>
|
||||
<span className='font-medium'>{c.is_system ? '⚙ System' : (c.author_name ?? 'Unbekannt')}</span>
|
||||
<span className='text-xs text-gray-400'>{new Date(c.created_at).toLocaleString('de-DE')}</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className='px-5 text-xs text-red-500'>{error}</p>}
|
||||
<div className='px-5 py-3 border-t flex gap-2'>
|
||||
<input
|
||||
type='text' value={body} onChange={e => 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'
|
||||
/>
|
||||
<button onClick={send} disabled={sending || !body.trim()}
|
||||
className='px-4 py-1.5 bg-blue-600 text-white text-sm rounded disabled:opacity-50'>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,7 @@ interface CreateAbsenceModalProps {
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
substitute_id: string
|
||||
}
|
||||
setForm: React.Dispatch<React.SetStateAction<{
|
||||
type_id: string
|
||||
@@ -247,6 +248,7 @@ interface CreateAbsenceModalProps {
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
substitute_id: string
|
||||
}>>
|
||||
types: AbsenceTypeOut[]
|
||||
colleagues: UserListItem[]
|
||||
@@ -300,10 +302,23 @@ export function CreateAbsenceModal({
|
||||
className={inputClass}
|
||||
>
|
||||
<option value=''>— Für mich selbst —</option>
|
||||
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)}
|
||||
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name}{c.email ? ` (${c.email})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Vertretung <span className='text-gray-400 font-normal'>(optional)</span></label>
|
||||
<select
|
||||
value={form.substitute_id}
|
||||
onChange={e => setForm(f => ({ ...f, substitute_id: e.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value=''>— Keine —</option>
|
||||
{colleagues.filter(c => c.id !== form.for_user_id).map(c => (
|
||||
<option key={c.id} value={c.id}>{c.full_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Abwesenheitsart *</label>
|
||||
<select
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
VacationBalanceOut,
|
||||
OvertimeBalanceOut,
|
||||
} from '../types/absence'
|
||||
import { MANAGER_ROLES } from '../utils/calendar'
|
||||
|
||||
export function useAbsences(year: number, statusFilter: string) {
|
||||
const [user, setUser] = useState<UserOut | null>(null)
|
||||
@@ -41,10 +40,10 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
setTotal(absList.total)
|
||||
setBalance(bal)
|
||||
setOvertimeBalance(otBal)
|
||||
if (MANAGER_ROLES.includes(me.role) && colleagues.length === 0) {
|
||||
if (colleagues.length === 0) {
|
||||
try {
|
||||
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
|
||||
setColleagues(res.items)
|
||||
// Schlanke Kollegenliste (für alle Rollen zugänglich) – u.a. Vertreter-Auswahl
|
||||
setColleagues(await api.get<UserListItem[]>('/users/colleagues'))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -57,7 +56,7 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const createAbsence = async (
|
||||
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string },
|
||||
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string; substitute_id?: string },
|
||||
onSuccess: () => void,
|
||||
setSubmitting: (v: boolean) => void,
|
||||
fzaHours?: number,
|
||||
@@ -77,6 +76,7 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
half_day_end: form.half_day_end,
|
||||
note: form.note || null,
|
||||
for_user_id: form.for_user_id || null,
|
||||
substitute_id: form.substitute_id || null,
|
||||
...(fzaHours !== undefined ? { fza_hours: fzaHours } : {}),
|
||||
})
|
||||
onSuccess()
|
||||
@@ -148,11 +148,18 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
const requestCancellation = async (id: string, reason: string) => {
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/absences/${id}/request-cancellation`, { reason: reason.trim() || null })
|
||||
await load()
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
const loadColleaguesIfNeeded = async () => {
|
||||
if (colleagues.length === 0) {
|
||||
try {
|
||||
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
|
||||
setColleagues(res.items)
|
||||
setColleagues(await api.get<UserListItem[]>('/users/colleagues'))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -180,6 +187,7 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
reject,
|
||||
saveEdit,
|
||||
cancel,
|
||||
requestCancellation,
|
||||
loadColleaguesIfNeeded,
|
||||
typeName,
|
||||
typeColor,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CreateAbsenceModal,
|
||||
QuickSickModal,
|
||||
} from '../components/absences/AbsenceModals'
|
||||
import { AbsenceCommentsModal } from '../components/absences/AbsenceCommentsModal'
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
export function AbsencesPage() {
|
||||
@@ -27,6 +28,9 @@ export function AbsencesPage() {
|
||||
const [quickError, setQuickError] = useState('')
|
||||
const [showReject, setShowReject] = useState<string | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [showCancelReq, setShowCancelReq] = useState<string | null>(null)
|
||||
const [cancelReason, setCancelReason] = useState('')
|
||||
const [commentsFor, setCommentsFor] = useState<AbsenceOut | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [showBalanceEdit, setShowBalanceEdit] = useState(false)
|
||||
@@ -41,7 +45,7 @@ export function AbsencesPage() {
|
||||
const [form, setForm] = useState({
|
||||
type_id: '', start_date: '', end_date: '',
|
||||
half_day_start: false, half_day_end: false,
|
||||
note: '', for_user_id: '',
|
||||
note: '', for_user_id: '', substitute_id: '',
|
||||
})
|
||||
const [fzaMode, setFzaMode] = useState<'days' | 'hours'>('days')
|
||||
const [fzaHours, setFzaHours] = useState<number>(4)
|
||||
@@ -51,7 +55,7 @@ export function AbsencesPage() {
|
||||
const {
|
||||
user, types, absences, total, balance, overtimeBalance,
|
||||
loading, error, setError, colleagues, colleagueMap,
|
||||
createAbsence, approve, reject, saveEdit, cancel,
|
||||
createAbsence, approve, reject, saveEdit, cancel, requestCancellation,
|
||||
loadColleaguesIfNeeded, typeName, typeColor, updateBalance, load,
|
||||
} = useAbsences(year, statusFilter)
|
||||
|
||||
@@ -108,7 +112,7 @@ export function AbsencesPage() {
|
||||
const openCreate = async () => {
|
||||
setShowCreate(true)
|
||||
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)
|
||||
if (isManager) await loadColleaguesIfNeeded()
|
||||
@@ -134,7 +138,7 @@ export function AbsencesPage() {
|
||||
const useFzaHours = isFzaType(form.type_id) && fzaMode === 'hours'
|
||||
await createAbsence(form, () => {
|
||||
setShowCreate(false)
|
||||
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)
|
||||
}, setSubmitting, useFzaHours ? fzaHours : undefined)
|
||||
@@ -436,6 +440,7 @@ export function AbsencesPage() {
|
||||
{' · '}{a.working_days} Arbeitstag{a.working_days !== 1 ? 'e' : ''}
|
||||
</p>
|
||||
{a.note && <p className='text-xs text-gray-400 mt-0.5'>{a.note}</p>}
|
||||
{a.substitute_id && <p className='text-xs text-gray-500 mt-0.5'>🔁 Vertretung: {colleagueMap[a.substitute_id] ?? '—'}</p>}
|
||||
{a.correction_note && <p className='text-xs text-orange-500 mt-0.5'>✏️ {a.correction_note}</p>}
|
||||
{a.rejection_reason && <p className='text-xs text-red-500 mt-0.5'>Abgelehnt: {a.rejection_reason}</p>}
|
||||
</div>
|
||||
@@ -464,23 +469,39 @@ export function AbsencesPage() {
|
||||
{(a.status === 'pending' || a.status === 'approved') && (isManager || a.user_id === user.id) && (
|
||||
<button onClick={() => openEdit(a)} className={`text-xs px-2 py-1 border rounded ${a.status === 'approved' && !isManager ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : 'border-blue-300 text-blue-600 hover:bg-blue-50'}`}>✏️</button>
|
||||
)}
|
||||
<button onClick={() => setCommentsFor(a)} title='Kommentare' className='text-xs px-2 py-1 border border-gray-300 text-gray-600 rounded hover:bg-gray-50'>💬</button>
|
||||
{isManager && a.status === 'pending' && (<>
|
||||
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700'>Genehmigen</button>
|
||||
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
|
||||
</>)}
|
||||
{isManager && a.status === 'cancellation_requested' && (<>
|
||||
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700' title='Stornierung genehmigen'>Storno ✓</button>
|
||||
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700' title='Stornierung ablehnen'>Storno ✗</button>
|
||||
</>)}
|
||||
{!isManager && a.status === 'pending' && a.user_id === user.id && (
|
||||
<button onClick={() => cancel(a.id)} className='text-xs px-2 py-1 border border-gray-300 text-gray-600 rounded hover:bg-gray-50'>Stornieren</button>
|
||||
)}
|
||||
{!isManager && a.status === 'approved' && a.user_id === user.id && (
|
||||
<button onClick={() => { setShowCancelReq(a.id); setCancelReason('') }} className='text-xs px-2 py-1 border border-orange-300 text-orange-600 rounded hover:bg-orange-50'>Storno beantragen</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showReject === a.id && (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<input type='text' placeholder='Ablehnungsgrund (Pflicht)' value={rejectReason} onChange={e => setRejectReason(e.target.value)}
|
||||
<input type='text' placeholder='Begründung (Pflicht)' value={rejectReason} onChange={e => 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' />
|
||||
<button onClick={() => handleReject(a.id)} disabled={!rejectReason.trim()} className='px-3 py-1.5 bg-red-600 text-white text-sm rounded disabled:opacity-50'>Senden</button>
|
||||
<button onClick={() => setShowReject(null)} className='px-3 py-1.5 border border-gray-300 text-gray-600 text-sm rounded'>Abbrechen</button>
|
||||
</div>
|
||||
)}
|
||||
{showCancelReq === a.id && (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<input type='text' placeholder='Grund der Stornierung (optional)' value={cancelReason} onChange={e => 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' />
|
||||
<button onClick={() => { requestCancellation(a.id, cancelReason); setShowCancelReq(null) }} className='px-3 py-1.5 bg-orange-600 text-white text-sm rounded'>Storno beantragen</button>
|
||||
<button onClick={() => setShowCancelReq(null)} className='px-3 py-1.5 border border-gray-300 text-gray-600 text-sm rounded'>Abbrechen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 && (
|
||||
<AbsenceCommentsModal
|
||||
absenceId={commentsFor.id}
|
||||
onClose={() => setCommentsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const STATUS_LABELS: Record<string, string> = {
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
cancelled: 'Storniert',
|
||||
cancellation_requested: 'Storno beantragt',
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
@@ -24,6 +25,7 @@ export const STATUS_COLORS: Record<string, string> = {
|
||||
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']
|
||||
|
||||
Reference in New Issue
Block a user