feat: Abwesenheiten-Screen in Mobile-App
- MobileAbsencesScreen.tsx: - Urlaubskonto-Karte (verbleibende Tage + Fortschrittsbalken) - Liste eigener Abwesenheiten (aktuell/geplant + vergangen) - Farbpunkt pro Abwesenheitstyp, Status-Badge - Bottom-Sheet Modal: Antrag stellen oder Krank melden - Start-/Enddatum-Picker, Typ-Auswahl, optionale Notiz - SICK-Typ → quick-sick Endpoint, sonst POST /absences/ - MobileBottomNav: 4. Tab 'Urlaub' (war 3 Tabs) - MobilePage: Screen 'absences' eingebunden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
type Screen = 'stamp' | 'today' | 'profile'
|
||||
type Screen = 'stamp' | 'today' | 'absences' | 'profile'
|
||||
|
||||
interface Props {
|
||||
active: Screen
|
||||
@@ -29,6 +29,16 @@ export function MobileBottomNav({ active, onChange }: Props) {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'absences',
|
||||
label: 'Urlaub',
|
||||
icon: (
|
||||
<svg className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={1.75}>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' d='M3 17l4-8 4 4 4-6 4 10' />
|
||||
<path strokeLinecap='round' strokeLinejoin='round' d='M3 20h18' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
@@ -52,9 +62,7 @@ export function MobileBottomNav({ active, onChange }: Props) {
|
||||
key={item.id}
|
||||
onClick={() => onChange(item.id)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 min-h-[56px] py-2 transition-colors ${
|
||||
active === item.id
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-400'
|
||||
active === item.id ? 'text-blue-600' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -5,13 +5,15 @@ import { MobileBottomNav } from './MobileBottomNav'
|
||||
import { MobileStampScreen } from './MobileStampScreen'
|
||||
import { MobileTodayScreen } from './MobileTodayScreen'
|
||||
import { MobileProfileScreen } from './MobileProfileScreen'
|
||||
import { MobileAbsencesScreen } from './MobileAbsencesScreen'
|
||||
|
||||
type Screen = 'stamp' | 'today' | 'profile'
|
||||
type Screen = 'stamp' | 'today' | 'absences' | 'profile'
|
||||
|
||||
const SCREEN_TITLES: Record<Screen, string> = {
|
||||
stamp: 'Zeiterfassung',
|
||||
today: 'Heute',
|
||||
profile: 'Profil',
|
||||
stamp: 'Zeiterfassung',
|
||||
today: 'Heute',
|
||||
absences: 'Abwesenheiten',
|
||||
profile: 'Profil',
|
||||
}
|
||||
|
||||
export function MobilePage() {
|
||||
@@ -63,9 +65,10 @@ export function MobilePage() {
|
||||
className='flex-1 overflow-y-auto'
|
||||
style={{ paddingBottom: 'calc(72px + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{screen === 'stamp' && <MobileStampScreen />}
|
||||
{screen === 'today' && <MobileTodayScreen />}
|
||||
{screen === 'profile' && <MobileProfileScreen />}
|
||||
{screen === 'stamp' && <MobileStampScreen />}
|
||||
{screen === 'today' && <MobileTodayScreen />}
|
||||
{screen === 'absences' && <MobileAbsencesScreen />}
|
||||
{screen === 'profile' && <MobileProfileScreen />}
|
||||
</main>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
|
||||
Reference in New Issue
Block a user