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:
2026-06-23 11:47:47 +02:00
co-authored by Claude Opus 4.8
parent be22a51805
commit 6fa66b8c13
18 changed files with 793 additions and 27 deletions
+34 -6
View File
@@ -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>
)