@@ -0,0 +1,370 @@
import { useCallback , useEffect , useState } from 'react'
import { api } from '../../api/client'
// ── Typen ────────────────────────────────────────────────────────────────────
interface AbsenceType {
id : string
name : string
category : string
color : string | null
requires_approval : boolean
}
interface Absence {
id : string
type_id : string
start_date : string
end_date : string
half_day_start : boolean
half_day_end : boolean
working_days : number
status : 'pending' | 'approved' | 'rejected' | 'cancelled'
note : string | null
rejection_reason : string | null
}
interface AbsenceList {
total : number
items : Absence [ ]
}
interface VacationBalance {
entitled_days : number
special_days : number
carried_over : number
used_days : number
total_days : number
remaining_days : number
pending_days : number
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtDate ( d : string ) {
return new Date ( d + 'T00:00:00' ) . toLocaleDateString ( 'de-DE' , { day : '2-digit' , month : '2-digit' , year : 'numeric' } )
}
function statusLabel ( s : Absence [ 'status' ] ) {
return { pending : 'Ausstehend' , approved : 'Genehmigt' , rejected : 'Abgelehnt' , cancelled : 'Storniert' } [ s ]
}
function statusColor ( s : Absence [ 'status' ] ) {
return {
pending : 'bg-yellow-100 text-yellow-700' ,
approved : 'bg-green-100 text-green-700' ,
rejected : 'bg-red-100 text-red-700' ,
cancelled : 'bg-gray-100 text-gray-500' ,
} [ s ]
}
const today = new Date ( )
const todayStr = today . toISOString ( ) . slice ( 0 , 10 )
// ── Neuer Antrag Modal ───────────────────────────────────────────────────────
interface NewAbsenceModalProps {
types : AbsenceType [ ]
onClose : ( ) = > void
onCreated : ( ) = > void
}
function NewAbsenceModal ( { types , onClose , onCreated } : NewAbsenceModalProps ) {
const [ typeId , setTypeId ] = useState ( types [ 0 ] ? . id ? ? '' )
const [ startDate , setStartDate ] = useState ( todayStr )
const [ endDate , setEndDate ] = useState ( todayStr )
const [ note , setNote ] = useState ( '' )
const [ loading , setLoading ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
// Kategorie SICK → quick-sick, sonst normaler Antrag
const selectedType = types . find ( t = > t . id === typeId )
const isSick = selectedType ? . category === 'SICK'
const handleSubmit = async ( e : React.FormEvent ) = > {
e . preventDefault ( )
setLoading ( true ) ; setError ( null )
try {
if ( isSick ) {
await api . post ( '/absences/quick-sick' , { start_date : startDate , end_date : endDate } )
} else {
await api . post ( '/absences/' , { type_id : typeId , start_date : startDate , end_date : endDate , note : note || null } )
}
onCreated ( )
} catch ( e : unknown ) {
setError ( e instanceof Error ? e . message : 'Fehler beim Erstellen' )
} finally {
setLoading ( false ) }
}
return (
< div className = 'fixed inset-0 z-50 flex flex-col justify-end bg-black/40' onClick = { onClose } >
< div
className = 'bg-white rounded-t-2xl p-5 flex flex-col gap-4'
style = { { paddingBottom : 'calc(1.25rem + env(safe-area-inset-bottom))' } }
onClick = { e = > e . stopPropagation ( ) }
>
< div className = 'flex items-center justify-between' >
< h2 className = 'text-lg font-bold text-gray-900' > Neuer Antrag < / h2 >
< button onClick = { onClose } className = 'text-gray-400 text-2xl leading-none' > × < / button >
< / div >
{ error && (
< div className = 'bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700' > { error } < / div >
) }
< form onSubmit = { handleSubmit } className = 'flex flex-col gap-3' >
{ /* Typ */ }
< div className = 'flex flex-col gap-1' >
< label className = 'text-sm font-medium text-gray-700' > Art < / label >
< select
value = { typeId }
onChange = { e = > setTypeId ( e . target . value ) }
className = 'min-h-[48px] px-3 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white'
>
{ types . map ( t = > (
< option key = { t . id } value = { t . id } > { t . name } < / option >
) ) }
< / select >
< / div >
{ /* Datum */ }
< div className = 'grid grid-cols-2 gap-3' >
< div className = 'flex flex-col gap-1' >
< label className = 'text-sm font-medium text-gray-700' > Von < / label >
< input
type = 'date'
value = { startDate }
onChange = { e = > { setStartDate ( e . target . value ) ; if ( e . target . value > endDate ) setEndDate ( e . target . value ) } }
required
className = 'min-h-[48px] px-3 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500'
/ >
< / div >
< div className = 'flex flex-col gap-1' >
< label className = 'text-sm font-medium text-gray-700' > Bis < / label >
< input
type = 'date'
value = { endDate }
min = { startDate }
onChange = { e = > setEndDate ( e . target . value ) }
required
className = 'min-h-[48px] px-3 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500'
/ >
< / div >
< / div >
{ /* Notiz (nicht bei Krankmeldung) */ }
{ ! isSick && (
< div className = 'flex flex-col gap-1' >
< label className = 'text-sm font-medium text-gray-700' > Notiz < span className = 'text-gray-400 font-normal' > ( optional ) < / span > < / label >
< textarea
value = { note }
onChange = { e = > setNote ( e . target . value ) }
rows = { 2 }
placeholder = 'z.B. Hochzeit, Arzttermin …'
className = 'px-3 py-2 rounded-xl border border-gray-300 text-gray-900 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'
/ >
< / div >
) }
< button
type = 'submit'
disabled = { loading }
className = 'min-h-[52px] rounded-xl bg-blue-600 active:bg-blue-800 text-white font-semibold text-base transition-colors disabled:opacity-50 flex items-center justify-center gap-2 mt-1'
>
{ loading
? < span className = 'animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent' / >
: isSick ? '🤒 Krank melden' : '✓ Antrag stellen'
}
< / button >
< / form >
< / div >
< / div >
)
}
// ── Haupt-Screen ─────────────────────────────────────────────────────────────
export function MobileAbsencesScreen() {
const [ balance , setBalance ] = useState < VacationBalance | null > ( null )
const [ absences , setAbsences ] = useState < Absence [ ] > ( [ ] )
const [ types , setTypes ] = useState < AbsenceType [ ] > ( [ ] )
const [ typeMap , setTypeMap ] = useState < Record < string , AbsenceType > > ( { } )
const [ loading , setLoading ] = useState ( true )
const [ error , setError ] = useState < string | null > ( null )
const [ showModal , setShowModal ] = useState ( false )
const year = today . getFullYear ( )
const load = useCallback ( async ( ) = > {
setError ( null )
try {
const [ bal , list , typeList ] = await Promise . all ( [
api . get < VacationBalance > ( ` /absences/balance?year= ${ year } ` ) ,
api . get < AbsenceList > ( ` /absences/?year= ${ year } &limit=50 ` ) ,
api . get < AbsenceType [ ] > ( '/absence-types/' ) ,
] )
setBalance ( bal )
setAbsences ( list . items )
const active = typeList . filter ( t = > ( t as AbsenceType & { is_active? : boolean } ) . is_active !== false )
setTypes ( active )
setTypeMap ( Object . fromEntries ( active . map ( t = > [ t . id , t ] ) ) )
} catch ( e : unknown ) {
setError ( e instanceof Error ? e . message : 'Fehler beim Laden' )
} finally {
setLoading ( false )
}
} , [ year ] )
useEffect ( ( ) = > { load ( ) } , [ load ] )
if ( loading ) return (
< div className = 'flex flex-col items-center justify-center flex-1 py-20 gap-4' >
< div className = 'animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' / >
< p className = 'text-sm text-gray-400' > Wird geladen … < / p >
< / div >
)
// Abwesenheiten sortiert: zukünftige + laufende zuerst, dann vergangene
const sorted = [ . . . absences ] . sort ( ( a , b ) = > b . start_date . localeCompare ( a . start_date ) )
const upcoming = sorted . filter ( a = > a . end_date >= todayStr && a . status !== 'cancelled' && a . status !== 'rejected' )
const past = sorted . filter ( a = > a . end_date < todayStr || a . status === 'cancelled' || a . status === 'rejected' )
return (
< div className = 'flex flex-col gap-4 px-4 pt-4' >
{ error && (
< div className = 'bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700' > { error } < / div >
) }
{ /* Urlaubskonto */ }
{ balance && (
< div className = 'bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-4' >
< p className = 'text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3' > Urlaubskonto { year } < / p >
< div className = 'flex items-center justify-between mb-3' >
< div >
< p className = 'text-4xl font-bold text-blue-600' > { balance . remaining_days } < / p >
< p className = 'text-sm text-gray-500 mt-0.5' > Tage verbleibend < / p >
< / div >
< div className = 'flex flex-col gap-1.5 text-right text-sm' >
< div className = 'flex gap-2 justify-end' >
< span className = 'text-gray-400' > Anspruch < / span >
< span className = 'font-semibold text-gray-700' > { balance . total_days } Tage < / span >
< / div >
< div className = 'flex gap-2 justify-end' >
< span className = 'text-gray-400' > Genommen < / span >
< span className = 'font-semibold text-gray-700' > { balance . used_days } Tage < / span >
< / div >
{ balance . pending_days > 0 && (
< div className = 'flex gap-2 justify-end' >
< span className = 'text-gray-400' > Ausstehend < / span >
< span className = 'font-semibold text-yellow-600' > { balance . pending_days } Tage < / span >
< / div >
) }
{ balance . carried_over > 0 && (
< div className = 'flex gap-2 justify-end' >
< span className = 'text-gray-400' > Ü bertrag < / span >
< span className = 'font-semibold text-gray-700' > { balance . carried_over } Tage < / span >
< / div >
) }
< / div >
< / div >
{ /* Fortschrittsbalken */ }
< div className = 'h-2 bg-gray-100 rounded-full overflow-hidden' >
< div
className = 'h-full bg-blue-500 rounded-full transition-all'
style = { { width : ` ${ Math . min ( 100 , ( balance . used_days / Math . max ( balance . total_days , 1 ) ) * 100 ) } % ` } }
/ >
< / div >
< / div >
) }
{ /* Neuer Antrag Button */ }
< button
onClick = { ( ) = > setShowModal ( true ) }
className = 'flex items-center justify-center gap-2 min-h-[52px] rounded-xl bg-blue-600 active:bg-blue-800 text-white font-semibold text-base shadow-sm transition-colors'
>
< span className = 'text-lg' > + < / span >
Antrag stellen / Krank melden
< / button >
{ /* Kommende Abwesenheiten */ }
{ upcoming . length > 0 && (
< section >
< p className = 'text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2 px-1' > Aktuell & Geplant < / p >
< div className = 'flex flex-col gap-2' >
{ upcoming . map ( a = > (
< AbsenceCard key = { a . id } absence = { a } type = { typeMap [ a . type_id ] } / >
) ) }
< / div >
< / section >
) }
{ /* Vergangene Abwesenheiten */ }
{ past . length > 0 && (
< section className = 'mb-4' >
< p className = 'text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2 px-1' > Vergangen < / p >
< div className = 'flex flex-col gap-2' >
{ past . map ( a = > (
< AbsenceCard key = { a . id } absence = { a } type = { typeMap [ a . type_id ] } / >
) ) }
< / div >
< / section >
) }
{ upcoming . length === 0 && past . length === 0 && ! loading && (
< div className = 'flex flex-col items-center py-12 gap-2 text-gray-400' >
< span className = 'text-4xl' > 🌴 < / span >
< p className = 'text-sm' > Keine Abwesenheiten in { year } < / p >
< / div >
) }
{ /* Modal */ }
{ showModal && types . length > 0 && (
< NewAbsenceModal
types = { types }
onClose = { ( ) = > setShowModal ( false ) }
onCreated = { ( ) = > { setShowModal ( false ) ; load ( ) } }
/ >
) }
< / div >
)
}
// ── Karte pro Abwesenheit ────────────────────────────────────────────────────
function AbsenceCard ( { absence , type } : { absence : Absence ; type ? : AbsenceType } ) {
const isSameDay = absence . start_date === absence . end_date
const dateStr = isSameDay
? fmtDate ( absence . start_date )
: ` ${ fmtDate ( absence . start_date ) } – ${ fmtDate ( absence . end_date ) } `
const colorDot = type ? . color ? ? '#94a3b8'
return (
< div className = 'bg-white rounded-xl border border-gray-200 shadow-sm px-4 py-3 flex items-start gap-3' >
{ /* Farbpunkt */ }
< div className = 'mt-1 w-3 h-3 rounded-full flex-shrink-0' style = { { backgroundColor : colorDot } } / >
< div className = 'flex-1 min-w-0' >
< div className = 'flex items-center justify-between gap-2 flex-wrap' >
< p className = 'font-semibold text-gray-800 text-sm truncate' > { type ? . name ? ? '– ' } < / p >
< span className = { ` text-[11px] font-semibold px-2 py-0.5 rounded-full ${ statusColor ( absence . status ) } ` } >
{ statusLabel ( absence . status ) }
< / span >
< / div >
< p className = 'text-xs text-gray-500 mt-0.5' > { dateStr } < / p >
{ absence . working_days > 0 && (
< p className = 'text-xs text-gray-400 mt-0.5' > { absence . working_days } Arbeitstag { absence . working_days !== 1 ? 'e' : '' } < / p >
) }
{ absence . rejection_reason && (
< p className = 'text-xs text-red-500 mt-1' > Grund : { absence . rejection_reason } < / p >
) }
{ absence . note && (
< p className = 'text-xs text-gray-400 mt-1 italic' > „ { absence . note } " < / p >
) }
< / div >
< / div >
)
}