feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

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 c3cb9ce073
commit 3b2df1c978
18 changed files with 793 additions and 27 deletions
@@ -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