From 1dcc04ae072eb73d41464be68c64270c101e6f85 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 6 Aug 2026 22:19:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(reports):=20DATEV-Monatsblatt-Export=20(Bl?= =?UTF-8?q?anko-Vorlage)=20f=C3=BCr=20Steuerberater?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage (Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen + Summe + Unterschriftsfelder). Führt time_entries, Absences und Feiertage pro Kalendertag zusammen. Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/ Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom hours_payouts-Modul als aktuellstem sauberen Muster. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj --- backend/app/routers/reports.py | 24 ++- backend/app/schemas/report.py | 26 +++ backend/app/services/report_service.py | 194 +++++++++++++++++++- backend/templates/README.md | 17 ++ backend/templates/migration_template.py | 69 +++++++ backend/templates/model_template.py | 67 +++++++ backend/templates/router_template.py | 168 +++++++++++++++++ backend/templates/schema_template.py | 42 +++++ backend/templates/test_template.py | 66 +++++++ frontend/src/templates/PageTemplate.tsx | 230 ++++++++++++++++++++++++ 10 files changed, 901 insertions(+), 2 deletions(-) create mode 100644 backend/templates/README.md create mode 100644 backend/templates/migration_template.py create mode 100644 backend/templates/model_template.py create mode 100644 backend/templates/router_template.py create mode 100644 backend/templates/schema_template.py create mode 100644 backend/templates/test_template.py create mode 100644 frontend/src/templates/PageTemplate.tsx diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py index 8801939..738453c 100644 --- a/backend/app/routers/reports.py +++ b/backend/app/routers/reports.py @@ -1,7 +1,7 @@ from datetime import date, timedelta from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession @@ -152,6 +152,28 @@ async def export_time_report( headers={"Content-Disposition": f"attachment; filename={filename}.csv"}) +@router.get("/reports/datev-monthly/export") +async def export_datev_monthly( + current_user: CurrentUser, + user_id: UUID, + year: int = Query(..., ge=2000, le=2100), + month: int = Query(..., ge=1, le=12), + db: AsyncSession = Depends(get_db), +): + """DATEV-Monatsblatt (Blanko-Layout) als PDF für Steuerberater-Übergabe. + EMPLOYEE darf nur den eigenen Monat exportieren.""" + if current_user.role not in _manager_roles and user_id != current_user.id: + raise HTTPException(403, "Nur eigenes Monatsblatt exportierbar") + + sheet = await report_service.datev_monthly_report( + current_user.company_id, user_id, year, month, db + ) + content = report_service.datev_monthly_report_to_pdf(sheet) + filename = f"datev_stundenaufzeichnung_{sheet.user_name.replace(' ', '_')}_{year}_{month:02d}.pdf" + return Response(content=content, media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"}) + + @router.get("/reports/absences/export") async def export_absence_report( current_user: CurrentUser, diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 60308d2..5e8f590 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -188,3 +188,29 @@ class OvertimeReportDetailed(BaseModel): total_employees: int total_overtime: float rows: list[OvertimeReportRowDetailed] + + +# ── DATEV-Monatsblatt ────────────────────────────────────────────────────────── +# Layout entspricht "Stundenaufzeichnungen Muster blanko DATEV ab 2015": +# ein Blatt pro Mitarbeiter/Monat, Zeile pro Kalendertag. + +class DatevDayRow(BaseModel): + day: int # Kalendertag 1-31 + weekday_label: str # "Mo".."So" für Anzeige + start_time: time | None = None + break_minutes: int | None = None + end_time: time | None = None + duration_hours: float | None = None + code: str | None = None # K/U/UU/F/SA/SU – siehe DATEV_CODES + recorded_on: date | None = None # "aufgezeichnet am" (created_at des Eintrags) + note: str | None = None # Bemerkungen (Absence-Typ-Name o.ä.) + + +class DatevMonthlySheet(BaseModel): + company_name: str + user_name: str + personnel_number: str | None + year: int + month: int + rows: list[DatevDayRow] + total_hours: float diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index b041e4c..0ca3222 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -1,3 +1,4 @@ +import calendar import csv import io from collections import defaultdict @@ -9,7 +10,7 @@ from sqlalchemy import distinct, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.absence import Absence, AbsenceStatus -from app.models.absence_type import AbsenceType +from app.models.absence_type import AbsenceCategory, AbsenceType from app.models.company import Company from app.models.department import Department from app.models.overtime_balance import OvertimeBalance @@ -21,6 +22,8 @@ from app.schemas.report import ( AbsenceReport, AbsenceReportRow, CompanyDashboard, + DatevDayRow, + DatevMonthlySheet, DayEntry, EmployeeDashboard, HoursBreakdown, @@ -675,6 +678,195 @@ class ReportService: total_rows=len(rows), total_hours=round(total_hours, 2), rows=rows, ) + # ── DATEV-Monatsblatt ──────────────────────────────────────────────────── + # Kürzel-Mapping laut Vorlage "Stundenaufzeichnungen Muster blanko DATEV ab 2015": + # K=Krank, U=Urlaub, UU=unbezahlter Urlaub, F=Feiertag, SA=Stundenweise abwesend, + # SU=Stundenweise Urlaub. + + _WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + @staticmethod + def _datev_code_for_absence(absence_type: AbsenceType, is_half_day: bool) -> str: + if absence_type.category == AbsenceCategory.SICK: + return "K" + if absence_type.category == AbsenceCategory.VACATION: + if is_half_day: + return "SU" + return "U" if absence_type.is_paid else "UU" + # Alle übrigen Kategorien (overtime_comp, training, business_trip, other) + # haben kein eigenes DATEV-Kürzel -> nur Bemerkungen-Spalte + return "SA" if is_half_day else "" + + async def datev_monthly_report( + self, + company_id: UUID, + user_id: UUID, + year: int, + month: int, + db: AsyncSession, + ) -> DatevMonthlySheet: + """Ein-Seite-pro-Mitarbeiter-Monatsblatt im DATEV-Blanko-Layout.""" + user = await db.get(User, user_id) + if user is None or user.company_id != company_id: + raise ValueError("Mitarbeiter nicht gefunden") + company = await db.get(Company, company_id) + + days_in_month = calendar.monthrange(year, month)[1] + date_from = date(year, month, 1) + date_to = date(year, month, days_in_month) + + # Zeiteinträge des Monats + entries_stmt = select(TimeEntry).where( + TimeEntry.user_id == user_id, + TimeEntry.date >= date_from, + TimeEntry.date <= date_to, + ) + entries_by_day: dict[int, TimeEntry] = { + e.date.day: e for e in (await db.scalars(entries_stmt)).all() + } + + # Genehmigte Abwesenheiten, die den Monat überlappen + absences_stmt = ( + select(Absence, AbsenceType) + .join(AbsenceType, Absence.absence_type_id == AbsenceType.id) + .where( + Absence.user_id == user_id, + Absence.status.in_([AbsenceStatus.APPROVED, AbsenceStatus.FIRST_APPROVED]), + Absence.start_date <= date_to, + Absence.end_date >= date_from, + ) + ) + absences = list((await db.execute(absences_stmt)).all()) + + # Feiertage (nur wenn Bundesland konfiguriert) + holidays: dict[date, tuple[str, bool]] = {} + if company and company.state: + holidays = await get_holidays_set(date_from, date_to, company.state, db) + + rows: list[DatevDayRow] = [] + total_hours = 0.0 + + for day in range(1, days_in_month + 1): + d = date(year, month, day) + weekday_label = self._WEEKDAY_LABELS[d.weekday()] + + row = DatevDayRow(day=day, weekday_label=weekday_label) + + entry = entries_by_day.get(day) + if entry is not None: + row.start_time = entry.start_time + row.end_time = entry.end_time + row.break_minutes = entry.break_minutes + row.duration_hours = entry.worked_hours + row.recorded_on = entry.created_at.date() if entry.created_at else None + if entry.worked_hours: + total_hours += entry.worked_hours + + # Feiertag hat Vorrang vor Abwesenheit (analog _categorize_hours-Logik) + if d in holidays: + row.code = "F" + row.note = holidays[d][0] + else: + for absence, absence_type in absences: + if absence.start_date <= d <= absence.end_date: + is_half_day = ( + (d == absence.start_date and absence.half_day_start) + or (d == absence.end_date and absence.half_day_end) + ) + row.code = self._datev_code_for_absence(absence_type, is_half_day) + row.note = absence_type.name + break + + rows.append(row) + + return DatevMonthlySheet( + company_name=company.name if company else "", + user_name=f"{user.first_name} {user.last_name}", + personnel_number=user.personnel_number, + year=year, month=month, + rows=rows, total_hours=round(total_hours, 2), + ) + + def datev_monthly_report_to_pdf(self, sheet: DatevMonthlySheet) -> bytes: + def fmt_t(t: time | None) -> str: + return t.strftime("%H:%M") if t else "" + + def fmt_h(h: float | None) -> str: + return f"{h:.2f}".replace(".", ",") if h is not None else "" + + rows_html = "" + for r in sheet.rows: + break_str = f"{r.break_minutes} min" if r.break_minutes else "" + recorded = r.recorded_on.strftime("%d.%m.%Y") if r.recorded_on else "" + rows_html += f""" + {r.day}. ({r.weekday_label}) + {fmt_t(r.start_time)} + {break_str} + {fmt_t(r.end_time)} + {fmt_h(r.duration_hours)} + {r.code or ""} + {recorded} + {r.note or ""} +""" + + month_names = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", + ] + period = f"{month_names[sheet.month - 1]} {sheet.year}" + + html = f""" + + + + + + +

Stundenaufzeichnung – {period}

+
+
Firma{sheet.company_name}
+
Name des Mitarbeiters{sheet.user_name}
+
Pers.-Nr.{sheet.personnel_number or "—"}
+
+ + + + + + +{rows_html} + + + + + + +
KalendertagBeginnPauseEndeDauer*aufgezeichnet amBemerkungen
Summe{fmt_h(sheet.total_hours)}
+
* K=Krank · U=Urlaub · UU=unbezahlter Urlaub · F=Feiertag · SA=Stundenweise abwesend · SU=Stundenweise Urlaub
+
+
Datum, Unterschrift Arbeitnehmer
+
Datum, Unterschrift Arbeitgeber
+
+ +""" + from weasyprint import HTML + return HTML(string=html).write_pdf() + # ── Absence Report ─────────────────────────────────────────────────────── async def absence_report( diff --git a/backend/templates/README.md b/backend/templates/README.md new file mode 100644 index 0000000..1db72c0 --- /dev/null +++ b/backend/templates/README.md @@ -0,0 +1,17 @@ +# Mustervorlagen (Backend) + +Kopiervorlagen für ein neues Workflow-Modul (Model + Schema + Router + Migration + Test), +abgeleitet vom aktuellsten sauberen Muster im Projekt: `hours_payouts` (Migration 0042). + +Reihenfolge beim Bau eines neuen Moduls: + +1. `model_template.py` → `app/models/.py` +2. `schema_template.py` → `app/schemas/.py` +3. `router_template.py` → `app/routers/.py`, in `app/main.py` registrieren +4. `migration_template.py` → `migrations/versions/00XX_.py`, `conftest.py` RLS nachziehen +5. `test_template.py` → `tests/test_.py` + +Jede Datei hat einen Anleitung-Docstring am Kopf. Kern-Fallstricke sind dort verlinkt +(RLS-Bypass + mid-request commit, Company-Isolation, conftest.py-Replikation). + +Diese Ordner werden NICHT von der App importiert (kein `__init__.py`) – reine Vorlagen. diff --git a/backend/templates/migration_template.py b/backend/templates/migration_template.py new file mode 100644 index 0000000..5a04a02 --- /dev/null +++ b/backend/templates/migration_template.py @@ -0,0 +1,69 @@ +"""TEMPLATE – Mustervorlage für Migration mit NEUER Tabelle + RLS-Policy. + +Abgeleitet vom Muster in migrations/versions/0035_absence_comments.py. + +WICHTIG (siehe project_dsgvo_multitenant_priority): +- JEDE neue company-bezogene Tabelle braucht company_id-Spalte + RLS-Policy +- NACH dieser Migration: tests/conftest.py muss die RLS-Policy manuell + replizieren (Test-DB nutzt keine echten Migrationen) – sonst schlagen + Company-Isolation-Tests unbemerkt fehl (false green) +- Nur neue SPALTEN auf bereits RLS-gefencten Tabellen brauchen KEINE + RLS-Änderung (siehe 0042_payout_requests.py als Gegenbeispiel) + +Anleitung: +1. Nach backend/migrations/versions/00XX_xxx_things.py kopieren +2. revision/down_revision auf nächste freie Nummer setzen (siehe + "Datenbank-Migrationen – Chronik" in docmost für die aktuell höchste Nummer) +3. Tabellenname/Spalten anpassen +4. conftest.py RLS-Replikation ergänzen +5. Lokal (auf 137!) testen: alembic upgrade head, dann pytest +""" +from alembic import op +from sqlalchemy import text + +revision = "00XX" # TODO: nächste freie Nummer +down_revision = "00XX-1" # TODO: vorherige Migration +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) Tabelle anlegen + _exec(""" + CREATE TABLE IF NOT EXISTS xxx_things ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + note TEXT, + status VARCHAR(12) NOT NULL DEFAULT 'requested', + rejection_reason TEXT, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL, + decided_by UUID REFERENCES users(id) ON DELETE SET NULL, + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + _exec("CREATE INDEX IF NOT EXISTS ix_xxx_things_company_id ON xxx_things(company_id)") + _exec("CREATE INDEX IF NOT EXISTS ix_xxx_things_user_id ON xxx_things(user_id)") + + # 2) RLS (company_id-gefenced) + _exec("ALTER TABLE xxx_things ENABLE ROW LEVEL SECURITY") + _exec("ALTER TABLE xxx_things FORCE ROW LEVEL SECURITY") + for cmd in ("select", "insert", "update", "delete"): + _exec(f"DROP POLICY IF EXISTS rls_xxx_things_{cmd} ON xxx_things") + _exec(f"CREATE POLICY rls_xxx_things_select ON xxx_things FOR SELECT USING {_USING}") + _exec(f"CREATE POLICY rls_xxx_things_insert ON xxx_things FOR INSERT WITH CHECK {_USING}") + _exec(f"CREATE POLICY rls_xxx_things_update ON xxx_things FOR UPDATE USING {_USING} WITH CHECK {_USING}") + _exec(f"CREATE POLICY rls_xxx_things_delete ON xxx_things FOR DELETE USING {_USING}") + + +def downgrade() -> None: + _exec("DROP TABLE IF EXISTS xxx_things") diff --git a/backend/templates/model_template.py b/backend/templates/model_template.py new file mode 100644 index 0000000..494c99a --- /dev/null +++ b/backend/templates/model_template.py @@ -0,0 +1,67 @@ +"""TEMPLATE – Mustervorlage für ein neues Model. + +Anleitung: +1. Datei nach app/models/.py kopieren +2. XxxThing, xxx_things, xxx_thing_id konsequent ersetzen +3. Enum/Felder an das reale Fachmodell anpassen +4. In app/models/__init__.py importieren (Alembic autodiscovery) +5. RLS-Policy in der zugehörigen Migration NICHT vergessen (company_id-Fenced) – + siehe conftest.py, dort muss die Policy für Tests manuell repliziert werden. +""" +import enum +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, 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 + from app.models.company import Company + + +class XxxThingStatus(str, enum.Enum): + REQUESTED = "requested" + APPROVED = "approved" + REJECTED = "rejected" + CANCELLED = "cancelled" + + +class XxxThing(Base): + """Kurzbeschreibung was dieses Model fachlich abbildet.""" + __tablename__ = "xxx_things" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # Mandanten-Fenced – Pflicht für RLS-Isolation (DSGVO) + company_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), + nullable=False, index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, index=True + ) + + note: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(12), nullable=False, default=XxxThingStatus.REQUESTED.value) + + created_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False + ) + decided_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") + ) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload") + creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload") + decider: Mapped["User"] = relationship("User", foreign_keys=[decided_by], lazy="noload") + company: Mapped["Company"] = relationship("Company", lazy="noload") diff --git a/backend/templates/router_template.py b/backend/templates/router_template.py new file mode 100644 index 0000000..13d2802 --- /dev/null +++ b/backend/templates/router_template.py @@ -0,0 +1,168 @@ +"""TEMPLATE – Mustervorlage für einen neuen Router (Modul mit Workflow-Status). + +Abgeleitet vom aktuellsten sauberen Muster im Projekt: app/routers/hours_payouts.py. + +Kern-Regeln (aus CLAUDE.md + gelernte Fallstricke): +- Company-Isolation: JEDE Query filtert auf company_id == current_user.company_id + (zusätzlich zur DB-seitigen RLS – Python-Check ist die zweite Verteidigungslinie) +- **RLS-Bypass + mid-request commit Falle**: Response-Objekt (_build_out) IMMER + VOR db.commit() bauen. db.refresh() nur nach db.flush() (noch in Transaktion). + Nach commit() ist app.bypass_rls/app.company_id verfallen -> Post-Commit-Reads + sehen ggf. keine Zeilen mehr. +- AuditLog bei JEDER schreibenden Aktion (create/approve/reject/cancel/delete) +- HTTPException mit sprechendem detail, kein Hard-Delete +- require_role() aus core.dependencies für Rollenprüfung + +Anleitung: +1. Nach app/routers/.py kopieren, XxxThing/xxx_things ersetzen +2. In app/main.py registrieren: app.include_router(xxx_things.router, prefix="/api/v1") +3. Model + Schema aus model_template.py / schema_template.py zuerst anlegen +4. Migration schreiben (Alembic) inkl. RLS-Policy + conftest.py nachziehen +5. Tests in backend/tests/test_xxx_things.py (siehe test_hours_payouts.py als Muster) +""" +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_client_ip, require_role +from app.models.audit_log import AuditLog +from app.models.user import User, UserRole +from app.models.xxx_thing import XxxThing, XxxThingStatus # TODO: Model anlegen +from app.schemas.xxx_thing import ( # TODO: Schemas anlegen + XxxThingCreate, + XxxThingListResponse, + XxxThingOut, + XxxThingReject, +) + +router = APIRouter(tags=["Xxx-Thing"]) + +_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) +_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) + + +async def _build_out(item: XxxThing, db: AsyncSession) -> XxxThingOut: + """Response-Objekt VOR commit() bauen – siehe RLS-Bypass-Falle oben.""" + out = XxxThingOut.model_validate(item) + user = await db.get(User, item.user_id) + out.user_name = f"{user.first_name} {user.last_name}" if user else str(item.user_id) + return out + + +# ── GET /xxx-things ──────────────────────────────────────────────────────── + +@router.get("/xxx-things", response_model=XxxThingListResponse) +async def list_items( + status: str | None = Query(None), + current_user: User = require_role(*_all_roles), + db: AsyncSession = Depends(get_db), +): + """Liste der eigenen Firma. EMPLOYEE/MANAGER sehen nur eigene Einträge.""" + filters = [XxxThing.company_id == current_user.company_id] + if current_user.role not in _hr_roles: + filters.append(XxxThing.user_id == current_user.id) + if status is not None: + filters.append(XxxThing.status == status) + + total_count = await db.scalar(select(func.count()).select_from(XxxThing).where(*filters)) + rows = list(await db.scalars( + select(XxxThing).where(*filters).order_by(XxxThing.created_at.desc()) + )) + result = [await _build_out(row, db) for row in rows] + return XxxThingListResponse(items=result, total_count=total_count or 0) + + +# ── POST /xxx-things ─────────────────────────────────────────────────────── + +@router.post("/xxx-things", response_model=XxxThingOut, status_code=201) +async def create_item( + request: Request, + data: XxxThingCreate, + current_user: User = require_role(*_all_roles), + db: AsyncSession = Depends(get_db), +): + item = XxxThing( + company_id=current_user.company_id, + user_id=data.user_id, + note=data.note, + status=XxxThingStatus.REQUESTED.value, + created_by=current_user.id, + ) + db.add(item) + await db.flush() + await db.refresh(item) # server_default created_at – noch in-Transaktion + + db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, + action="xxx_thing_created", entity_type="xxx_thing", entity_id=item.id, + new_value={"note": data.note}, + ip=get_client_ip(request), + )) + out = await _build_out(item, db) + await db.commit() + return out + + +# ── POST /xxx-things/{id}/approve ────────────────────────────────────────── + +@router.post("/xxx-things/{item_id}/approve", response_model=XxxThingOut) +async def approve_item( + item_id: UUID, + request: Request, + current_user: User = require_role(*_hr_roles), + db: AsyncSession = Depends(get_db), +): + item = await db.get(XxxThing, item_id) + if item is None or item.company_id != current_user.company_id: + raise HTTPException(404, "Eintrag nicht gefunden") + if item.status != XxxThingStatus.REQUESTED.value: + raise HTTPException(409, "Nur offene Anträge können genehmigt werden.") + + item.status = XxxThingStatus.APPROVED.value + item.decided_by = current_user.id + item.decided_at = datetime.utcnow() + + db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, + action="xxx_thing_approved", entity_type="xxx_thing", entity_id=item.id, + ip=get_client_ip(request), + )) + out = await _build_out(item, db) + await db.commit() + return out + + +# ── POST /xxx-things/{id}/reject ─────────────────────────────────────────── + +@router.post("/xxx-things/{item_id}/reject", response_model=XxxThingOut) +async def reject_item( + item_id: UUID, + request: Request, + data: XxxThingReject, + current_user: User = require_role(*_hr_roles), + db: AsyncSession = Depends(get_db), +): + item = await db.get(XxxThing, item_id) + if item is None or item.company_id != current_user.company_id: + raise HTTPException(404, "Eintrag nicht gefunden") + if item.status != XxxThingStatus.REQUESTED.value: + raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.") + + item.status = XxxThingStatus.REJECTED.value + item.rejection_reason = data.rejection_reason + item.decided_by = current_user.id + item.decided_at = datetime.utcnow() + + db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, + action="xxx_thing_rejected", entity_type="xxx_thing", entity_id=item.id, + new_value={"rejection_reason": data.rejection_reason}, + ip=get_client_ip(request), + )) + out = await _build_out(item, db) + await db.commit() + return out diff --git a/backend/templates/schema_template.py b/backend/templates/schema_template.py new file mode 100644 index 0000000..a1e26cb --- /dev/null +++ b/backend/templates/schema_template.py @@ -0,0 +1,42 @@ +"""TEMPLATE – Mustervorlage für Pydantic-v2-Schemas. + +Anleitung: +1. Nach app/schemas/.py kopieren, XxxThing ersetzen +2. Computed-Felder (z.B. *_name) im Router nachträglich befüllen, nie im Schema + selbst berechnen (Router = einzige Stelle mit DB-Zugriff für den Response-Bau) +""" +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + + +class XxxThingCreate(BaseModel): + user_id: uuid.UUID + note: str | None = Field(None, max_length=500) + + +class XxxThingReject(BaseModel): + rejection_reason: str | None = Field(None, max_length=500) + + +class XxxThingOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + company_id: uuid.UUID + user_id: uuid.UUID + user_name: str = "" # Computed im Router (first_name + last_name) + note: str | None + status: str = "requested" + rejection_reason: str | None = None + created_by: uuid.UUID + created_by_name: str = "" # Computed im Router + decided_by: uuid.UUID | None = None + decided_by_name: str = "" # Computed im Router + decided_at: datetime | None = None + created_at: datetime + + +class XxxThingListResponse(BaseModel): + items: list[XxxThingOut] + total_count: int diff --git a/backend/templates/test_template.py b/backend/templates/test_template.py new file mode 100644 index 0000000..c850bf0 --- /dev/null +++ b/backend/templates/test_template.py @@ -0,0 +1,66 @@ +"""TEMPLATE – Mustervorlage für Router-Tests. Abgeleitet von test_hours_payouts.py. + +Anleitung: +1. Nach backend/tests/test_xxx_things.py kopieren, Endpunkte/Felder anpassen +2. pytest-Konvention: alle Fixtures scope="session" + loop_scope="session" + (asyncpg + pytest-asyncio 1.x Anforderung – siehe project_pytest_asyncio) +3. Ausführen NUR auf dem Server (root@192.168.1.137), nie lokal: + ssh root@192.168.1.137 'cd /opt/timemaster/backend && source venv/bin/activate && python -m pytest tests/test_xxx_things.py -v' +""" +import pytest +import pytest_asyncio +from httpx import AsyncClient + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def xxx_thing_headers(client: AsyncClient): + resp = await client.post("/api/v1/auth/register", json={ + "company_name": "XxxThing GmbH", + "first_name": "Test", + "last_name": "User", + "email": "admin@xxxthinggmbh.de", + "password": "Secret123", + }) + assert resp.status_code == 201, resp.text + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_create_item(client: AsyncClient, xxx_thing_headers): + r = await client.post("/api/v1/xxx-things", + json={"user_id": "...", "note": "Test"}, headers=xxx_thing_headers) + assert r.status_code == 201, r.text + assert r.json()["status"] == "requested" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_approve_item(client: AsyncClient, xxx_thing_headers): + create = await client.post("/api/v1/xxx-things", + json={"user_id": "...", "note": "Test"}, headers=xxx_thing_headers) + item_id = create.json()["id"] + + ap = await client.post(f"/api/v1/xxx-things/{item_id}/approve", json={}, headers=xxx_thing_headers) + assert ap.status_code == 200, ap.text + assert ap.json()["status"] == "approved" + + # Doppel-Approve muss scheitern (409) + ap2 = await client.post(f"/api/v1/xxx-things/{item_id}/approve", json={}, headers=xxx_thing_headers) + assert ap2.status_code == 409 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_company_isolation(client: AsyncClient, xxx_thing_headers): + """Cross-Tenant-Zugriff muss 404 liefern, nicht 200/403 (verrät keine Existenz).""" + other = await client.post("/api/v1/auth/register", json={ + "company_name": "Andere Firma GmbH", + "first_name": "Other", "last_name": "Admin", + "email": "admin@andere-firma.de", "password": "Secret123", + }) + other_headers = {"Authorization": f"Bearer {other.json()['access_token']}"} + + create = await client.post("/api/v1/xxx-things", + json={"user_id": "...", "note": "Geheim"}, headers=xxx_thing_headers) + item_id = create.json()["id"] + + r = await client.get("/api/v1/xxx-things", headers=other_headers) + assert all(item["id"] != item_id for item in r.json()["items"]) diff --git a/frontend/src/templates/PageTemplate.tsx b/frontend/src/templates/PageTemplate.tsx new file mode 100644 index 0000000..7cd2010 --- /dev/null +++ b/frontend/src/templates/PageTemplate.tsx @@ -0,0 +1,230 @@ +// TEMPLATE – Mustervorlage für eine neue Seite mit Liste + Modal + Approve/Reject. +// Abgeleitet von pages/HoursPayoutPage.tsx. +// +// Anleitung: +// 1. Nach pages/XxxThingsPage.tsx kopieren, XxxThing/xxx-things ersetzen +// 2. Route in App.tsx registrieren, Nav-Eintrag in components/navConfig.ts +// 3. Konventionen: kein useEffect wenn Event-Handler reicht, Fehler als +// string | null, Lade-Zustand mit , Modal mit -Komponente +// 4. Bauen + deployen: npm run build && rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/ + +import { useEffect, useState } from 'react' +import { api } from '../api/client' +import { Spinner } from '../components/Spinner' +import { Layout } from '../components/Layout' +import { Modal } from '../components/Modal' + +interface XxxThingOut { + id: string + user_name: string + note: string | null + status: string + rejection_reason: string | null + created_at: string +} + +interface XxxThingListResponse { + items: XxxThingOut[] + total_count: number +} + +interface Me { + first_name: string + last_name: string + role: string +} + +const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-transparent' + +const STATUS_BADGE: Record = { + requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' }, + approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' }, + rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' }, +} + +function StatusBadge({ status }: { status: string }) { + const b = STATUS_BADGE[status] ?? { label: status, cls: 'bg-gray-100 text-gray-500' } + return {b.label} +} + +export function XxxThingsPage() { + const [me, setMe] = useState(null) + const [pageLoading, setPageLoading] = useState(true) + const [pageError, setPageError] = useState(null) + + const [items, setItems] = useState([]) + const [tableLoading, setTableLoading] = useState(false) + + const [showModal, setShowModal] = useState(false) + const [formNote, setFormNote] = useState('') + const [modalSaving, setModalSaving] = useState(false) + const [modalError, setModalError] = useState(null) + + useEffect(() => { + async function init() { + try { + const meData = await api.get('/auth/me') + setMe(meData) + await loadItems() + } catch (e: unknown) { + setPageError(e instanceof Error ? e.message : 'Fehler beim Laden') + } finally { + setPageLoading(false) + } + } + init() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function loadItems() { + setTableLoading(true) + try { + const data = await api.get('/xxx-things') + setItems(data.items) + } catch (e: unknown) { + setPageError(e instanceof Error ? e.message : 'Fehler beim Laden') + } finally { + setTableLoading(false) + } + } + + function openNewModal() { + setFormNote('') + setModalError(null) + setShowModal(true) + } + + async function handleSave() { + setModalSaving(true) + setModalError(null) + try { + await api.post('/xxx-things', { note: formNote.trim() || null }) + setShowModal(false) + loadItems() + } catch (e: unknown) { + setModalError(e instanceof Error ? e.message : 'Fehler beim Anlegen') + } finally { + setModalSaving(false) + } + } + + async function handleApprove(item: XxxThingOut) { + if (!confirm(`Eintrag für ${item.user_name} genehmigen?`)) return + try { + const updated = await api.post(`/xxx-things/${item.id}/approve`, {}) + setItems(prev => prev.map(i => (i.id === item.id ? updated : i))) + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Fehler beim Genehmigen') + } + } + + async function handleReject(item: XxxThingOut) { + const reason = prompt('Grund der Ablehnung (optional):') + if (reason === null) return + try { + const updated = await api.post(`/xxx-things/${item.id}/reject`, { + rejection_reason: reason.trim() || null, + }) + setItems(prev => prev.map(i => (i.id === item.id ? updated : i))) + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Fehler beim Ablehnen') + } + } + + if (pageLoading) return ( +
+ ) + if (pageError) return ( +
+

{pageError}

+
+ ) + + return ( + +
+
+
+

Xxx-Things

+

Kurzbeschreibung der Seite

+
+ +
+ +
+ {tableLoading ? ( +
+ ) : ( +
+ + + + {['Mitarbeiter', 'Status', 'Notiz', 'Datum', 'Aktion'].map(h => ( + + ))} + + + + {items.map(item => ( + + + + + + + + ))} + {items.length === 0 && ( + + )} + +
{h}
{item.user_name}{item.note || '—'} + {new Date(item.created_at).toLocaleDateString('de-DE')} + + {item.status === 'requested' && ( +
+ + +
+ )} +
Keine Einträge gefunden.
+
+ )} +
+
+ + {showModal && ( + setShowModal(false)}> +
+