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,184 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const MAIN_NAV: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/time', label: 'Zeiterfassung' },
|
||||
{ path: '/absences', label: 'Abwesenheiten' },
|
||||
{ path: '/calendar', label: 'Kalender' },
|
||||
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
|
||||
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||
]
|
||||
|
||||
const SETTINGS_NAV: NavItem[] = [
|
||||
{ path: '/work-schedules', label: 'Arbeitspläne', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/absence-types', label: 'Abwesenheitstypen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/company', label: 'Firma', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/kiosk', label: 'Kiosk-Geräte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/users/import', label: 'Mitarbeiter-Import', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/import', label: 'Daten importieren', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||
{ path: '/settings/caldav', label: 'CalDAV' },
|
||||
{ path: '/settings/ldap', label: 'LDAP', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/smtp', label: 'SMTP', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/audit-log', label: 'Audit-Log', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
]
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
COMPANY_ADMIN: 'Administrator',
|
||||
HR: 'HR',
|
||||
MANAGER: 'Manager',
|
||||
EMPLOYEE: 'Mitarbeiter',
|
||||
}
|
||||
|
||||
export function Layout({ children, userRole, userName }: {
|
||||
children: React.ReactNode
|
||||
userRole: string
|
||||
userName: string
|
||||
}) {
|
||||
const { logout } = useAuth()
|
||||
const { pathname } = useLocation()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const settingsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const visibleMain = MAIN_NAV.filter(n => !n.roles || n.roles.includes(userRole))
|
||||
const visibleSettings = SETTINGS_NAV.filter(n => !n.roles || n.roles.includes(userRole))
|
||||
const isSettingsActive = visibleSettings.some(n => pathname === n.path)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (settingsRef.current && !settingsRef.current.contains(e.target as Node))
|
||||
setSettingsOpen(false)
|
||||
}
|
||||
if (settingsOpen) document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [settingsOpen])
|
||||
|
||||
useEffect(() => { setSettingsOpen(false) }, [pathname])
|
||||
|
||||
const initials = userName
|
||||
? userName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
: '?'
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<header className='bg-white border-b border-gray-200 sticky top-0 z-30'>
|
||||
<div className='max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-2'>
|
||||
|
||||
{/* Logo */}
|
||||
<Link to='/dashboard' className='flex items-center gap-2 flex-shrink-0'>
|
||||
<div className='w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center'>
|
||||
<span className='text-white font-bold text-sm'>TM</span>
|
||||
</div>
|
||||
<span className='font-bold text-gray-800 hidden lg:block text-sm'>TimeMaster</span>
|
||||
</Link>
|
||||
|
||||
{/* Hauptnavigation */}
|
||||
<nav className='flex items-center gap-0.5 flex-1 overflow-x-auto'>
|
||||
{visibleMain.map(n => (
|
||||
<Link
|
||||
key={n.path}
|
||||
to={n.path}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
pathname === n.path
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Rechte Seite: Einstellungen + User + Abmelden */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
|
||||
{/* Zahnrad-Dropdown */}
|
||||
{visibleSettings.length > 0 && (
|
||||
<div ref={settingsRef} className='relative'>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(o => !o)}
|
||||
title='Einstellungen'
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isSettingsActive || settingsOpen
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75}
|
||||
d='M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' />
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75} d='M15 12a3 3 0 11-6 0 3 3 0 016 0z' />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className='absolute right-0 top-full mt-1.5 w-52 bg-white rounded-xl shadow-lg border border-gray-200 py-1.5 z-50'>
|
||||
<p className='px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wide'>Einstellungen</p>
|
||||
{visibleSettings.map((n, i) => {
|
||||
const divider = i > 0 && n.path === '/settings/caldav'
|
||||
return (
|
||||
<div key={n.path}>
|
||||
{divider && <div className='my-1 border-t border-gray-100' />}
|
||||
<Link
|
||||
to={n.path}
|
||||
className={`flex items-center px-4 py-2 text-sm transition-colors ${
|
||||
pathname === n.path
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Avatar → Profil */}
|
||||
<Link
|
||||
to='/profile'
|
||||
title={`${userName} · ${ROLE_LABELS[userRole] ?? userRole}`}
|
||||
className='flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-100 transition-colors'
|
||||
>
|
||||
<div className='w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0'>
|
||||
<span className='text-blue-700 font-bold text-xs'>{initials}</span>
|
||||
</div>
|
||||
<div className='hidden md:block text-left'>
|
||||
<p className='text-sm font-medium text-gray-800 leading-tight'>{userName}</p>
|
||||
<p className='text-xs text-gray-400 leading-tight'>{ROLE_LABELS[userRole] ?? userRole}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Abmelden */}
|
||||
<button
|
||||
onClick={logout}
|
||||
title='Abmelden'
|
||||
className='p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors'
|
||||
>
|
||||
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75}
|
||||
d='M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className='max-w-7xl mx-auto px-4 sm:px-6 py-8'>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export function Modal({ title, children, onClose, size = 'md' }: ModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4' onClick={onClose}>
|
||||
<div
|
||||
className={`bg-white rounded-2xl shadow-xl w-full ${SIZE_CLASS[size]} p-6 max-h-[90vh] overflow-auto`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-base font-semibold text-gray-800'>{title}</h2>
|
||||
<button onClick={onClose} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { token } = useAuth()
|
||||
return token ? <Outlet /> : <Navigate to='/login' replace />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function Spinner() {
|
||||
return (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<div className='animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import type {
|
||||
AbsenceTypeOut,
|
||||
AbsenceOut,
|
||||
UserListItem,
|
||||
VacationBalanceOut,
|
||||
OvertimeBalanceOut,
|
||||
} from '../../types/absence'
|
||||
|
||||
// ── BalanceEditModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BalanceEditModalProps {
|
||||
year: number
|
||||
balance: VacationBalanceOut
|
||||
balanceForm: { entitled_days: number; special_days: number; carried_over: number }
|
||||
setBalanceForm: React.Dispatch<React.SetStateAction<{ entitled_days: number; special_days: number; carried_over: number }>>
|
||||
balanceSaving: boolean
|
||||
onSave: () => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BalanceEditModal({
|
||||
year,
|
||||
balanceForm,
|
||||
setBalanceForm,
|
||||
balanceSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: BalanceEditModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Urlaubskonto {year} bearbeiten</h2>
|
||||
<div className='space-y-3'>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Grundurlaub (Tage)</span>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.entitled_days}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, entitled_days: Number(e.target.value) }))}
|
||||
className='mt-1 w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Sondertage (Tage)</span>
|
||||
<p className='text-xs text-gray-400 mb-1'>z.B. Betriebsurlaub, Jubiläum, individuelle Zusatztage</p>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.special_days}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, special_days: Number(e.target.value) }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Resturlaub aus {year - 1} (Tage)</span>
|
||||
<p className='text-xs text-gray-400 mb-1'>Wird automatisch aus dem Vorjahr berechnet</p>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.carried_over}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, carried_over: Number(e.target.value) }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<div className='bg-blue-50 rounded-lg p-3 text-sm text-blue-700'>
|
||||
Gesamt: <strong>{balanceForm.entitled_days + balanceForm.special_days + balanceForm.carried_over} Tage</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 pt-1'>
|
||||
<button
|
||||
disabled={balanceSaving}
|
||||
onClick={onSave}
|
||||
className='flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{balanceSaving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EditAbsenceModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface EditAbsenceModalProps {
|
||||
editAbsence: AbsenceOut
|
||||
editForm: {
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
correction_note: string
|
||||
}
|
||||
setEditForm: React.Dispatch<React.SetStateAction<{
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
correction_note: string
|
||||
}>>
|
||||
types: AbsenceTypeOut[]
|
||||
isManager: boolean
|
||||
submitting: boolean
|
||||
error: string
|
||||
onSave: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EditAbsenceModal({
|
||||
editAbsence,
|
||||
editForm,
|
||||
setEditForm,
|
||||
types,
|
||||
isManager,
|
||||
submitting,
|
||||
error,
|
||||
onSave,
|
||||
onClose,
|
||||
}: EditAbsenceModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
|
||||
<div className='px-6 py-4 border-b border-gray-200'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Antrag bearbeiten</h2>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
{editAbsence.status === 'approved' && !isManager && (
|
||||
<div className='bg-orange-50 border border-orange-200 rounded-lg p-3 text-sm text-orange-700'>
|
||||
Der Antrag wurde bereits genehmigt. Nach der Änderung wird er zur erneuten Genehmigung eingereicht.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Abwesenheitsart *</label>
|
||||
<select
|
||||
value={editForm.type_id}
|
||||
onChange={e => setEditForm(f => ({ ...f, type_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>Bitte wählen…</option>
|
||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={editForm.start_date}
|
||||
onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={editForm.end_date} min={editForm.start_date}
|
||||
onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={editForm.half_day_start}
|
||||
onChange={e => setEditForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={editForm.half_day_end}
|
||||
onChange={e => setEditForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
|
||||
<textarea
|
||||
value={editForm.note}
|
||||
onChange={e => setEditForm(f => ({ ...f, note: e.target.value }))}
|
||||
rows={2}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'
|
||||
/>
|
||||
</div>
|
||||
{editAbsence.status === 'approved' && !isManager && (
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-red-600 mb-1'>Änderungsgrund *</label>
|
||||
<input
|
||||
type='text' value={editForm.correction_note}
|
||||
onChange={e => setEditForm(f => ({ ...f, correction_note: e.target.value }))}
|
||||
placeholder='z. B. Urlaub wurde verkürzt'
|
||||
className='w-full border border-red-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400'
|
||||
/>
|
||||
<p className='text-xs text-gray-400 mt-1'>Wird im Änderungsprotokoll gespeichert.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={submitting}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting
|
||||
? 'Wird gespeichert…'
|
||||
: (editAbsence.status === 'approved' && !isManager ? 'Änderung einreichen' : 'Speichern')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CreateAbsenceModal ────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateAbsenceModalProps {
|
||||
form: {
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
}
|
||||
setForm: React.Dispatch<React.SetStateAction<{
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
}>>
|
||||
types: AbsenceTypeOut[]
|
||||
colleagues: UserListItem[]
|
||||
isManager: boolean
|
||||
submitting: boolean
|
||||
error: string
|
||||
overtimeBalance: OvertimeBalanceOut | null
|
||||
onCreate: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CreateAbsenceModal({
|
||||
form,
|
||||
setForm,
|
||||
types,
|
||||
colleagues,
|
||||
isManager,
|
||||
submitting,
|
||||
error,
|
||||
overtimeBalance,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: CreateAbsenceModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
|
||||
<div className='px-6 py-4 border-b border-gray-200'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Abwesenheitsantrag</h2>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
{isManager && (
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Mitarbeiter</label>
|
||||
<select
|
||||
value={form.for_user_id}
|
||||
onChange={e => setForm(f => ({ ...f, for_user_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>— Für mich selbst —</option>
|
||||
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Abwesenheitsart *</label>
|
||||
<select
|
||||
value={form.type_id}
|
||||
onChange={e => setForm(f => ({ ...f, type_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>Bitte wählen…</option>
|
||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{form.type_id && types.find(t => t.id === form.type_id)?.affects_overtime_balance && overtimeBalance !== null && (
|
||||
<p className='text-xs mt-1 text-purple-600'>
|
||||
Überstunden-Konto: <strong>{overtimeBalance.available_hours.toFixed(1)} h verfügbar</strong> (≈ {(overtimeBalance.available_hours / 8).toFixed(1)} Tage)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_start}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_end}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={e => setForm(f => ({ ...f, note: e.target.value }))}
|
||||
rows={2}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button onClick={onClose} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
disabled={submitting}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting ? 'Wird gesendet…' : 'Antrag stellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── QuickSickModal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuickSickModalProps {
|
||||
form: { start_date: string; end_date: string }
|
||||
setForm: React.Dispatch<React.SetStateAction<{ start_date: string; end_date: string }>>
|
||||
submitting: boolean
|
||||
error: string
|
||||
onSubmit: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuickSickModal({ form, setForm, submitting, error, onSubmit, onClose }: QuickSickModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-sm'>
|
||||
<div className='px-6 py-4 border-b border-gray-200 flex items-center gap-2'>
|
||||
<span className='text-2xl'>🤒</span>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Krank melden</h2>
|
||||
<p className='text-xs text-gray-500'>Wird sofort genehmigt — Attest-Frist wird automatisch berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button onClick={onClose} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitting || !form.start_date || !form.end_date}
|
||||
className='px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-medium hover:bg-orange-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting ? 'Wird gesendet…' : 'Krank melden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user