feat: Live-Stempel-Uhr, Break-UI, Balance-Widget, Approval-Queue + PDF-Export (WeasyPrint)

Frontend (TimeTrackingPage):
- Live-Arbeitsuhr (HH:MM:SS) während eingestempelt
- Break-Start/End-Buttons mit laufender Pausenuhr
- Wochen-Balance-Widget (gearbeitet / erwartet / überstunden)
- Approval-Queue Tab für Manager/HR/Admin (pending entries genehmigen/ablehnen)

Backend (Reports):
- weasyprint>=61.0 in requirements.txt
- 3 neue PDF-Export-Tests (Zeit, Abwesenheit, Überstunden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 11:59:32 +02:00
parent ada1b51f33
commit 62ef6c2a11
6 changed files with 1327 additions and 139 deletions
+380 -139
View File
@@ -25,9 +25,9 @@ interface TimeEntryOut {
correction_note: string | null
status: string
source: string
break_start?: string | null // ISO-Timestamp wenn Pause läuft
}
interface TimeEntryWithWarnings {
entry: TimeEntryOut
warnings: string[]
@@ -53,6 +53,8 @@ interface TodayStatus {
today_open: boolean
today_start: string | null
today_hours_so_far: number | null
break_start?: string | null
break_minutes?: number
}
const STATUS_LABELS: Record<string, string> = {
@@ -73,7 +75,6 @@ const STATUS_COLORS: Record<string, string> = {
function fmt(iso: string | null): string {
if (!iso) return ''
// Backend kann reines time-Objekt liefern ("HH:MM:SS") oder ISO-Datetime
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
const d = new Date(iso)
if (isNaN(d.getTime())) return iso.slice(0, 5)
@@ -87,11 +88,33 @@ function fmtH(h: number | null): string {
return `${hrs}h ${min}m`
}
function fmtHMS(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function fmtMS(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/** Gibt den ISO-Datums-String (YYYY-MM-DD) des Montags der aktuellen Woche zurück */
function getMondayOfCurrentWeek(): string {
const now = new Date()
const day = now.getDay() // 0=So, 1=Mo, ...
const diff = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diff)
return monday.toISOString().slice(0, 10)
}
export function TimeTrackingPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
const [balance, setBalance] = useState<BalanceResponse | null>(null)
const [weekBalance, setWeekBalance] = useState<BalanceResponse | null>(null)
const [entries, setEntries] = useState<TimeEntryOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
@@ -100,6 +123,15 @@ export function TimeTrackingPage() {
const [warnings, setWarnings] = useState<string[]>([])
const [error, setError] = useState('')
// Approval queue (Manager+)
const [activeTab, setActiveTab] = useState<'mine' | 'approval'>('mine')
const [pendingEntries, setPendingEntries] = useState<TimeEntryOut[]>([])
const [pendingTotal, setPendingTotal] = useState(0)
const [pendingLoading, setPendingLoading] = useState(false)
const [rejectId, setRejectId] = useState<string | null>(null)
const [rejectNote, setRejectNote] = useState('')
const [approvalError, setApprovalError] = useState('')
// Edit modal
const [editEntry, setEditEntry] = useState<TimeEntryOut | null>(null)
const [editStart, setEditStart] = useState('')
@@ -130,21 +162,25 @@ export function TimeTrackingPage() {
const [newSaving, setNewSaving] = useState(false)
const [newError, setNewError] = useState('')
// Live tickers
const [liveSeconds, setLiveSeconds] = useState(0)
const [breakSeconds, setBreakSeconds] = useState(0)
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const breakTickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const monday = getMondayOfCurrentWeek()
const [me, dash, bal, list] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<TodayStatus>('/dashboard/me'),
api.get<BalanceResponse>('/time/balance/me'),
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
])
setUser(me)
setDashboard(dash)
setBalance(bal)
setWeekBalance(bal)
setEntries(list.items)
setTotal(list.total)
} catch (e: unknown) {
@@ -154,25 +190,61 @@ export function TimeTrackingPage() {
}
}, [])
const loadPending = useCallback(async () => {
setPendingLoading(true)
setApprovalError('')
try {
const res = await api.get<TimeEntryListResponse>('/time/entries?status=pending&limit=50')
setPendingEntries(res.items)
setPendingTotal(res.total)
} catch (e: unknown) {
setApprovalError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setPendingLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// Live-Ticker: läuft nur wenn eingestempelt
// Lade Approval-Queue wenn Tab aktiv und User ein Manager ist
useEffect(() => {
if (activeTab === 'approval' && user && ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
loadPending()
}
}, [activeTab, user, loadPending])
// Live-Ticker: läuft nur wenn eingestempelt und KEINE Pause läuft
useEffect(() => {
if (tickerRef.current) clearInterval(tickerRef.current)
if (dashboard?.today_open && dashboard.today_start) {
// Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen
if (dashboard?.today_open && dashboard.today_start && !dashboard.break_start) {
const startStr = dashboard.today_start
const today = new Date()
const [h, m, s] = startStr.split(':').map(Number)
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs) / 1000)))
// Bereits vergangene Pausenminuten abziehen
const pausedMs = (dashboard.break_minutes ?? 0) * 60 * 1000
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs - pausedMs) / 1000)))
update()
tickerRef.current = setInterval(update, 1000)
} else {
setLiveSeconds(0)
}
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
}, [dashboard?.today_open, dashboard?.today_start])
}, [dashboard?.today_open, dashboard?.today_start, dashboard?.break_start, dashboard?.break_minutes])
// Pausen-Ticker: läuft nur wenn Pause aktiv
useEffect(() => {
if (breakTickerRef.current) clearInterval(breakTickerRef.current)
if (dashboard?.today_open && dashboard.break_start) {
const breakStartMs = new Date(dashboard.break_start).getTime()
const update = () => setBreakSeconds(Math.max(0, Math.floor((Date.now() - breakStartMs) / 1000)))
update()
breakTickerRef.current = setInterval(update, 1000)
} else {
setBreakSeconds(0)
}
return () => { if (breakTickerRef.current) clearInterval(breakTickerRef.current) }
}, [dashboard?.today_open, dashboard?.break_start])
const stampIn = async () => {
setStamping(true); setError(''); setWarnings([])
@@ -212,6 +284,26 @@ export function TimeTrackingPage() {
const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '')
const canManual = isManager || (user?.can_manual_time_entry ?? false)
const isOnBreak = dashboard?.today_open && !!dashboard.break_start
const approveEntry = async (id: string) => {
setApprovalError('')
try {
await api.post(`/time/entries/${id}/approve`, {})
await loadPending()
} catch (e: unknown) { setApprovalError(e instanceof Error ? e.message : 'Fehler beim Genehmigen') }
}
const rejectEntry = async () => {
if (!rejectId) return
setApprovalError('')
try {
await api.post(`/time/entries/${rejectId}/reject`, { rejection_note: rejectNote || null })
setRejectId(null)
setRejectNote('')
await loadPending()
} catch (e: unknown) { setApprovalError(e instanceof Error ? e.message : 'Fehler beim Ablehnen') }
}
const deleteEntry = async (id: string) => {
if (!confirm('Eintrag wirklich löschen?')) return
@@ -224,7 +316,7 @@ export function TimeTrackingPage() {
const openDuplicate = (entry: TimeEntryOut) => {
setDupEntry(entry)
setDupDate(new Date().toISOString().slice(0, 10)) // heute als Standard
setDupDate(new Date().toISOString().slice(0, 10))
setDupStart(fmt(entry.start_time))
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
setDupBreak(entry.break_minutes)
@@ -335,30 +427,67 @@ export function TimeTrackingPage() {
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
)}
{/* Stempeluhr */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<h2 className='text-lg font-semibold text-gray-800 mb-4'>Stempeluhr</h2>
<div className='grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4'>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Status</p>
<p className={`text-lg font-bold ${isOpen ? 'text-green-600' : 'text-gray-500'}`}>
{isOpen ? 'Eingestempelt' : 'Ausgestempelt'}
</p>
</div>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Beginn</p>
<p className='text-lg font-bold text-gray-800'>{fmt(dashboard?.today_start ?? null)}</p>
</div>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Bisher heute</p>
<p className='text-lg font-bold text-gray-800'>
{dashboard?.today_open && liveSeconds > 0
? `${Math.floor(liveSeconds / 3600)}h ${Math.floor((liveSeconds % 3600) / 60)}m ${liveSeconds % 60}s`
: fmtH(dashboard?.today_hours_so_far ?? null)}
</p>
{/* Wochen-Balance Widget */}
{weekBalance && (
<div className='bg-white rounded-xl shadow-sm border border-gray-100 p-6'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4'>
Aktuelle Woche ({new Date(weekBalance.period_start + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} {new Date(weekBalance.period_end + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })})
</p>
<div className='grid grid-cols-3 gap-6'>
<div className='text-center'>
<p className='text-xs text-gray-500 mb-1'>Gearbeitet</p>
<p className='text-2xl font-bold text-gray-800'>{fmtH(weekBalance.total_hours_worked)}</p>
</div>
<div className='text-center border-x border-gray-100'>
<p className='text-xs text-gray-500 mb-1'>Erwartet</p>
<p className='text-2xl font-bold text-gray-800'>{fmtH(weekBalance.expected_hours)}</p>
</div>
<div className='text-center'>
<p className='text-xs text-gray-500 mb-1'>Überstunden</p>
<p className={`text-2xl font-bold ${
weekBalance.overtime_hours > 0
? 'text-green-600'
: weekBalance.overtime_hours < 0
? 'text-red-600'
: 'text-gray-800'
}`}>
{weekBalance.overtime_hours > 0 ? '+' : ''}{fmtH(weekBalance.overtime_hours)}
</p>
</div>
</div>
</div>
<div className='flex gap-2 mb-3'>
)}
{/* Stempeluhr */}
<div className='bg-white rounded-xl shadow-sm border border-gray-100 p-6'>
<h2 className='text-lg font-semibold text-gray-800 mb-5'>Stempeluhr</h2>
{/* Live-Uhr nur sichtbar wenn eingestempelt */}
{isOpen && (
<div className='flex flex-col items-center mb-6 py-4 bg-gray-50 rounded-xl'>
{isOnBreak ? (
<>
<p className='text-xs font-medium text-yellow-600 uppercase tracking-widest mb-2'>Pause läuft</p>
<p className='text-4xl font-mono font-bold text-yellow-500'>{fmtMS(breakSeconds)}</p>
<p className='text-xs text-gray-400 mt-2'>
Gesamtpause bisher: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min
</p>
</>
) : (
<>
<p className='text-xs font-medium text-green-600 uppercase tracking-widest mb-2'>Arbeitszeit</p>
<p className='text-4xl font-mono font-bold text-gray-800'>{fmtHMS(liveSeconds)}</p>
<p className='text-xs text-gray-400 mt-2'>
Start: {fmt(dashboard?.today_start ?? null)}
{(dashboard?.break_minutes ?? 0) > 0 && ` · Pause: ${dashboard!.break_minutes} min`}
</p>
</>
)}
</div>
)}
{/* Notiz-Eingabe */}
<div className='flex gap-2 mb-4'>
<input
type='text'
placeholder='Notiz (optional)'
@@ -367,137 +496,249 @@ export function TimeTrackingPage() {
className='flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
{/* Stempel-Buttons */}
<div className='flex flex-wrap gap-2'>
{!isOpen ? (
<button onClick={stampIn} disabled={stamping}
className='px-6 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors'>
className='px-6 py-2 rounded-lg font-medium text-sm bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors'>
{stamping ? '...' : 'Einstempeln'}
</button>
) : (
<>
<button onClick={stampOut} disabled={stamping}
className='px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors'>
className='px-6 py-2 rounded-lg font-medium text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors'>
{stamping ? '...' : 'Ausstempeln'}
</button>
<button onClick={breakStart} disabled={stamping}
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
Pause starten
</button>
<button onClick={breakEnd} disabled={stamping}
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
Pause beenden
</button>
{!isOnBreak ? (
<button onClick={breakStart} disabled={stamping}
className='px-4 py-2 rounded-lg font-medium text-sm bg-yellow-400 text-white hover:bg-yellow-500 disabled:opacity-50 transition-colors'>
{stamping ? '...' : '☕ Pause starten'}
</button>
) : (
<button onClick={breakEnd} disabled={stamping}
className='px-4 py-2 rounded-lg font-medium text-sm bg-green-500 text-white hover:bg-green-600 disabled:opacity-50 transition-colors'>
{stamping ? '...' : '▶ Pause beenden'}
</button>
)}
</>
)}
</div>
{/* Status-Info wenn ausgestempelt */}
{!isOpen && (
<p className='text-sm text-gray-400 mt-3'>Heute noch nicht eingestempelt.</p>
)}
</div>
{/* Balance */}
{balance && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3'>
Monat ({new Date(balance.period_start).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })})
</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-4'>
{[
{ label: 'Gearbeitet', value: fmtH(balance.total_hours_worked) },
{ label: 'Soll', value: fmtH(balance.expected_hours) },
{ label: 'Überstunden', value: fmtH(balance.overtime_hours), ot: balance.overtime_hours },
{ label: 'Genehmigte Einträge', value: String(balance.approved_entries) },
].map(({ label, value, ot }) => (
<div key={label}>
<p className='text-xs text-gray-500'>{label}</p>
<p className={`text-xl font-bold mt-1 ${
ot !== undefined ? (ot > 0 ? 'text-green-600' : ot < 0 ? 'text-red-600' : 'text-gray-800') : 'text-gray-800'
}`}>{value}</p>
</div>
))}
{/* Tabs: Meine Einträge + Freigabe-Queue (nur Manager+) */}
<div className='bg-white rounded-xl shadow-sm border border-gray-100'>
{isManager && (
<div className='flex border-b border-gray-100'>
<button
onClick={() => setActiveTab('mine')}
className={`px-6 py-3 text-sm font-medium transition-colors ${
activeTab === 'mine'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Meine Einträge
</button>
<button
onClick={() => setActiveTab('approval')}
className={`px-6 py-3 text-sm font-medium transition-colors flex items-center gap-2 ${
activeTab === 'approval'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Zur Freigabe
{pendingTotal > 0 && (
<span className='bg-blue-100 text-blue-700 text-xs font-bold px-1.5 py-0.5 rounded-full'>
{pendingTotal}
</span>
)}
</button>
</div>
</div>
)}
)}
{/* Recent entries */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between'>
<h2 className='text-lg font-semibold text-gray-800'>Letzte Einträge</h2>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-400'>{total} gesamt</span>
{canManual && (
<button
onClick={openNew}
className='flex items-center gap-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-3.5 h-3.5' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z' clipRule='evenodd' />
</svg>
Neuer Eintrag
</button>
)}
</div>
</div>
<div className='divide-y divide-gray-50'>
{entries.length === 0 ? (
<p className='text-center text-gray-400 py-8 text-sm'>Noch keine Einträge</p>
) : (
entries.map(entry => (
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
<div className='flex-1'>
<p className='text-sm font-medium text-gray-800'>
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<p className='text-xs text-gray-500'>
{fmt(entry.start_time)} {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
</p>
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
{entry.correction_note && <p className='text-xs text-orange-500 mt-0.5'> {entry.correction_note}</p>}
</div>
<div className='flex items-center gap-3'>
<div className='text-right'>
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[entry.status] ?? entry.status}
</span>
</div>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => openEdit(entry)}
title={entry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Bearbeiten'}
className={`transition-colors p-1 ${entry.status === 'approved' ? 'text-orange-400 hover:text-orange-600' : 'text-gray-400 hover:text-blue-600'}`}
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z' />
</svg>
</button>
)}
{/* Meine Einträge */}
{activeTab === 'mine' && (
<>
<div className='px-6 py-4 border-b border-gray-50 flex items-center justify-between'>
<h2 className='text-base font-semibold text-gray-800'>Letzte Einträge</h2>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-400'>{total} gesamt</span>
{canManual && (
<button
onClick={() => openDuplicate(entry)}
title='Eintrag duplizieren'
className='transition-colors p-1 text-gray-400 hover:text-purple-600'
onClick={openNew}
className='flex items-center gap-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z' />
<path d='M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z' />
<svg xmlns='http://www.w3.org/2000/svg' className='w-3.5 h-3.5' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z' clipRule='evenodd' />
</svg>
Neuer Eintrag
</button>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => deleteEntry(entry.id)}
title='Eintrag löschen'
className='transition-colors p-1 text-gray-400 hover:text-red-600'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z' clipRule='evenodd' />
</svg>
</button>
)}
</div>
)}
</div>
))
)}
</div>
</div>
<div className='divide-y divide-gray-50'>
{entries.length === 0 ? (
<p className='text-center text-gray-400 py-8 text-sm'>Noch keine Einträge</p>
) : (
entries.map(entry => (
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
<div className='flex-1'>
<p className='text-sm font-medium text-gray-800'>
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<p className='text-xs text-gray-500'>
{fmt(entry.start_time)} {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
</p>
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
{entry.correction_note && <p className='text-xs text-orange-500 mt-0.5'> {entry.correction_note}</p>}
</div>
<div className='flex items-center gap-3'>
<div className='text-right'>
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[entry.status] ?? entry.status}
</span>
</div>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => openEdit(entry)}
title={entry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Bearbeiten'}
className={`transition-colors p-1 ${entry.status === 'approved' ? 'text-orange-400 hover:text-orange-600' : 'text-gray-400 hover:text-blue-600'}`}
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z' />
</svg>
</button>
)}
<button
onClick={() => openDuplicate(entry)}
title='Eintrag duplizieren'
className='transition-colors p-1 text-gray-400 hover:text-purple-600'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z' />
<path d='M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z' />
</svg>
</button>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => deleteEntry(entry.id)}
title='Eintrag löschen'
className='transition-colors p-1 text-gray-400 hover:text-red-600'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z' clipRule='evenodd' />
</svg>
</button>
)}
</div>
</div>
))
)}
</div>
</>
)}
{/* Freigabe-Queue */}
{activeTab === 'approval' && isManager && (
<>
<div className='px-6 py-4 border-b border-gray-50 flex items-center justify-between'>
<h2 className='text-base font-semibold text-gray-800'>Einträge zur Freigabe</h2>
<span className='text-sm text-gray-400'>{pendingTotal} ausstehend</span>
</div>
{approvalError && (
<div className='mx-6 mt-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{approvalError}</div>
)}
{pendingLoading ? (
<div className='flex justify-center py-10'><Spinner /></div>
) : pendingEntries.length === 0 ? (
<p className='text-center text-gray-400 py-10 text-sm'>Keine ausstehenden Einträge</p>
) : (
<div className='divide-y divide-gray-50'>
{pendingEntries.map(entry => (
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
<div className='flex-1'>
<p className='text-sm font-medium text-gray-800'>
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<p className='text-xs text-gray-500'>
{fmt(entry.start_time)} {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
</p>
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
<p className='text-xs text-gray-400 mt-0.5'>Mitarbeiter-ID: {entry.user_id.slice(0, 8)}</p>
</div>
<div className='flex items-center gap-3'>
<div className='text-right mr-2'>
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
<span className='inline-block text-xs px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700'>
Zur Prüfung
</span>
</div>
<button
onClick={() => approveEntry(entry.id)}
title='Genehmigen'
className='px-4 py-2 rounded-lg font-medium text-sm bg-green-600 text-white hover:bg-green-700 transition-colors'
>
Genehmigen
</button>
<button
onClick={() => { setRejectId(entry.id); setRejectNote('') }}
title='Ablehnen'
className='px-4 py-2 rounded-lg font-medium text-sm border border-red-300 text-red-600 hover:bg-red-50 transition-colors'
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Reject-Bestätigungs-Modal */}
{rejectId && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
<div className='flex justify-between items-center mb-4'>
<h2 className='text-base font-semibold text-gray-800'>Eintrag ablehnen</h2>
<button onClick={() => setRejectId(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ablehnungsgrund (optional)</span>
<textarea
value={rejectNote}
onChange={e => setRejectNote(e.target.value)}
placeholder='z. B. Zeiten stimmen nicht mit Kiosk überein'
rows={3}
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-400 mt-1'
/>
</label>
<div className='flex justify-end gap-2 pt-1'>
<button onClick={() => setRejectId(null)}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={rejectEntry}
className='px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700'>
Ablehnen
</button>
</div>
</div>
</div>
</div>
)}
{/* Duplicate Modal */}
{dupEntry && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>