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 = { open: 'Offen', pending: 'Zur Prüfung', approved: 'Genehmigt', rejected: 'Abgelehnt', auto: 'Auto', } const STATUS_COLORS: Record = { 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(null) const [dashboard, setDashboard] = useState(null) const [weekBalance, setWeekBalance] = useState(null) const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [stamping, setStamping] = useState(false) const [note, setNote] = useState('') const [warnings, setWarnings] = useState([]) const [error, setError] = useState('') // Approval queue (Manager+) const [activeTab, setActiveTab] = useState<'mine' | 'approval'>('mine') const [pendingEntries, setPendingEntries] = useState([]) const [pendingTotal, setPendingTotal] = useState(0) const [pendingLoading, setPendingLoading] = useState(false) const [rejectId, setRejectId] = useState(null) const [rejectNote, setRejectNote] = useState('') const [approvalError, setApprovalError] = useState('') // Edit modal const [editEntry, setEditEntry] = useState(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(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 | null>(null) const breakTickerRef = useRef | null>(null) const load = useCallback(async () => { setLoading(true) try { const monday = getMondayOfCurrentWeek() const [me, dash, bal, list] = await Promise.all([ api.get('/auth/me'), api.get('/dashboard/me'), api.get(`/time/balance/me?period_start=${monday}`), api.get('/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('/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('/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('/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 = { 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 = { 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 = { 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 (
) if (!user) return ( {error &&
{error}
}
) 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 (

Zeiterfassung

{warnings.length > 0 && (

Hinweise:

    {warnings.map((w, i) =>
  • {w}
  • )}
)} {error && (
{error}
)} {/* Wochen-Balance Widget */} {weekBalance && (

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' })})

Gearbeitet

{fmtH(weekBalance.total_hours_worked)}

Erwartet

{fmtH(weekBalance.expected_hours)}

Überstunden

0 ? 'text-green-600' : weekBalance.overtime_hours < 0 ? 'text-red-600' : 'text-gray-800' }`}> {weekBalance.overtime_hours > 0 ? '+' : ''}{fmtH(weekBalance.overtime_hours)}

)} {/* Stempeluhr */}

Stempeluhr

{/* Live-Uhr – nur sichtbar wenn eingestempelt */} {isOpen && (
{isOnBreak ? ( <>

Pause läuft

{fmtMS(breakSeconds)}

Gesamtpause bisher: {(dashboard?.break_minutes ?? 0) + Math.floor(breakSeconds / 60)} min

) : ( <>

Arbeitszeit

{fmtHMS(liveSeconds)}

Start: {fmt(dashboard?.today_start ?? null)} {(dashboard?.break_minutes ?? 0) > 0 && ` · Pause: ${dashboard!.break_minutes} min`}

)}
)} {/* Notiz-Eingabe */}
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' />
{/* Stempel-Buttons */}
{!isOpen ? ( ) : ( <> {!isOnBreak ? ( ) : ( )} )}
{/* Status-Info wenn ausgestempelt */} {!isOpen && (

Heute noch nicht eingestempelt.

)}
{/* Tabs: Meine Einträge + Freigabe-Queue (nur Manager+) */}
{isManager && (
)} {/* Meine Einträge */} {activeTab === 'mine' && ( <>

Letzte Einträge

{total} gesamt {canManual && ( )}
{entries.length === 0 ? (

Noch keine Einträge

) : ( entries.map(entry => (

{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}

{fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min

{entry.note &&

{entry.note}

} {entry.correction_note &&

✏️ {entry.correction_note}

}

{fmtH(entry.worked_hours)}

{STATUS_LABELS[entry.status] ?? entry.status}
{(entry.status !== 'approved' || isManager) && ( )} {(entry.status !== 'approved' || isManager) && ( )}
)) )}
)} {/* Freigabe-Queue */} {activeTab === 'approval' && isManager && ( <>

Einträge zur Freigabe

{pendingTotal} ausstehend
{approvalError && (
{approvalError}
)} {pendingLoading ? (
) : pendingEntries.length === 0 ? (

Keine ausstehenden Einträge

) : (
{pendingEntries.map(entry => (

{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}

{fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min

{entry.note &&

{entry.note}

}

Mitarbeiter-ID: {entry.user_id.slice(0, 8)}…

{fmtH(entry.worked_hours)}

Zur Prüfung
))}
)} )}
{/* Reject-Bestätigungs-Modal */} {rejectId && (

Eintrag ablehnen