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:
@@ -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'>×</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user