Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+664
View File
@@ -0,0 +1,664 @@
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
}
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
}
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 ''
// 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)
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`
}
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 [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('')
// 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('')
const [liveSeconds, setLiveSeconds] = useState(0)
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
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<TimeEntryListResponse>('/time/entries?limit=20'),
])
setUser(me)
setDashboard(dash)
setBalance(bal)
setEntries(list.items)
setTotal(list.total)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// Live-Ticker: läuft nur wenn eingestempelt
useEffect(() => {
if (tickerRef.current) clearInterval(tickerRef.current)
if (dashboard?.today_open && dashboard.today_start) {
// Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen
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)))
update()
tickerRef.current = setInterval(update, 1000)
} else {
setLiveSeconds(0)
}
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
}, [dashboard?.today_open, dashboard?.today_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 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)) // heute als Standard
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>
)}
{/* 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>
</div>
</div>
<div className='flex gap-2 mb-3'>
<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>
<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'>
{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'>
{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>
</>
)}
</div>
</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>
))}
</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>
)}
<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>
</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>
)
}