62ef6c2a11
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>
906 lines
41 KiB
TypeScript
906 lines
41 KiB
TypeScript
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'>×</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'>×</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'>×</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>
|
||
)
|
||
}
|