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:
@@ -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