Files
timemaster/frontend/src/pages/TimeTrackingPage.tsx
T
patrick 62ef6c2a11 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>
2026-05-24 11:59:32 +02:00

906 lines
41 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useRef } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
company_id: string
can_manual_time_entry: boolean
}
interface TimeEntryOut {
id: string
user_id: string
date: string // "YYYY-MM-DD"
start_time: string // "HH:MM:SS"
end_time: string | null // "HH:MM:SS" oder null wenn offen
break_minutes: number
worked_hours: number | null
note: string | null
correction_note: string | null
status: string
source: string
break_start?: string | null // ISO-Timestamp wenn Pause läuft
}
interface TimeEntryWithWarnings {
entry: TimeEntryOut
warnings: string[]
}
interface TimeEntryListResponse {
total: number
items: TimeEntryOut[]
}
interface BalanceResponse {
user_id: string
period_start: string
period_end: string
total_hours_worked: number
expected_hours: number
overtime_hours: number
approved_entries: number
pending_entries: number
}
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> = {
open: 'Offen',
pending: 'Zur Prüfung',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
auto: 'Auto',
}
const STATUS_COLORS: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
pending: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
auto: 'bg-gray-100 text-gray-600',
}
function fmt(iso: string | null): string {
if (!iso) return ''
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)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function fmtH(h: number | null): string {
if (h === null || h === undefined) return ''
const hrs = Math.floor(h)
const min = Math.round((h - hrs) * 60)
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 [weekBalance, setWeekBalance] = useState<BalanceResponse | null>(null)
const [entries, setEntries] = useState<TimeEntryOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [stamping, setStamping] = useState(false)
const [note, setNote] = useState('')
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('')
const [editEnd, setEditEnd] = useState('')
const [editBreak, setEditBreak] = useState(0)
const [editNote, setEditNote] = useState('')
const [editCorrectionNote, setEditCorrectionNote] = useState('')
const [editSaving, setEditSaving] = useState(false)
const [editError, setEditError] = useState('')
// Duplicate modal
const [dupEntry, setDupEntry] = useState<TimeEntryOut | null>(null)
const [dupDate, setDupDate] = useState('')
const [dupStart, setDupStart] = useState('')
const [dupEnd, setDupEnd] = useState('')
const [dupBreak, setDupBreak] = useState(0)
const [dupNote, setDupNote] = useState('')
const [dupSaving, setDupSaving] = useState(false)
const [dupError, setDupError] = useState('')
// New manual entry modal
const [showNew, setShowNew] = useState(false)
const [newDate, setNewDate] = useState('')
const [newStart, setNewStart] = useState('')
const [newEnd, setNewEnd] = useState('')
const [newBreak, setNewBreak] = useState(0)
const [newNote, setNewNote] = useState('')
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?period_start=${monday}`),
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
])
setUser(me)
setDashboard(dash)
setWeekBalance(bal)
setEntries(list.items)
setTotal(list.total)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
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])
// 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 && !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()
// 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?.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([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-in', { note: note || null })
if (res.warnings.length) setWarnings(res.warnings)
setNote('')
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const stampOut = async () => {
setStamping(true); setError(''); setWarnings([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-out', { note: note || null })
if (res.warnings.length) setWarnings(res.warnings)
setNote('')
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakStart = async () => {
setStamping(true); setError('')
try { await api.post('/time/break-start', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakEnd = async () => {
setStamping(true); setError('')
try { await api.post('/time/break-end', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
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
setError('')
try {
await api.del(`/time/entries/${id}`)
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Löschen') }
}
const openDuplicate = (entry: TimeEntryOut) => {
setDupEntry(entry)
setDupDate(new Date().toISOString().slice(0, 10))
setDupStart(fmt(entry.start_time))
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
setDupBreak(entry.break_minutes)
setDupNote(entry.note ?? '')
setDupError('')
}
const saveDuplicate = async () => {
if (!dupDate || !dupStart) { setDupError('Datum und Startzeit sind Pflicht.'); return }
setDupSaving(true); setDupError('')
try {
const payload: Record<string, unknown> = {
date: dupDate,
start_time: dupStart,
break_minutes: dupBreak,
note: dupNote || null,
}
if (dupEnd) payload.end_time = dupEnd
await api.post('/time/entries', payload)
setDupEntry(null)
await load()
} catch (e: unknown) { setDupError(e instanceof Error ? e.message : 'Fehler') }
finally { setDupSaving(false) }
}
const openNew = () => {
setNewDate(new Date().toISOString().slice(0, 10))
setNewStart(''); setNewEnd(''); setNewBreak(0); setNewNote(''); setNewError('')
setShowNew(true)
}
const saveNew = async () => {
if (!newDate || !newStart) { setNewError('Datum und Startzeit sind Pflicht.'); return }
setNewSaving(true); setNewError('')
try {
const payload: Record<string, unknown> = {
date: newDate, start_time: newStart, break_minutes: newBreak, note: newNote || null,
}
if (newEnd) payload.end_time = newEnd
await api.post('/time/entries', payload)
setShowNew(false)
await load()
} catch (e: unknown) { setNewError(e instanceof Error ? e.message : 'Fehler') }
finally { setNewSaving(false) }
}
const openEdit = (entry: TimeEntryOut) => {
setEditEntry(entry)
setEditStart(fmt(entry.start_time))
setEditEnd(entry.end_time ? fmt(entry.end_time) : '')
setEditBreak(entry.break_minutes)
setEditNote(entry.note ?? '')
setEditCorrectionNote('')
setEditError('')
}
const saveEdit = async () => {
if (!editEntry) return
if (!editStart) { setEditError('Startzeit fehlt.'); return }
if (editEntry.status === 'approved' && !editCorrectionNote.trim()) {
setEditError('Änderungsgrund ist bei genehmigten Einträgen Pflicht.')
return
}
setEditSaving(true); setEditError('')
try {
const payload: Record<string, unknown> = {
start_time: editStart,
break_minutes: editBreak,
note: editNote || null,
}
if (editEnd) payload.end_time = editEnd
if (editEntry.status === 'approved') payload.correction_note = editCorrectionNote.trim()
await api.patch(`/time/entries/${editEntry.id}`, payload)
setEditEntry(null)
await load()
} catch (e: unknown) { setEditError(e instanceof Error ? e.message : 'Fehler') }
finally { setEditSaving(false) }
}
if (loading) return (
<Layout userRole='' userName=''>
<div className='flex justify-center py-20'><Spinner /></div>
</Layout>
)
if (!user) return (
<Layout userRole='' userName=''>
{error && <div className='m-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700'>{error}</div>}
</Layout>
)
const isOpen = dashboard?.today_open ?? false
const inp = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
<h1 className='text-2xl font-bold text-gray-900'>Zeiterfassung</h1>
{warnings.length > 0 && (
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-4'>
<p className='font-medium text-yellow-800 mb-1'>Hinweise:</p>
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{error && (
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
)}
{/* 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>
)}
{/* 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)'
value={note}
onChange={e => setNote(e.target.value)}
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 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 rounded-lg font-medium text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors'>
{stamping ? '...' : 'Ausstempeln'}
</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>
{/* 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>
)}
{/* 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={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>
)}
<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'>
<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 duplizieren</h2>
<button onClick={() => setDupEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<p className='text-xs text-gray-500 mb-4'>
Kopie von: {new Date(dupEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Datum *</span>
<input type='date' value={dupDate} onChange={e => setDupDate(e.target.value)} className={inp} />
</label>
<div className='grid grid-cols-2 gap-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={dupStart} onChange={e => setDupStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={dupEnd} onChange={e => setDupEnd(e.target.value)} className={inp} />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={dupBreak}
onChange={e => setDupBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={dupNote} onChange={e => setDupNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{dupError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{dupError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setDupEntry(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={saveDuplicate} disabled={dupSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
{dupSaving ? 'Wird erstellt…' : 'Duplizieren'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editEntry && (
<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'>
{editEntry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Eintrag bearbeiten'}
</h2>
<button onClick={() => setEditEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<p className='text-xs text-gray-500 mb-4'>
{new Date(editEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<div className='space-y-3'>
<div className='grid grid-cols-2 gap-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={editStart} onChange={e => setEditStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={editEnd} onChange={e => setEditEnd(e.target.value)} className={inp} />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={editBreak}
onChange={e => setEditBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={editNote} onChange={e => setEditNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{editEntry.status === 'approved' && (
<label className='block'>
<span className='text-xs font-medium text-red-600'>Änderungsgrund *</span>
<input type='text' value={editCorrectionNote}
onChange={e => setEditCorrectionNote(e.target.value)}
placeholder='z. B. Tippfehler bei Stempelzeit korrigiert'
className={`${inp} border-red-300 focus:ring-red-400`} />
<p className='text-xs text-gray-400 mt-1'>Wird im Änderungsprotokoll gespeichert.</p>
</label>
)}
{editError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditEntry(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={saveEdit} disabled={editSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{editSaving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Neuer manueller Eintrag Modal */}
{showNew && (
<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'>
<h2 className='text-base font-semibold text-gray-800 mb-4'>Neuer Eintrag</h2>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Datum *</span>
<input type='date' value={newDate} onChange={e => setNewDate(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={newStart} onChange={e => setNewStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={newEnd} onChange={e => setNewEnd(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={newBreak}
onChange={e => setNewBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={newNote} onChange={e => setNewNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{newError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{newError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowNew(false)}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={saveNew} disabled={newSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{newSaving ? 'Speichere…' : 'Eintrag anlegen'}
</button>
</div>
</div>
</div>
</div>
)}
</Layout>
)
}