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:
2026-05-24 23:31:00 +02:00
parent 4a1dec7ae7
commit 22be68ee27
4 changed files with 432 additions and 11 deletions
+40
View File
@@ -1094,3 +1094,43 @@ Keine Commits in dieser Session.
- frontend/src/api/client.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-05-24 21:15 21:17 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- edb1568 feat: mobile Login-Seite /mobile/login
### Geänderte Dateien
- DEVLOG.md | 77 ++++++
- frontend/src/App.tsx | 4 +
- frontend/src/pages/mobile/MobileBottomNav.tsx | 69 +++++
- frontend/src/pages/mobile/MobileLoginPage.tsx | 195 ++++++++++++++
- frontend/src/pages/mobile/MobilePage.tsx | 75 ++++++
- frontend/src/pages/mobile/MobileProfileScreen.tsx | 124 +++++++++
- frontend/src/pages/mobile/MobileStampScreen.tsx | 310 ++++++++++++++++++++++
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 146 ++++++++++
---
## 2026-05-24 23:21 23:22 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 4a1dec7 fix: mobile/tablet Geräteerkennung in LoginPage → Redirect zu /mobile/login
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 +++++++++--
---
## 2026-05-24 23:23 23:23 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/LoginPage.tsx | 11 +++++++++--
---
@@ -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>
)
}
+12 -4
View File
@@ -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}
+10 -7
View File
@@ -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 */}