Initial commit – TimeMaster Zeiterfassung & HR-Tool

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
co-authored by Claude Sonnet 4.6
commit 45218cc744
177 changed files with 29145 additions and 0 deletions
+431
View File
@@ -0,0 +1,431 @@
# frontend Dev Log
## 2026-03-28 22:34 22:34 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** archivmail
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 22:42 23:11 (28m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:13 23:18 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:18 23:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:20 23:21 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:28 23:32 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:32 23:35 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:37 23:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:44 23:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:50 23:52 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:55 23:55 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-28 23:56 23:58 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-29 00:13 00:14 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-29 00:14 00:25 (10m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-29 00:27 00:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-03-29 00:29 00:29 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 23:38 23:43 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-06 23:44 23:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-05 19:54 22:28 (2h 33m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 10:25 10:27 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 10:28 10:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 10:35 10:37 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 10:39 10:41 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 11:20 11:21 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 12:47 12:47 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 12:47 15:57 (3h 09m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 15:58 16:00 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 23:13 23:16 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 23:19 23:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-06 23:25 23:25 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-07 00:21 00:22 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-07 00:24 00:24 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-07 00:24 00:25 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-07 00:27 00:27 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-07 10:06 10:07 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-11 22:48 22:56 (7m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-23 19:22 19:39 (17m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-23 19:56 19:58 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-05-23 20:00 20:00 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TimeMaster</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3139
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "timemaster-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.6.2",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+59
View File
@@ -0,0 +1,59 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { DashboardPage } from './pages/DashboardPage'
import { UsersPage } from './pages/UsersPage'
import { LdapSettingsPage } from './pages/LdapSettingsPage'
import { TimeTrackingPage } from './pages/TimeTrackingPage'
import { AbsencesPage } from './pages/AbsencesPage'
import { SmtpSettingsPage } from './pages/SmtpSettingsPage'
import { ForgotPasswordPage } from './pages/ForgotPasswordPage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { WorkSchedulePage } from './pages/WorkSchedulePage'
import { CalendarPage } from './pages/CalendarPage'
import { ReportsPage } from './pages/ReportsPage'
import { CaldavSettingsPage } from './pages/CaldavSettingsPage'
import { AbsenceTypesPage } from './pages/AbsenceTypesPage'
import ImportPage from './pages/ImportPage'
import UserImportPage from './pages/UserImportPage'
import { CompanySettingsPage } from './pages/CompanySettingsPage'
import { ProfilePage } from './pages/ProfilePage'
import { KioskDevicesPage } from './pages/KioskDevicesPage'
import { AuditLogPage } from './pages/AuditLogPage'
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path='/login' element={<LoginPage />} />
<Route path='/register' element={<RegisterPage />} />
<Route path='/forgot-password' element={<ForgotPasswordPage />} />
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
<Route element={<ProtectedRoute />}>
<Route path='/dashboard' element={<DashboardPage />} />
<Route path='/time' element={<TimeTrackingPage />} />
<Route path='/absences' element={<AbsencesPage />} />
<Route path='/users' element={<UsersPage />} />
<Route path='/users/import' element={<UserImportPage />} />
<Route path='/settings/ldap' element={<LdapSettingsPage />} />
<Route path='/settings/smtp' element={<SmtpSettingsPage />} />
<Route path='/work-schedules' element={<WorkSchedulePage />} />
<Route path='/calendar' element={<CalendarPage />} />
<Route path='/reports' element={<ReportsPage />} />
<Route path='/settings/caldav' element={<CaldavSettingsPage />} />
<Route path='/settings/absence-types' element={<AbsenceTypesPage />} />
<Route path='/settings/import' element={<ImportPage />} />
<Route path='/settings/company' element={<CompanySettingsPage />} />
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
<Route path='/settings/audit-log' element={<AuditLogPage />} />
<Route path='/profile' element={<ProfilePage />} />
</Route>
<Route path='*' element={<Navigate to='/login' replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
)
}
+52
View File
@@ -0,0 +1,52 @@
const BASE_URL = '/api/v1'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
const msg = typeof err.detail === 'string'
? err.detail
: Array.isArray(err.detail)
? err.detail.map((e: { msg: string }) => e.msg).join(', ')
: res.statusText
throw new Error(msg)
}
if (res.status === 204) return undefined as T
return res.json()
}
async function requestForm<T>(path: string, form: FormData): Promise<T> {
const token = localStorage.getItem('access_token')
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
// No Content-Type header browser sets multipart boundary automatically
const res = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: form, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
const msg = typeof err.detail === 'string'
? err.detail
: Array.isArray(err.detail)
? err.detail.map((e: { msg: string }) => e.msg).join(', ')
: res.statusText
throw new Error(msg)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
postForm: <T>(path: string, form: FormData) => requestForm<T>(path, form),
}
+184
View File
@@ -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>
)
}
+32
View File
@@ -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'>&times;</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 />
}
+7
View File
@@ -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>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useContext, useState, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api/client'
interface AuthContextType {
token: string | null
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType>(null!)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(
() => localStorage.getItem('access_token'),
)
const navigate = useNavigate()
async function login(email: string, password: string) {
const data = await api.post<{ access_token: string; refresh_token: string }>(
'/auth/login',
{ email, password },
)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
setToken(data.access_token)
navigate('/dashboard')
}
function logout() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setToken(null)
navigate('/login')
}
return (
<AuthContext.Provider value={{ token, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, useCallback, useEffect } from 'react'
import { api } from '../api/client'
import type {
UserOut,
AbsenceTypeOut,
AbsenceOut,
AbsenceListResponse,
UserListItem,
VacationBalanceOut,
OvertimeBalanceOut,
} from '../types/absence'
import { MANAGER_ROLES } from '../utils/calendar'
export function useAbsences(year: number, statusFilter: string) {
const [user, setUser] = useState<UserOut | null>(null)
const [types, setTypes] = useState<AbsenceTypeOut[]>([])
const [absences, setAbsences] = useState<AbsenceOut[]>([])
const [total, setTotal] = useState(0)
const [balance, setBalance] = useState<VacationBalanceOut | null>(null)
const [overtimeBalance, setOvertimeBalance] = useState<OvertimeBalanceOut | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [colleagues, setColleagues] = useState<UserListItem[]>([])
const colleagueMap = Object.fromEntries(colleagues.map(c => [c.id, c.full_name]))
const load = useCallback(async () => {
setLoading(true)
try {
const params = statusFilter ? `?status=${statusFilter}&year=${year}` : `?year=${year}`
const [me, typeList, absList, bal, otBal] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<AbsenceTypeOut[]>('/absence-types/'),
api.get<AbsenceListResponse>(`/absences/${params}`),
api.get<VacationBalanceOut>(`/absences/balance?year=${year}`),
api.get<OvertimeBalanceOut>('/absences/overtime-balance'),
])
setUser(me)
setTypes(typeList.filter(t => t.is_active))
setAbsences(absList.items)
setTotal(absList.total)
setBalance(bal)
setOvertimeBalance(otBal)
if (MANAGER_ROLES.includes(me.role) && colleagues.length === 0) {
try {
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
setColleagues(res.items)
} catch { /* ignore */ }
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [statusFilter, year]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { load() }, [load])
const createAbsence = async (
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string },
onSuccess: () => void,
setSubmitting: (v: boolean) => void,
) => {
if (!form.type_id || !form.start_date || !form.end_date) {
setError('Bitte alle Pflichtfelder ausfüllen')
return
}
setSubmitting(true)
setError('')
try {
await api.post('/absences/', {
type_id: form.type_id,
start_date: form.start_date,
end_date: form.end_date,
half_day_start: form.half_day_start,
half_day_end: form.half_day_end,
note: form.note || null,
for_user_id: form.for_user_id || null,
})
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
} finally {
setSubmitting(false)
}
}
const approve = async (id: string) => {
setError('')
try { await api.post(`/absences/${id}/approve`, {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
const reject = async (id: string, rejectReason: string, onSuccess: () => void) => {
if (!rejectReason.trim()) return
setError('')
try {
await api.post(`/absences/${id}/reject`, { rejection_reason: rejectReason })
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
}
}
const saveEdit = async (
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 },
isManager: boolean,
onSuccess: () => void,
setSubmitting: (v: boolean) => void,
) => {
if (!editForm.type_id || !editForm.start_date || !editForm.end_date) {
setError('Bitte alle Pflichtfelder ausfüllen')
return
}
if (editAbsence.status === 'approved' && !isManager && !editForm.correction_note.trim()) {
setError('Änderungsgrund ist Pflicht bei genehmigten Anträgen.')
return
}
setSubmitting(true)
setError('')
try {
await api.patch(`/absences/${editAbsence.id}`, {
type_id: editForm.type_id,
start_date: editForm.start_date,
end_date: editForm.end_date,
half_day_start: editForm.half_day_start,
half_day_end: editForm.half_day_end,
note: editForm.note || null,
correction_note: editForm.correction_note.trim() || null,
})
onSuccess()
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSubmitting(false)
}
}
const cancel = async (id: string) => {
setError('')
try { await api.del(`/absences/${id}`); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
const loadColleaguesIfNeeded = async () => {
if (colleagues.length === 0) {
try {
const res = await api.get<{ items: UserListItem[] }>('/users/?limit=500')
setColleagues(res.items)
} catch { /* ignore */ }
}
}
const typeName = (typeId: string) => types.find(t => t.id === typeId)?.name ?? typeId
const typeColor = (typeId: string) => types.find(t => t.id === typeId)?.color ?? '#6B7280'
const updateBalance = (updated: VacationBalanceOut) => setBalance(updated)
return {
user,
types,
absences,
total,
balance,
overtimeBalance,
loading,
error,
setError,
colleagues,
colleagueMap,
load,
createAbsence,
approve,
reject,
saveEdit,
cancel,
loadColleaguesIfNeeded,
typeName,
typeColor,
updateBalance,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useCallback, useEffect } from 'react'
import { api } from '../api/client'
import type { UserOut, UserListItem, VacationBalanceOut } from '../types/absence'
import { MANAGER_ROLES } from '../utils/calendar'
export function usePlanerView(user: UserOut | null, colleagues: UserListItem[], year: number) {
const [viewMode, setViewMode] = useState<'liste' | 'planer'>('liste')
const [planMonth, setPlanMonth] = useState(new Date().getMonth())
const [colleagueBalances, setColleagueBalances] = useState<Record<string, VacationBalanceOut>>({})
const [balancesLoaded, setBalancesLoaded] = useState(false)
const [showYearGrid, setShowYearGrid] = useState<boolean>(() => {
try { return localStorage.getItem('planer_showYearGrid') !== 'false' }
catch { return true }
})
useEffect(() => {
try { localStorage.setItem('planer_showYearGrid', String(showYearGrid)) }
catch { /* ignore */ }
}, [showYearGrid])
const loadColleagueBalances = useCallback(async (list: UserListItem[]) => {
if (list.length === 0 || balancesLoaded) return
try {
const results = await Promise.allSettled(
list.map(c => api.get<VacationBalanceOut>(`/absences/balance/${c.id}?year=${year}`))
)
const map: Record<string, VacationBalanceOut> = {}
results.forEach((r, i) => { if (r.status === 'fulfilled') map[list[i].id] = r.value })
setColleagueBalances(map)
setBalancesLoaded(true)
} catch { /* ignore */ }
}, [year, balancesLoaded])
useEffect(() => {
if (viewMode === 'planer' && user && MANAGER_ROLES.includes(user.role) && colleagues.length > 0 && !balancesLoaded) {
loadColleagueBalances(colleagues)
}
}, [viewMode, user, colleagues, balancesLoaded, loadColleagueBalances])
return {
viewMode,
setViewMode,
planMonth,
setPlanMonth,
colleagueBalances,
balancesLoaded,
showYearGrid,
setShowYearGrid,
}
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+507
View File
@@ -0,0 +1,507 @@
import { useEffect, useState, useCallback } 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
}
interface AbsenceTypeOut {
id: string
company_id: string
name: string
color: string
requires_approval: boolean
deducts_vacation: boolean
is_paid: boolean
max_days_per_year: number | null
is_active: boolean
}
interface AbsenceTypeCreate {
name: string
color: string
requires_approval: boolean
deducts_vacation: boolean
is_paid: boolean
max_days_per_year: number | null
}
interface AbsenceTypeUpdate {
name?: string
color?: string
requires_approval?: boolean
deducts_vacation?: boolean
is_paid?: boolean
max_days_per_year?: number | null
is_active?: boolean
}
const ADMIN_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN']
const DEFAULT_FORM: AbsenceTypeCreate = {
name: '',
color: '#3B82F6',
requires_approval: true,
deducts_vacation: false,
is_paid: true,
max_days_per_year: null,
}
export function AbsenceTypesPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [types, setTypes] = useState<AbsenceTypeOut[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Modal state
const [modalOpen, setModalOpen] = useState(false)
const [editTarget, setEditTarget] = useState<AbsenceTypeOut | null>(null)
const [form, setForm] = useState<AbsenceTypeCreate>({ ...DEFAULT_FORM })
const [formError, setFormError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const isAdmin = user !== null && ADMIN_ROLES.includes(user.role)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [me, list] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<AbsenceTypeOut[]>('/absence-types/'),
])
setUser(me)
setTypes(list)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// ── Modal helpers ───────────────────────────────────────────────────────────
function openCreate() {
setEditTarget(null)
setForm({ ...DEFAULT_FORM })
setFormError(null)
setModalOpen(true)
}
function openEdit(type: AbsenceTypeOut) {
setEditTarget(type)
setForm({
name: type.name,
color: type.color,
requires_approval: type.requires_approval,
deducts_vacation: type.deducts_vacation,
is_paid: type.is_paid,
max_days_per_year: type.max_days_per_year,
})
setFormError(null)
setModalOpen(true)
}
function closeModal() {
setModalOpen(false)
setEditTarget(null)
setFormError(null)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.name.trim()) {
setFormError('Name ist erforderlich.')
return
}
if (!/^#[0-9A-Fa-f]{6}$/.test(form.color)) {
setFormError('Farbe muss im Format #RRGGBB angegeben werden.')
return
}
setSaving(true)
setFormError(null)
try {
if (editTarget) {
const updated = await api.patch<AbsenceTypeOut>(
`/absence-types/${editTarget.id}`,
form as AbsenceTypeUpdate,
)
setTypes(prev => prev.map(t => (t.id === updated.id ? updated : t)))
} else {
const created = await api.post<AbsenceTypeOut>('/absence-types/', form)
setTypes(prev => [...prev, created])
}
closeModal()
} catch (e) {
setFormError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
async function toggleActive(type: AbsenceTypeOut) {
try {
const updated = await api.patch<AbsenceTypeOut>(
`/absence-types/${type.id}`,
{ is_active: !type.is_active } satisfies AbsenceTypeUpdate,
)
setTypes(prev => prev.map(t => (t.id === updated.id ? updated : t)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Aktualisierung fehlgeschlagen')
}
}
// ── Render ──────────────────────────────────────────────────────────────────
if (loading) {
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
</Layout>
)
}
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Abwesenheitstypen</h1>
<p className="text-sm text-gray-500 mt-1">
Verwalten Sie die Abwesenheitskategorien Ihres Unternehmens.
</p>
</div>
{isAdmin && (
<button
onClick={openCreate}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<span className="text-lg leading-none">+</span>
Abwesenheitstyp
</button>
)}
</div>
{/* Error banner */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{types.length === 0 ? (
<div className="py-16 text-center text-gray-400 text-sm">
Keine Abwesenheitstypen vorhanden.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 w-8">Farbe</th>
<th className="px-4 py-3 font-semibold text-gray-600">Name</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-center">
Genehmigung erforderlich
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-center">
Urlaubsabzug
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-center">
Bezahlt
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-center">
Max. Tage/Jahr
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-center">
Status
</th>
{isAdmin && (
<th className="px-4 py-3 font-semibold text-gray-600 text-right">
Aktionen
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{types.map(type => (
<tr
key={type.id}
className={`hover:bg-gray-50 transition-colors ${
!type.is_active ? 'opacity-50' : ''
}`}
>
{/* Color dot */}
<td className="px-4 py-3">
<span
className="inline-block w-4 h-4 rounded-full border border-black/10"
style={{ backgroundColor: type.color }}
/>
</td>
{/* Name */}
<td className="px-4 py-3 font-medium text-gray-900">{type.name}</td>
{/* Requires approval */}
<td className="px-4 py-3 text-center">
{type.requires_approval ? (
<span className="inline-block px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">
Ja
</span>
) : (
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full text-xs font-medium">
Nein
</span>
)}
</td>
{/* Deducts vacation */}
<td className="px-4 py-3 text-center">
{type.deducts_vacation ? (
<span className="inline-block px-2 py-0.5 bg-orange-100 text-orange-700 rounded-full text-xs font-medium">
Ja
</span>
) : (
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full text-xs font-medium">
Nein
</span>
)}
</td>
{/* Is paid */}
<td className="px-4 py-3 text-center">
{type.is_paid ? (
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Ja
</span>
) : (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-600 rounded-full text-xs font-medium">
Nein
</span>
)}
</td>
{/* Max days */}
<td className="px-4 py-3 text-center text-gray-700">
{type.max_days_per_year !== null ? type.max_days_per_year : '—'}
</td>
{/* Active toggle */}
<td className="px-4 py-3 text-center">
{isAdmin ? (
<button
onClick={() => toggleActive(type)}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
type.is_active
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{type.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
) : (
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
type.is_active
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{type.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
)}
</td>
{/* Edit action */}
{isAdmin && (
<td className="px-4 py-3 text-right">
<button
onClick={() => openEdit(type)}
className="text-blue-600 hover:text-blue-800 text-xs font-medium transition-colors"
>
Bearbeiten
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* ── Create / Edit Modal ─────────────────────────────────────────────── */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
{/* Modal header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{editTarget ? 'Abwesenheitstyp bearbeiten' : 'Neuer Abwesenheitstyp'}
</h2>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 transition-colors text-xl leading-none"
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{formError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{formError}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="z. B. Urlaub"
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"
required
/>
</div>
{/* Color */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Farbe</label>
<div className="flex items-center gap-3">
<input
type="color"
value={form.color}
onChange={e => setForm(f => ({ ...f, color: e.target.value }))}
className="h-9 w-14 cursor-pointer rounded border border-gray-300 p-0.5"
/>
<input
type="text"
value={form.color}
onChange={e => setForm(f => ({ ...f, color: e.target.value }))}
placeholder="#3B82F6"
maxLength={7}
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Max days per year */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max. Tage pro Jahr{' '}
<span className="text-gray-400 font-normal">(leer = unbegrenzt)</span>
</label>
<input
type="number"
min={1}
value={form.max_days_per_year ?? ''}
onChange={e =>
setForm(f => ({
...f,
max_days_per_year: e.target.value === '' ? null : Number(e.target.value),
}))
}
placeholder="z. B. 30"
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>
{/* Boolean flags */}
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-gray-700 mb-1">Eigenschaften</legend>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.requires_approval}
onChange={e => setForm(f => ({ ...f, requires_approval: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Genehmigung erforderlich</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.deducts_vacation}
onChange={e => setForm(f => ({ ...f, deducts_vacation: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Urlaubsabzug (zählt vom Urlaubskonto ab)</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.is_paid}
onChange={e => setForm(f => ({ ...f, is_paid: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Bezahlt</span>
</label>
{/* is_active only available when editing */}
{editTarget && (
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={(form as AbsenceTypeUpdate).is_active ?? editTarget.is_active}
onChange={e =>
setForm(f => ({ ...f, is_active: e.target.checked } as AbsenceTypeCreate))
}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Aktiv</span>
</label>
)}
</fieldset>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={closeModal}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{saving && <Spinner />}
{editTarget ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
)}
</Layout>
)
}
+799
View File
@@ -0,0 +1,799 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import type { AbsenceOut, AbsenceSpan, VacationBalanceOut } from '../types/absence'
import {
MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES,
} from '../utils/calendar'
import { useAbsences } from '../hooks/useAbsences'
import { usePlanerView } from '../hooks/usePlanerView'
import {
BalanceEditModal,
EditAbsenceModal,
CreateAbsenceModal,
QuickSickModal,
} from '../components/absences/AbsenceModals'
// ── Component ─────────────────────────────────────────────────────────────────
export function AbsencesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [showCreate, setShowCreate] = useState(false)
const [showQuickSick, setShowQuickSick] = useState(false)
const todayIso = new Date().toISOString().slice(0, 10)
const [quickSickForm, setQuickSickForm] = useState({ start_date: todayIso, end_date: todayIso })
const [quickSubmitting, setQuickSubmitting] = useState(false)
const [quickError, setQuickError] = useState('')
const [showReject, setShowReject] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') ?? '')
const [submitting, setSubmitting] = useState(false)
const [showBalanceEdit, setShowBalanceEdit] = useState(false)
const [balanceForm, setBalanceForm] = useState({ entitled_days: 0, special_days: 0, carried_over: 0 })
const [balanceSaving, setBalanceSaving] = useState(false)
const [editAbsence, setEditAbsence] = useState<AbsenceOut | null>(null)
const [editForm, setEditForm] = useState({
type_id: '', start_date: '', end_date: '',
half_day_start: false, half_day_end: false,
note: '', correction_note: '',
})
const [form, setForm] = useState({
type_id: '', start_date: '', end_date: '',
half_day_start: false, half_day_end: false,
note: '', for_user_id: '',
})
const year = new Date().getFullYear()
const {
user, types, absences, total, balance, overtimeBalance,
loading, error, setError, colleagues, colleagueMap,
createAbsence, approve, reject, saveEdit, cancel,
loadColleaguesIfNeeded, typeName, typeColor, updateBalance, load,
} = useAbsences(year, statusFilter)
const isSickType = (typeId: string) => types.find(t => t.id === typeId)?.category === 'sick'
// HR/COMPANY_ADMIN/SUPER_ADMIN dürfen Attest-Eingang markieren MANAGER nicht.
const canMarkCertificate = !!user && ['HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role)
const todayDate = todayIso
const handleQuickSick = async () => {
setQuickSubmitting(true)
setQuickError('')
try {
await api.post('/absences/quick-sick', quickSickForm)
setShowQuickSick(false)
setQuickSickForm({ start_date: todayIso, end_date: todayIso })
await load()
} catch (e: any) {
setQuickError(e?.message ?? 'Krankmeldung fehlgeschlagen.')
} finally {
setQuickSubmitting(false)
}
}
const markCertificate = async (absenceId: string) => {
try {
await api.patch(`/absences/${absenceId}/certificate`, {})
await load()
} catch (e: any) {
setError(e?.message ?? 'Attest konnte nicht markiert werden.')
}
}
const {
viewMode, setViewMode, planMonth, setPlanMonth,
colleagueBalances, showYearGrid, setShowYearGrid,
} = usePlanerView(user, colleagues, year)
const isManager = user ? MANAGER_ROLES.includes(user.role) : false
const openEdit = (a: AbsenceOut) => {
setEditAbsence(a)
setEditForm({
type_id: a.type_id,
start_date: a.start_date,
end_date: a.end_date,
half_day_start: a.half_day_start,
half_day_end: a.half_day_end,
note: a.note ?? '',
correction_note: '',
})
setError('')
}
const openCreate = async () => {
setShowCreate(true)
setError('')
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
if (isManager) await loadColleaguesIfNeeded()
}
const handleReject = async (id: string) => {
await reject(id, rejectReason, () => { setShowReject(null); setRejectReason('') })
}
const handleSaveEdit = async () => {
if (!editAbsence) return
await saveEdit(editAbsence, editForm, isManager, () => setEditAbsence(null), setSubmitting)
}
const handleCreate = async () => {
await createAbsence(form, () => {
setShowCreate(false)
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
}, setSubmitting)
}
const handleSaveBalance = async () => {
if (!balance) return
setBalanceSaving(true)
try {
const updated = await api.patch<VacationBalanceOut>(`/absences/balance/${balance.user_id}?year=${year}`, balanceForm)
updateBalance(updated)
setShowBalanceEdit(false)
} catch { /* ignore */ }
finally { setBalanceSaving(false) }
}
// Build absence spans from local data
const absenceSpans: AbsenceSpan[] = absences.map(a => ({
id: a.id,
userId: a.user_id,
userName: colleagueMap[a.user_id] ?? (user?.id === a.user_id ? `${user.first_name} ${user.last_name}` : a.user_id),
typeName: typeName(a.type_id),
color: typeColor(a.type_id),
start: new Date(a.start_date),
end: new Date(a.end_date),
status: a.status,
isHalfStart: a.half_day_start,
isHalfEnd: a.half_day_end,
}))
const absencesInMonth = (month: number) => {
const first = new Date(year, month, 1)
const last = new Date(year, month + 1, 0)
return absences.filter(a => new Date(a.start_date) <= last && new Date(a.end_date) >= first)
}
const buildAbsenceListHtml = () => {
const sorted = [...absences]
.filter(a => a.status !== 'cancelled')
.sort((a, b) => a.start_date.localeCompare(b.start_date))
const userName = user ? `${user.first_name} ${user.last_name}` : ''
const managerHeader = isManager ? '<th>Mitarbeiter</th>' : ''
const balanceRow = balance ? `
<div style="display:flex;gap:24px;margin-bottom:20px;padding:12px 16px;background:#f9fafb;border-radius:8px;font-size:13px">
<div><span style="color:#6b7280">Grundurlaub:</span> <strong>${balance.entitled_days} Tage</strong></div>
${balance.special_days > 0 ? `<div><span style="color:#6b7280">Sondertage:</span> <strong>+${balance.special_days} Tage</strong></div>` : ''}
${balance.carried_over > 0 ? `<div><span style="color:#6b7280">Resturlaub ${year - 1}:</span> <strong>+${balance.carried_over} Tage</strong></div>` : ''}
<div><span style="color:#6b7280">Gesamt:</span> <strong>${balance.total_days} Tage</strong></div>
<div><span style="color:#6b7280">Verbleibend:</span> <strong style="color:${balance.remaining_days > 5 ? '#16a34a' : balance.remaining_days > 0 ? '#d97706' : '#dc2626'}">${balance.remaining_days} Tage</strong></div>
</div>` : ''
const rows = sorted.map(a => {
const name = isManager ? `<td>${colleagueMap[a.user_id] ?? ''}</td>` : ''
const statusColor = a.status === 'approved' ? '#16a34a' : a.status === 'pending' ? '#d97706' : '#dc2626'
return `<tr>
${name}
<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${typeColor(a.type_id)};margin-right:6px;vertical-align:middle"></span>${typeName(a.type_id)}</td>
<td>${new Date(a.start_date).toLocaleDateString('de-DE')}</td>
<td>${new Date(a.end_date).toLocaleDateString('de-DE')}</td>
<td style="text-align:right">${a.working_days}</td>
<td style="color:${statusColor}">${STATUS_LABELS[a.status] ?? a.status}</td>
${a.note ? `<td style="color:#6b7280;font-size:12px">${a.note}</td>` : '<td></td>'}
</tr>`
}).join('')
const html = `<!DOCTYPE html><html><head>
<meta charset="UTF-8">
<title>Urlaubsliste ${userName} ${year}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 28px; color: #111; }
h1 { font-size: 20px; margin: 0 0 2px; }
p.meta { font-size: 12px; color: #888; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: #f3f4f6; padding: 8px 12px; text-align: left; font-weight: 600; border-top: 2px solid #e5e7eb; border-bottom: 2px solid #e5e7eb; }
td { padding: 7px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
@media print { body { padding: 10px; } }
</style>
</head><body>
<h1>Urlaubsliste ${year}${!isManager ? ` ${userName}` : ''}</h1>
<p class="meta">Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</p>
${!isManager ? balanceRow : ''}
${sorted.length === 0
? '<p style="color:#9ca3af;text-align:center;padding:32px">Keine Einträge.</p>'
: `<table>
<thead><tr>${managerHeader}<th>Abwesenheitsart</th><th>Von</th><th>Bis</th><th style="text-align:right">Tage</th><th>Status</th><th>Notiz</th></tr></thead>
<tbody>${rows}</tbody>
</table>`}
</body></html>`
return html
}
const buildMonthHtml = () => {
const list = absencesInMonth(planMonth)
.filter(a => a.status !== 'cancelled' && a.status !== 'rejected')
.sort((a, b) => a.start_date.localeCompare(b.start_date))
const monthName = MONTHS[planMonth]
const managerHeader = isManager ? '<th>Mitarbeiter</th>' : ''
const remainingHeader = isManager ? '<th style="text-align:right">Resturlaub</th>' : ''
const rows = list.map(a => {
const name = isManager ? `<td>${colleagueMap[a.user_id] ?? ''}</td>` : ''
const bal = colleagueBalances[a.user_id]
const remaining = isManager
? `<td style="text-align:right;${bal && bal.remaining_days <= 0 ? 'color:#dc2626;font-weight:700' : ''}">${bal != null ? bal.remaining_days + ' Tage' : ''}</td>`
: ''
const statusColor = a.status === 'approved' ? '#16a34a' : '#d97706'
return `<tr>${name}<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${typeColor(a.type_id)};margin-right:6px;vertical-align:middle"></span>${typeName(a.type_id)}</td><td>${new Date(a.start_date).toLocaleDateString('de-DE')}</td><td>${new Date(a.end_date).toLocaleDateString('de-DE')}</td><td style="text-align:right">${a.working_days}</td><td style="color:${statusColor}">${STATUS_LABELS[a.status] ?? a.status}</td>${remaining}</tr>`
}).join('')
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Urlaubsplaner ${monthName} ${year}</title><style>body{font-family:-apple-system,sans-serif;padding:28px;color:#111}h1{font-size:22px;margin:0 0 4px}p.meta{font-size:12px;color:#888;margin:0 0 20px}table{width:100%;border-collapse:collapse;font-size:13px}th{background:#f3f4f6;padding:8px 12px;text-align:left;font-weight:600;border-top:2px solid #e5e7eb;border-bottom:2px solid #e5e7eb}td{padding:7px 12px;border-bottom:1px solid #f0f0f0;vertical-align:middle}@media print{body{padding:10px}}</style></head><body><h1>Urlaubsplaner ${monthName} ${year}</h1><p class="meta">Stand: ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</p>${list.length === 0 ? '<p style="color:#9ca3af;text-align:center;padding:32px">Keine Abwesenheiten in diesem Monat.</p>' : `<table><thead><tr>${managerHeader}<th>Abwesenheitsart</th><th>Von</th><th>Bis</th><th style="text-align:right">Tage</th><th>Status</th>${remainingHeader}</tr></thead><tbody>${rows}</tbody></table>`}</body></html>`
}
const openPdf = (html: string) => {
const w = window.open('', '_blank', 'width=960,height=720')
if (w) { w.document.write(html); w.document.close() }
}
const openPrint = (html: string) => {
const w = window.open('', '_blank', 'width=960,height=720')
if (w) { w.document.write(html); w.document.close(); setTimeout(() => { w.focus(); w.print() }, 250) }
}
if (loading) return (
<Layout userRole='' userName=''>
<div className='flex justify-center py-20'><Spinner /></div>
</Layout>
)
if (!user) return null
const today = new Date()
const todayStr = today.toDateString()
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold text-gray-900'>Abwesenheiten</h1>
<div className='flex gap-2'>
<button
onClick={() => { setQuickError(''); setShowQuickSick(true) }}
className='px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-medium hover:bg-orange-700 transition-colors'
>
🤒 Krank melden
</button>
<button onClick={openCreate} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors'>
+ Antrag stellen
</button>
</div>
</div>
{/* Tabs */}
<div className='flex items-center gap-3 flex-wrap'>
<div className='flex gap-1 bg-gray-100 rounded-lg p-1 w-fit'>
{(['liste', 'planer'] as const).map(v => (
<button key={v} onClick={() => setViewMode(v)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${viewMode === v ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
{v === 'liste' ? 'Liste' : 'Jahresplaner'}
</button>
))}
</div>
{viewMode === 'planer' && isManager && (
<button
onClick={() => setShowYearGrid(v => !v)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm border transition-colors ${
showYearGrid
? 'bg-blue-50 border-blue-200 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
title={showYearGrid ? 'Jahresübersicht ausblenden' : 'Jahresübersicht einblenden'}
>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
{showYearGrid
? <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M3 10h18M3 14h18M10 3v18M14 3v18' />
: <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 6h16M4 12h16M4 18h16' />
}
</svg>
{showYearGrid ? 'Jahresübersicht' : 'Jahresübersicht'}
</button>
)}
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{/* ─── LISTE ─────────────────────────────────────────────────────────── */}
{viewMode === 'liste' && (<>
{balance && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<div className='flex items-center justify-between mb-4'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide'>Urlaubskonto {year}</h2>
{isManager && (
<button onClick={() => { setBalanceForm({ entitled_days: balance.entitled_days, special_days: balance.special_days, carried_over: balance.carried_over }); setShowBalanceEdit(true) }}
className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
)}
</div>
<div className='mb-4 p-3 bg-gray-50 rounded-lg space-y-1.5'>
<div className='flex justify-between text-sm'><span className='text-gray-500'>Grundurlaub</span><span className='font-medium text-gray-800'>{balance.entitled_days} Tage</span></div>
{balance.special_days > 0 && <div className='flex justify-between text-sm'><span className='text-gray-500'>Sondertage</span><span className='font-medium text-blue-700'>+{balance.special_days} Tage</span></div>}
{balance.carried_over > 0 && (
<div className='space-y-0.5'>
<div className='flex justify-between text-sm'>
<span className={balance.carried_over_expired ? 'text-red-500 line-through' : 'text-gray-500'}>
Resturlaub {year - 1}
</span>
<span className={`font-medium ${balance.carried_over_expired ? 'text-red-400 line-through' : 'text-purple-700'}`}>
+{balance.carried_over} Tage
</span>
</div>
{balance.carried_over_expires_at && (
<div className={`text-xs px-2 py-0.5 rounded ${balance.carried_over_expired ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-700'}`}>
{balance.carried_over_expired
? `Verfallen am ${new Date(balance.carried_over_expires_at).toLocaleDateString('de-DE')}`
: `Verfällt am ${new Date(balance.carried_over_expires_at).toLocaleDateString('de-DE')}`}
</div>
)}
</div>
)}
<div className='flex justify-between text-sm border-t border-gray-200 pt-1.5'><span className='font-medium text-gray-700'>Gesamt</span><span className='font-bold text-gray-900'>{balance.total_days} Tage</span></div>
</div>
<div className='mb-4'>
<div className='flex justify-between text-xs text-gray-500 mb-1'>
<span>{balance.used_days} genommen{balance.pending_days > 0 ? ` · ${balance.pending_days} beantragt` : ''}</span>
<span>{balance.total_days} gesamt</span>
</div>
<div className='h-2 bg-gray-100 rounded-full overflow-hidden flex'>
<div className='h-full bg-green-500' style={{ width: `${Math.min(100, balance.used_days / balance.total_days * 100)}%` }} />
{balance.pending_days > 0 && <div className='h-full bg-yellow-400' style={{ width: `${Math.min(100 - balance.used_days / balance.total_days * 100, balance.pending_days / balance.total_days * 100)}%` }} />}
</div>
</div>
<div className='grid grid-cols-3 gap-3'>
<div><p className='text-xs text-gray-500'>Genommen</p><p className='text-xl font-bold text-gray-800'>{balance.used_days}</p></div>
{balance.pending_days > 0 && <div><p className='text-xs text-gray-500'>Beantragt</p><p className='text-xl font-bold text-yellow-600'>{balance.pending_days}</p></div>}
<div>
<p className='text-xs text-gray-500'>Verbleibend</p>
<p className={`text-xl font-bold ${balance.remaining_days > 5 ? 'text-green-600' : balance.remaining_days > 0 ? 'text-yellow-600' : 'text-red-600'}`}>{balance.remaining_days}</p>
</div>
</div>
</div>
)}
{overtimeBalance !== null && overtimeBalance.total_hours > 0 && (
<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'>Überstunden-Konto</h2>
<div className='grid grid-cols-3 gap-4'>
<div><p className='text-xs text-gray-500'>Angesammelt</p><p className='text-2xl font-bold text-gray-800'>{overtimeBalance.total_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
<div><p className='text-xs text-gray-500'>Genommen</p><p className='text-2xl font-bold text-gray-800'>{overtimeBalance.taken_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
<div><p className='text-xs text-gray-500'>Verfügbar</p><p className={`text-2xl font-bold ${overtimeBalance.available_hours > 0 ? 'text-green-600' : 'text-red-600'}`}>{overtimeBalance.available_hours.toFixed(1)} <span className='text-sm font-normal text-gray-500'>h</span></p></div>
</div>
</div>
)}
<div className='flex gap-2 flex-wrap'>
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${statusFilter === val ? 'bg-blue-600 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
<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'>Anträge {year}</h2>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-400'>{total} gesamt</span>
<button onClick={() => openPrint(buildAbsenceListHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Drucken'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z' />
</svg>
Drucken
</button>
<button onClick={() => openPdf(buildAbsenceListHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Als PDF speichern'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' />
</svg>
PDF
</button>
</div>
</div>
<div className='divide-y divide-gray-50'>
{absences.length === 0 ? (
<p className='text-center text-gray-400 py-8 text-sm'>Keine Einträge</p>
) : absences.map(a => (
<div key={a.id} className='px-6 py-4'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-3'>
<div className='w-3 h-3 rounded-full mt-1.5 flex-shrink-0' style={{ backgroundColor: typeColor(a.type_id) }} />
<div>
<p className='text-sm font-medium text-gray-800'>{typeName(a.type_id)}</p>
{isManager && a.user_id !== user?.id && <p className='text-xs font-medium text-blue-600 mb-0.5'>{colleagueMap[a.user_id] ?? a.user_id}</p>}
<p className='text-xs text-gray-500'>
{new Date(a.start_date).toLocaleDateString('de-DE')} {new Date(a.end_date).toLocaleDateString('de-DE')}
{' · '}{a.working_days} Arbeitstag{a.working_days !== 1 ? 'e' : ''}
</p>
{a.note && <p className='text-xs text-gray-400 mt-0.5'>{a.note}</p>}
{a.correction_note && <p className='text-xs text-orange-500 mt-0.5'> {a.correction_note}</p>}
{a.rejection_reason && <p className='text-xs text-red-500 mt-0.5'>Abgelehnt: {a.rejection_reason}</p>}
</div>
</div>
<div className='flex items-center gap-2 flex-shrink-0'>
{isSickType(a.type_id) && a.certificate_received_at && (
<span className='text-xs px-2 py-0.5 rounded-full font-medium bg-green-100 text-green-700' title={`Eingegangen am ${new Date(a.certificate_received_at).toLocaleDateString('de-DE')}`}>
Attest
</span>
)}
{isSickType(a.type_id) && !a.certificate_received_at && a.certificate_required_by && a.certificate_required_by < todayDate && (
<span className='text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700' title={`Fällig seit ${new Date(a.certificate_required_by).toLocaleDateString('de-DE')}`}>
AU überfällig
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[a.status] ?? 'bg-gray-100 text-gray-500'}`}>{STATUS_LABELS[a.status] ?? a.status}</span>
{canMarkCertificate && isSickType(a.type_id) && !a.certificate_received_at && (
<button
onClick={() => markCertificate(a.id)}
className='text-xs px-2 py-1 border border-green-300 text-green-700 rounded hover:bg-green-50'
title='Attest als eingegangen markieren'
>
Attest erhalten
</button>
)}
{(a.status === 'pending' || a.status === 'approved') && (isManager || a.user_id === user.id) && (
<button onClick={() => openEdit(a)} className={`text-xs px-2 py-1 border rounded ${a.status === 'approved' && !isManager ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : 'border-blue-300 text-blue-600 hover:bg-blue-50'}`}></button>
)}
{isManager && a.status === 'pending' && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700'>Genehmigen</button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
</>)}
{!isManager && a.status === 'pending' && a.user_id === user.id && (
<button onClick={() => cancel(a.id)} className='text-xs px-2 py-1 border border-gray-300 text-gray-600 rounded hover:bg-gray-50'>Stornieren</button>
)}
</div>
</div>
{showReject === a.id && (
<div className='mt-3 flex gap-2'>
<input type='text' placeholder='Ablehnungsgrund (Pflicht)' value={rejectReason} onChange={e => setRejectReason(e.target.value)}
className='flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-400' />
<button onClick={() => handleReject(a.id)} disabled={!rejectReason.trim()} className='px-3 py-1.5 bg-red-600 text-white text-sm rounded disabled:opacity-50'>Senden</button>
<button onClick={() => setShowReject(null)} className='px-3 py-1.5 border border-gray-300 text-gray-600 text-sm rounded'>Abbrechen</button>
</div>
)}
</div>
))}
</div>
</div>
</>)}
{/* ─── JAHRESPLANER ──────────────────────────────────────────────────── */}
{viewMode === 'planer' && (
<div className='space-y-4'>
{/* Jahresleiste */}
{(showYearGrid || !isManager) && (() => {
const allYearSpans = absenceSpans.filter(s => s.status !== 'cancelled' && s.status !== 'rejected')
const yearUserIds = isManager
? [...new Set([user!.id, ...allYearSpans.map(s => s.userId)])]
: [user!.id]
const sortedUserIds = [...yearUserIds].sort((a, b) => {
if (a === user!.id) return -1
if (b === user!.id) return 1
return (colleagueMap[a] ?? '').localeCompare(colleagueMap[b] ?? '', 'de')
})
return (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-4'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3'>Jahresübersicht {year}</p>
<div className='overflow-x-auto'>
<table className='w-full text-sm border-collapse'>
<thead>
<tr>
<th className='sticky left-0 bg-white z-10 w-28 min-w-28 text-left py-2 px-3 text-xs font-semibold text-gray-400 border-b border-gray-100'>
{year}
</th>
{MONTHS_SHORT.map((m, idx) => (
<th key={idx}
onClick={() => setPlanMonth(idx)}
className={`cursor-pointer py-2 px-1 text-center text-xs font-semibold border-b border-gray-100 min-w-[52px] transition-colors select-none
${idx === planMonth ? 'text-blue-600 bg-blue-50' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'}
${idx === today.getMonth() && idx !== planMonth ? 'underline decoration-dotted decoration-blue-400' : ''}
`}>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{sortedUserIds.map(uid => {
const name = uid === user!.id
? `${user!.first_name} ${user!.last_name}`
: (colleagueMap[uid] ?? uid)
const firstName = name.split(' ')[0]
const lastName = name.split(' ').slice(1).join(' ')
return (
<tr key={uid} className='border-b border-gray-50 hover:bg-gray-50/50'>
<td className='sticky left-0 bg-white z-10 py-2 px-3 border-r border-gray-100'>
<div className='text-sm font-medium text-gray-800 truncate max-w-[100px]'>
{firstName}
{lastName && <span className='text-gray-400 font-normal ml-1 text-xs'>{lastName.charAt(0)}.</span>}
</div>
</td>
{Array.from({ length: 12 }, (_, monthIdx) => {
const cellAbsences = absenceSpans.filter(s => {
const first = new Date(year, monthIdx, 1)
const last = new Date(year, monthIdx + 1, 0)
return s.userId === uid && s.start <= last && s.end >= first
&& s.status !== 'cancelled' && s.status !== 'rejected'
})
const isActiveMonth = monthIdx === planMonth
const totalDays = cellAbsences.reduce((sum, s) => {
const first = new Date(year, monthIdx, 1)
const last = new Date(year, monthIdx + 1, 0)
const cs = s.start < first ? first : s.start
const ce = s.end > last ? last : s.end
return sum + Math.round((ce.getTime() - cs.getTime()) / 86400000) + 1
}, 0)
return (
<td key={monthIdx}
onClick={() => setPlanMonth(monthIdx)}
className={`py-1.5 px-1 text-center cursor-pointer transition-colors ${isActiveMonth ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
{cellAbsences.length === 0 ? (
<span className='text-gray-200 text-xs'>·</span>
) : (
<div className='flex flex-col items-center gap-0.5'>
<div className='flex gap-0.5 justify-center flex-wrap'>
{[...new Map(cellAbsences.map(s => [s.color, s])).values()].slice(0, 4).map(s => (
<span key={s.color} className='w-2 h-2 rounded-full flex-shrink-0'
style={{ backgroundColor: s.color,
opacity: cellAbsences.every(a => a.status === 'pending') ? 0.5 : 1 }} />
))}
</div>
<span className='text-[10px] text-gray-500 leading-none'>{totalDays}d</span>
</div>
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})()}
{/* Monatskalender */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden'>
{/* Header */}
<div className='px-5 py-3.5 border-b border-gray-100 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<button onClick={() => setPlanMonth(m => (m - 1 + 12) % 12)}
className='p-1.5 rounded-md hover:bg-gray-100 text-gray-500 transition-colors'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M15 19l-7-7 7-7' /></svg>
</button>
<h2 className='text-base font-semibold text-gray-800 w-44 text-center'>
{MONTHS[planMonth]} {year}
</h2>
<button onClick={() => setPlanMonth(m => (m + 1) % 12)}
className='p-1.5 rounded-md hover:bg-gray-100 text-gray-500 transition-colors'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M9 5l7 7-7 7' /></svg>
</button>
<button onClick={() => setPlanMonth(today.getMonth())}
className='ml-1 px-2.5 py-1 text-xs border border-gray-200 text-gray-600 rounded-md hover:bg-gray-50 transition-colors'>
Heute
</button>
</div>
<button onClick={() => openPrint(buildMonthHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Drucken'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z' /></svg>
Drucken
</button>
<button onClick={() => openPdf(buildMonthHtml())}
className='flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg text-xs hover:bg-gray-50 transition-colors'
title='Als PDF speichern'>
<svg className='w-3.5 h-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' /></svg>
PDF
</button>
</div>
{/* Resource-Timeline (Gantt) */}
{(() => {
const CELL_W = 36
const daysInMonth = new Date(year, planMonth + 1, 0).getDate()
const monthDays = Array.from({ length: daysInMonth }, (_, i) => new Date(year, planMonth, i + 1))
const monthAbsenceSpans = absenceSpans.filter(s => {
const first = new Date(year, planMonth, 1)
const last = new Date(year, planMonth + 1, 0)
return s.start <= last && s.end >= first && s.status !== 'cancelled' && s.status !== 'rejected'
})
const visibleUserIds = isManager
? [...new Set([user!.id, ...monthAbsenceSpans.map(s => s.userId)])]
: [user!.id]
const sortedUserIds = [...visibleUserIds].sort((a, b) => {
if (a === user!.id) return -1
if (b === user!.id) return 1
return (colleagueMap[a] ?? '').localeCompare(colleagueMap[b] ?? '', 'de')
})
if (monthAbsenceSpans.length === 0) {
return (
<p className='text-center text-gray-400 py-10 text-sm'>Keine Abwesenheiten im {MONTHS[planMonth]}</p>
)
}
return (
<div className='overflow-x-auto'>
<div style={{ minWidth: 120 + CELL_W * daysInMonth }}>
{/* Header row */}
<div className='flex border-b border-gray-100 bg-gray-50'>
<div style={{ width: 120, minWidth: 120, flexShrink: 0 }} className='sticky left-0 z-10 bg-gray-50 border-r border-gray-200' />
{monthDays.map((day, idx) => {
const wd = (day.getDay() + 6) % 7
const isWeekend = wd >= 5
const isToday = day.toDateString() === todayStr
const dayLabel = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][wd]
return (
<div key={idx} style={{ width: CELL_W, minWidth: CELL_W, flexShrink: 0 }}
className={`flex flex-col items-center justify-center border-r border-gray-100 last:border-r-0 py-1 ${isWeekend ? 'bg-gray-50' : ''}`}>
<span className='text-[10px] text-gray-400 leading-none mb-0.5'>{dayLabel}</span>
{isToday ? (
<span className='inline-flex items-center justify-center w-5 h-5 bg-blue-600 text-white rounded-full text-[11px] font-bold leading-none'>
{day.getDate()}
</span>
) : (
<span className={`text-[12px] font-medium leading-none ${isWeekend ? 'text-gray-400' : 'text-gray-700'}`}>
{day.getDate()}
</span>
)}
</div>
)
})}
</div>
{/* User rows */}
{sortedUserIds.map(uid => {
const fullName = uid === user!.id
? `${user!.first_name} ${user!.last_name}`
: (colleagueMap[uid] ?? uid)
const firstName = fullName.split(' ')[0]
const bal = colleagueBalances[uid]
const userSpans = monthAbsenceSpans.filter(s => s.userId === uid)
const first = new Date(year, planMonth, 1)
const last = new Date(year, planMonth + 1, 0)
return (
<div key={uid} className='flex border-b border-gray-100 last:border-b-0' style={{ height: 40 }}>
<div style={{ width: 120, minWidth: 120, flexShrink: 0 }}
className='sticky left-0 z-10 bg-white/95 backdrop-blur border-r border-gray-200 flex flex-col justify-center px-3'>
<span className='text-sm font-medium text-gray-800 truncate leading-tight'>{firstName}</span>
{isManager && bal != null && (
<span className={`text-[10px] font-medium leading-tight ${bal.remaining_days <= 0 ? 'text-red-500' : bal.remaining_days <= 5 ? 'text-yellow-600' : 'text-green-600'}`}>
{bal.remaining_days} Tage
</span>
)}
</div>
<div className='relative flex-1' style={{ width: CELL_W * daysInMonth }}>
{monthDays.map((day, idx) => {
const wd = (day.getDay() + 6) % 7
const isWeekend = wd >= 5
return (
<div key={idx}
className={`absolute top-0 bottom-0 border-r border-gray-100 last:border-r-0 ${isWeekend ? 'bg-gray-50/60' : ''}`}
style={{ left: idx * CELL_W, width: CELL_W }}
/>
)
})}
{userSpans.map(span => {
const clampedStart = span.start < first ? first : span.start
const clampedEnd = span.end > last ? last : span.end
const startIdx = clampedStart.getDate() - 1
const endIdx = clampedEnd.getDate() - 1
const spanDays = endIdx - startIdx + 1
const isContinuedLeft = span.start < first
const isContinuedRight = span.end > last
const leftOffset = startIdx * CELL_W + (isContinuedLeft ? 0 : 2)
const barWidth = spanDays * CELL_W - (isContinuedLeft ? 0 : 2) - (isContinuedRight ? 0 : 2)
const borderRadius = `${isContinuedLeft ? 0 : 4}px ${isContinuedRight ? 0 : 4}px ${isContinuedRight ? 0 : 4}px ${isContinuedLeft ? 0 : 4}px`
return (
<div key={span.id}
className='absolute flex items-center overflow-hidden'
style={{
top: 4, bottom: 4, left: leftOffset, width: barWidth,
backgroundColor: span.color,
opacity: span.status === 'pending' ? 0.65 : 1,
borderRadius,
borderLeft: isContinuedLeft ? '3px solid rgba(0,0,0,0.15)' : undefined,
}}>
<span className='px-1.5 text-white text-[11px] font-medium truncate leading-none select-none'>
{span.typeName}
{span.status === 'pending' && <span className='opacity-75 ml-0.5 text-[10px]'>?</span>}
</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
})()}
</div>
</div>
)}
{/* ─── Modals ──────────────────────────────────────────────────────── */}
{showBalanceEdit && balance && (
<BalanceEditModal
year={year}
balance={balance}
balanceForm={balanceForm}
setBalanceForm={setBalanceForm}
balanceSaving={balanceSaving}
onSave={handleSaveBalance}
onClose={() => setShowBalanceEdit(false)}
/>
)}
{editAbsence && (
<EditAbsenceModal
editAbsence={editAbsence}
editForm={editForm}
setEditForm={setEditForm}
types={types}
isManager={isManager}
submitting={submitting}
error={error}
onSave={handleSaveEdit}
onClose={() => { setEditAbsence(null); setError('') }}
/>
)}
{showCreate && (
<CreateAbsenceModal
form={form}
setForm={setForm}
types={types}
colleagues={colleagues}
isManager={isManager}
submitting={submitting}
error={error}
overtimeBalance={overtimeBalance}
onCreate={handleCreate}
onClose={() => { setShowCreate(false); setError(''); setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) }}
/>
)}
{showQuickSick && (
<QuickSickModal
form={quickSickForm}
setForm={setQuickSickForm}
submitting={quickSubmitting}
error={quickError}
onSubmit={handleQuickSick}
onClose={() => { setShowQuickSick(false); setQuickError('') }}
/>
)}
</div>
</Layout>
)
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useState, useCallback } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
import { Spinner } from '../components/Spinner'
// ── Types ────────────────────────────────────────────────────────────────────
interface Me { id: string; first_name: string; last_name: string; role: string }
interface AuditEntry {
id: string
user_id: string | null
user_name: string | null
action: string
entity_type: string | null
entity_id: string | null
old_value: Record<string, unknown> | null
new_value: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
interface AuditListResponse { total: number; items: AuditEntry[] }
interface UserItem { id: string; full_name: string }
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatTs(iso: string) {
const d = new Date(iso)
return d.toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
function actionBadgeColor(action: string) {
if (action.includes('delete') || action.includes('cancel') || action.includes('reject') || action.includes('revoke'))
return 'bg-red-100 text-red-700'
if (action.includes('create') || action.includes('invite') || action.includes('register') || action.includes('approve'))
return 'bg-green-100 text-green-700'
if (action.includes('update') || action.includes('change') || action.includes('rotate') || action.includes('reset'))
return 'bg-yellow-100 text-yellow-700'
return 'bg-gray-100 text-gray-600'
}
function JsonBlock({ label, val }: { label: string; val: Record<string, unknown> | null }) {
if (!val) return <span className='text-gray-400 italic text-xs'></span>
return (
<div>
<p className='text-xs font-semibold text-gray-500 mb-1'>{label}</p>
<pre className='text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all'>
{JSON.stringify(val, null, 2)}
</pre>
</div>
)
}
const PAGE_SIZE = 50
// ── Component ─────────────────────────────────────────────────────────────────
export function AuditLogPage() {
const [me, setMe] = useState<Me | null>(null)
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Filters
const [filterUser, setFilterUser] = useState('')
const [filterAction, setFilterAction] = useState('')
const [filterEntity, setFilterEntity] = useState('')
const [filterFrom, setFilterFrom] = useState('')
const [filterTo, setFilterTo] = useState('')
const [offset, setOffset] = useState(0)
// Filter-Options
const [users, setUsers] = useState<UserItem[]>([])
const [actions, setActions] = useState<string[]>([])
const [entityTypes, setEntityTypes] = useState<string[]>([])
// Detail-Expand
const [expanded, setExpanded] = useState<string | null>(null)
const load = useCallback(async (off = 0) => {
setLoading(true)
setError('')
try {
const params = new URLSearchParams()
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(off))
if (filterUser) params.set('user_id', filterUser)
if (filterAction) params.set('action', filterAction)
if (filterEntity) params.set('entity_type', filterEntity)
if (filterFrom) params.set('date_from', new Date(filterFrom).toISOString())
if (filterTo) params.set('date_to', new Date(filterTo + 'T23:59:59').toISOString())
const data = await api.get<AuditListResponse>(`/audit-logs?${params}`)
setEntries(data.items)
setTotal(data.total)
setOffset(off)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [filterUser, filterAction, filterEntity, filterFrom, filterTo])
useEffect(() => {
Promise.all([
api.get<Me>('/auth/me'),
api.get<{ items: UserItem[]; total: number }>('/users').catch(() => ({ items: [], total: 0 })),
api.get<string[]>('/audit-logs/actions').catch(() => []),
api.get<string[]>('/audit-logs/entity-types').catch(() => []),
]).then(([meData, usersData, actionsData, entitiesData]) => {
setMe(meData)
setUsers(usersData.items)
setActions(actionsData)
setEntityTypes(entitiesData)
})
}, [])
useEffect(() => { load(0) }, [load])
// ── CSV-Export ──────────────────────────────────────────────────────────────
async function exportCsv() {
// Alle Seiten laden (max 5000)
const params = new URLSearchParams()
params.set('limit', '5000')
params.set('offset', '0')
if (filterUser) params.set('user_id', filterUser)
if (filterAction) params.set('action', filterAction)
if (filterEntity) params.set('entity_type', filterEntity)
if (filterFrom) params.set('date_from', new Date(filterFrom).toISOString())
if (filterTo) params.set('date_to', new Date(filterTo + 'T23:59:59').toISOString())
try {
const data = await api.get<AuditListResponse>(`/audit-logs?${params}`)
const header = ['Zeitstempel', 'Benutzer', 'Aktion', 'Entity-Typ', 'Entity-ID', 'IP', 'Alt', 'Neu']
const rows = data.items.map(e => [
formatTs(e.created_at),
e.user_name ?? '',
e.action,
e.entity_type ?? '',
e.entity_id ?? '',
e.ip_address ?? '',
e.old_value ? JSON.stringify(e.old_value) : '',
e.new_value ? JSON.stringify(e.new_value) : '',
])
const csv = [header, ...rows]
.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'))
.join('\n')
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Export fehlgeschlagen')
}
}
// ── Render ──────────────────────────────────────────────────────────────────
const inputClass = 'border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const totalPages = Math.ceil(total / PAGE_SIZE)
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className='p-6 max-w-screen-xl mx-auto'>
{/* Header */}
<div className='flex items-center justify-between mb-6'>
<div>
<h1 className='text-2xl font-bold text-gray-900'>Audit-Log</h1>
<p className='text-sm text-gray-500 mt-0.5'>
Alle sicherheitsrelevanten Aktionen im System
</p>
</div>
<button
onClick={exportCsv}
className='flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2}
d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' />
</svg>
CSV exportieren
</button>
</div>
{/* Filter */}
<div className='bg-white rounded-xl border border-gray-200 p-4 mb-6'>
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3'>
<div>
<label className='block text-xs font-medium text-gray-600 mb-1'>Benutzer</label>
<select value={filterUser} onChange={e => setFilterUser(e.target.value)} className={inputClass + ' w-full'}>
<option value=''>Alle</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.full_name}</option>
))}
</select>
</div>
<div>
<label className='block text-xs font-medium text-gray-600 mb-1'>Aktion</label>
<select value={filterAction} onChange={e => setFilterAction(e.target.value)} className={inputClass + ' w-full'}>
<option value=''>Alle</option>
{actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div>
<label className='block text-xs font-medium text-gray-600 mb-1'>Entity-Typ</label>
<select value={filterEntity} onChange={e => setFilterEntity(e.target.value)} className={inputClass + ' w-full'}>
<option value=''>Alle</option>
{entityTypes.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className='block text-xs font-medium text-gray-600 mb-1'>Von</label>
<input type='date' value={filterFrom} onChange={e => setFilterFrom(e.target.value)} className={inputClass + ' w-full'} />
</div>
<div>
<label className='block text-xs font-medium text-gray-600 mb-1'>Bis</label>
<input type='date' value={filterTo} onChange={e => setFilterTo(e.target.value)} className={inputClass + ' w-full'} />
</div>
</div>
<div className='flex gap-2 mt-3'>
<button
onClick={() => load(0)}
className='px-4 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700'
>
Filtern
</button>
<button
onClick={() => {
setFilterUser(''); setFilterAction(''); setFilterEntity('')
setFilterFrom(''); setFilterTo('')
}}
className='px-4 py-1.5 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200'
>
Zurücksetzen
</button>
</div>
</div>
{/* Table */}
{error && (
<div className='bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 mb-4 text-sm'>{error}</div>
)}
<div className='bg-white rounded-xl border border-gray-200 overflow-hidden'>
<div className='px-4 py-3 border-b border-gray-100 flex items-center justify-between'>
<span className='text-sm text-gray-500'>{total.toLocaleString('de-DE')} Einträge gesamt</span>
{loading && <Spinner />}
</div>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 border-b border-gray-200'>
<tr>
<th className='px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap'>Zeitstempel</th>
<th className='px-4 py-3 text-left font-medium text-gray-600'>Benutzer</th>
<th className='px-4 py-3 text-left font-medium text-gray-600'>Aktion</th>
<th className='px-4 py-3 text-left font-medium text-gray-600'>Objekt</th>
<th className='px-4 py-3 text-left font-medium text-gray-600'>IP</th>
<th className='px-4 py-3 text-left font-medium text-gray-600 w-8'></th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{entries.length === 0 && !loading && (
<tr>
<td colSpan={6} className='px-4 py-8 text-center text-gray-400'>
Keine Einträge gefunden
</td>
</tr>
)}
{entries.map(e => (
<>
<tr
key={e.id}
className='hover:bg-gray-50 cursor-pointer'
onClick={() => setExpanded(expanded === e.id ? null : e.id)}
>
<td className='px-4 py-3 text-gray-500 whitespace-nowrap font-mono text-xs'>
{formatTs(e.created_at)}
</td>
<td className='px-4 py-3 text-gray-900'>
{e.user_name ?? <span className='text-gray-400 italic'>System</span>}
</td>
<td className='px-4 py-3'>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${actionBadgeColor(e.action)}`}>
{e.action}
</span>
</td>
<td className='px-4 py-3 text-gray-600'>
{e.entity_type && (
<span className='font-medium'>{e.entity_type}</span>
)}
{e.entity_id && (
<span className='text-gray-400 text-xs ml-1 font-mono'>
{e.entity_id.slice(0, 8)}
</span>
)}
</td>
<td className='px-4 py-3 text-gray-500 font-mono text-xs'>
{e.ip_address ?? ''}
</td>
<td className='px-4 py-3 text-gray-400'>
{(e.old_value || e.new_value) && (
<svg
className={`w-4 h-4 transition-transform ${expanded === e.id ? 'rotate-90' : ''}`}
fill='none' stroke='currentColor' viewBox='0 0 24 24'
>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M9 5l7 7-7 7' />
</svg>
)}
</td>
</tr>
{expanded === e.id && (e.old_value || e.new_value) && (
<tr key={`${e.id}-detail`} className='bg-gray-50'>
<td colSpan={6} className='px-6 py-4'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<JsonBlock label='Vorher' val={e.old_value} />
<JsonBlock label='Nachher' val={e.new_value} />
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='px-4 py-3 border-t border-gray-100 flex items-center justify-between'>
<span className='text-sm text-gray-500'>
Seite {currentPage} von {totalPages}
</span>
<div className='flex gap-2'>
<button
disabled={offset === 0}
onClick={() => load(Math.max(0, offset - PAGE_SIZE))}
className='px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50'
>
Zurück
</button>
<button
disabled={offset + PAGE_SIZE >= total}
onClick={() => load(offset + PAGE_SIZE)}
className='px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50'
>
Weiter
</button>
</div>
</div>
)}
</div>
</div>
</Layout>
)
}
+363
View File
@@ -0,0 +1,363 @@
import { useEffect, useState } 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
}
interface CaldavConfigOut {
id: string
enabled: boolean
principal_url: string
calendar_url: string | null
username: string
calendar_display_name: string
verify_ssl: boolean
name_template?: string
last_error: string | null
updated_at: string
}
const EMPTY = {
enabled: false,
principal_url: '',
calendar_url: '',
username: '',
password: '',
calendar_display_name: '',
verify_ssl: true,
name_template: 'full',
}
const PLACEHOLDER_DOCS = [
{ ph: '$vorname', desc: 'Vollständiger Vorname', example: 'Patrick' },
{ ph: '$nachname', desc: 'Vollständiger Nachname', example: 'Perlbach' },
{ ph: '$vorname_short', desc: 'Erster Buchstabe des Vornamens', example: 'P' },
{ ph: '$nachname_middle', desc: 'Erste 3 Buchstaben des Nachnamens', example: 'Per' },
{ ph: '$kuerzel', desc: 'Manuell gesetztes Kürzel (Fallback: Initialen)', example: 'PP' },
{ ph: '$typ', desc: 'Abwesenheitsart', example: 'Urlaub' },
]
const TEMPLATE_PRESETS = [
{ label: 'Vollständiger Name', value: '$vorname $nachname $typ' },
{ label: 'Vorname abgekürzt', value: '$vorname_short. $nachname $typ' },
{ label: 'Kürzel', value: '$kuerzel $typ' },
{ label: 'Nur Art', value: '$typ' },
]
function renderPreview(template: string): string {
return template
.replace('$vorname_short', 'P')
.replace('$nachname_middle', 'Per')
.replace('$vorname', 'Patrick')
.replace('$nachname', 'Perlbach')
.replace('$kuerzel', 'PP')
.replace('$typ', 'Urlaub')
}
const ADMIN_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN']
function ConfigForm({
title,
hint,
config,
onSave,
onTest,
onResync,
showResync,
showNameFormat,
}: {
title: string
hint: string
config: CaldavConfigOut | null
onSave: (data: typeof EMPTY) => Promise<void>
onTest: () => Promise<void>
onResync?: () => Promise<void>
showResync?: boolean
showNameFormat?: boolean
}) {
const [form, setForm] = useState(EMPTY)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [resyncing, setResyncing] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
if (config) {
setForm({
enabled: config.enabled,
principal_url: config.principal_url,
calendar_url: config.calendar_url ?? '',
username: config.username,
password: '',
calendar_display_name: config.calendar_display_name,
verify_ssl: config.verify_ssl,
name_template: config.name_template ?? 'full',
})
}
}, [config])
const save = async () => {
setSaving(true); setError(''); setSuccess('')
try {
await onSave({ ...form, calendar_url: form.calendar_url || null, password: form.password || null } as typeof EMPTY)
setForm(f => ({ ...f, password: '' }))
setSuccess('Gespeichert.')
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
const test = async () => {
setTesting(true); setError(''); setSuccess('')
try { await onTest(); setSuccess('Verbindung erfolgreich!') }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Verbindungsfehler') }
finally { setTesting(false) }
}
const resync = async () => {
if (!onResync) return
setResyncing(true); setError(''); setSuccess('')
try { await onResync(); setSuccess('Neu synchronisiert.') }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setResyncing(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 (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5'>
<div>
<h2 className='text-base font-semibold text-gray-800'>{title}</h2>
<p className='text-xs text-gray-400 mt-0.5'>{hint}</p>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{success && <div className='bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-700'>{success}</div>}
{config?.last_error && (
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-xs text-yellow-700'>
Letzter Sync-Fehler: {config.last_error}
</div>
)}
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className='rounded' />
CalDAV-Sync aktiviert
</label>
<div className='grid grid-cols-2 gap-3'>
<div className='col-span-2'>
<label className='block text-sm font-medium text-gray-700 mb-1'>Server-URL (Principal oder Kalender-URL) *</label>
<input type='url' value={form.principal_url} onChange={e => setForm(f => ({ ...f, principal_url: e.target.value }))}
placeholder='https://cloud.example.de/remote.php/dav/calendars/user/abwesenheiten/' className={inp} />
<p className='text-xs text-gray-400 mt-1'>Nextcloud: Einstellungen Kalender Kalender-Link (WebDAV)</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Benutzername *</label>
<input type='text' value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} className={inp} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>
Passwort {config ? '(leer = unverändert)' : '*'}
</label>
<input type='password' value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
placeholder='••••••••' className={inp} autoComplete='new-password' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Kalender-Name (optional)</label>
<input type='text' value={form.calendar_display_name} onChange={e => setForm(f => ({ ...f, calendar_display_name: e.target.value }))}
placeholder='Abwesenheiten' className={inp} />
</div>
<div className='flex items-end pb-2'>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.verify_ssl} onChange={e => setForm(f => ({ ...f, verify_ssl: e.target.checked }))} className='rounded' />
SSL-Zertifikat prüfen
</label>
</div>
</div>
{showNameFormat && (
<div className='space-y-3'>
<div>
<label className='block text-sm font-medium text-gray-700'>
Anzeige-Name im Firmenkalender
</label>
<p className='text-xs text-gray-400 mt-0.5'>
Erstelle ein eigenes Template mit Platzhaltern. Im persönlichen Kalender steht immer nur die Abwesenheitsart.
</p>
</div>
{/* Schnellauswahl */}
<div className='flex flex-wrap gap-2'>
{TEMPLATE_PRESETS.map(p => (
<button
key={p.value}
type='button'
onClick={() => setForm(f => ({ ...f, name_template: p.value }))}
className={`text-xs px-3 py-1.5 rounded-lg border transition-colors ${
form.name_template === p.value
? 'border-blue-400 bg-blue-50 text-blue-700 font-medium'
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{p.label}
</button>
))}
</div>
{/* Template-Eingabe */}
<div>
<input
type='text'
value={form.name_template}
onChange={e => setForm(f => ({ ...f, name_template: e.target.value }))}
placeholder='$vorname $nachname $typ'
className={inp}
/>
{/* Live-Vorschau */}
<div className='mt-1.5 flex items-center gap-2'>
<span className='text-xs text-gray-400'>Vorschau:</span>
<span className='text-xs font-medium text-gray-700 bg-gray-100 px-2 py-0.5 rounded font-mono'>
{renderPreview(form.name_template || '$vorname $nachname $typ')}
</span>
</div>
</div>
{/* Platzhalter-Referenz */}
<div className='rounded-lg border border-gray-100 bg-gray-50 p-3'>
<p className='text-xs font-medium text-gray-600 mb-2'>Verfügbare Platzhalter:</p>
<div className='grid grid-cols-1 gap-1'>
{PLACEHOLDER_DOCS.map(p => (
<div key={p.ph} className='flex items-baseline gap-2 text-xs'>
<button
type='button'
onClick={() => setForm(f => ({ ...f, name_template: f.name_template + p.ph }))}
className='font-mono text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded hover:bg-blue-100 transition-colors shrink-0'
title='Klicken zum Einfügen'
>
{p.ph}
</button>
<span className='text-gray-500'>{p.desc}</span>
<span className='text-gray-400'> {p.example}</span>
</div>
))}
</div>
</div>
</div>
)}
<div className='flex flex-wrap gap-2 pt-1'>
<button onClick={save} disabled={saving || !form.principal_url || !form.username}
className='px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Speichern'}
</button>
{config && (
<button onClick={test} disabled={testing}
className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50'>
{testing ? 'Teste…' : 'Verbindung testen'}
</button>
)}
{showResync && config && onResync && (
<button onClick={resync} disabled={resyncing}
className='px-4 py-2 border border-blue-300 text-blue-700 rounded-lg text-sm hover:bg-blue-50 disabled:opacity-50'>
{resyncing ? 'Sync läuft…' : 'Alle Abwesenheiten neu synchronisieren'}
</button>
)}
</div>
</div>
)
}
export function CaldavSettingsPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [companyCfg, setCompanyCfg] = useState<CaldavConfigOut | null>(null)
const [userCfg, setUserCfg] = useState<CaldavConfigOut | null>(null)
const [loading, setLoading] = useState(true)
const isAdmin = user ? ADMIN_ROLES.includes(user.role) : false
useEffect(() => {
async function load() {
try {
const me = await api.get<UserOut>('/auth/me')
setUser(me)
const [cc, uc] = await Promise.all([
ADMIN_ROLES.includes(me.role)
? api.get<CaldavConfigOut | null>('/caldav/company/config').catch(() => null)
: Promise.resolve(null),
api.get<CaldavConfigOut | null>('/caldav/user/config').catch(() => null),
])
setCompanyCfg(cc)
setUserCfg(uc)
} catch { /* ignore */ }
finally { setLoading(false) }
}
load()
}, [])
const saveCompany = async (data: typeof EMPTY) => {
const saved = await api.post<CaldavConfigOut>('/caldav/company/config', data)
setCompanyCfg(saved)
}
const testCompany = async () => { await api.post('/caldav/company/test', {}) }
const resyncCompany = async () => { await api.post('/caldav/company/resync', {}) }
const saveUser = async (data: typeof EMPTY) => {
const saved = await api.post<CaldavConfigOut>('/caldav/user/config', data)
setUserCfg(saved)
}
const testUser = async () => { await api.post('/caldav/user/test', {}) }
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='max-w-2xl space-y-6'>
<div>
<h1 className='text-2xl font-bold text-gray-900'>CalDAV-Integration</h1>
<p className='text-sm text-gray-500 mt-1'>
Genehmigte Abwesenheiten werden automatisch in Kalender-Apps eingetragen.
Kompatibel mit Nextcloud, Thunderbird, Apple Kalender, Google Calendar und allen CalDAV-Clients.
</p>
</div>
{/* Hinweis: kein eigener Server nötig */}
<div className='bg-blue-50 border border-blue-100 rounded-xl p-4 text-sm text-blue-800'>
<p className='font-semibold mb-1'>Kein eigener CalDAV-Server nötig</p>
<p>Trage einfach die URL eines bestehenden Kalenders ein z. B. aus Nextcloud, iCloud oder Google Calendar:</p>
<ul className='mt-2 space-y-1 text-xs list-disc list-inside text-blue-700'>
<li><strong>Nextcloud:</strong> Kalender-App ··· Link kopieren (WebDAV-URL)</li>
<li><strong>Apple iCloud:</strong> calendar.icloud.com Kalender freigeben privater Link</li>
<li><strong>Google:</strong> calendar.google.com Einstellungen Kalender-Adresse (ICS-Export-URL, Read-only)</li>
<li><strong>Eigener Radicale/Baikal-Server:</strong> direkte CalDAV-URL verwenden</li>
</ul>
</div>
{isAdmin && (
<ConfigForm
title='Firmenkalender (zentral)'
hint='Alle genehmigten Abwesenheiten aller Mitarbeiter landen in diesem Kalender. Nur Admins können ihn konfigurieren.'
config={companyCfg}
onSave={saveCompany}
onTest={testCompany}
onResync={resyncCompany}
showResync
showNameFormat
/>
)}
<ConfigForm
title='Mein persönlicher Kalender'
hint='Nur deine eigenen Abwesenheiten. Jeder Mitarbeiter kann seinen eigenen Kalender hinterlegen.'
config={userCfg}
onSave={saveUser}
onTest={testUser}
/>
</div>
</Layout>
)
}
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState, useCallback } 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
}
interface CalendarEntry {
user_id: string
user_name: string
absence_id: string
type_name: string
type_color: string
start_date: string
end_date: string
status: string
working_days: number
}
interface PublicHoliday {
id: string
date: string
name: string
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function getDaysInMonth(year: number, month: number): Date[] {
const days: Date[] = []
const d = new Date(year, month, 1)
while (d.getMonth() === month) {
days.push(new Date(d))
d.setDate(d.getDate() + 1)
}
return days
}
// Mon=0..Sun=6
function dayOfWeek(d: Date): number {
return (d.getDay() + 6) % 7
}
export function CalendarPage() {
const today = new Date()
const [user, setUser] = useState<UserOut | null>(null)
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [entries, setEntries] = useState<CalendarEntry[]>([])
const [holidays, setHolidays] = useState<PublicHoliday[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [tooltip, setTooltip] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [me, cal, hol] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<CalendarEntry[]>(`/absences/calendar?year=${year}&month=${month + 1}`),
api.get<PublicHoliday[]>(`/public-holidays/?year=${year}&country=DE`),
])
setUser(me)
setEntries(cal.filter(e => e.status === 'approved' || e.status === 'pending'))
setHolidays(hol)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setLoading(false)
}
}, [year, month])
useEffect(() => { load() }, [load])
const prevMonth = () => {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const days = getDaysInMonth(year, month)
const firstDow = dayOfWeek(days[0]) // 0=Mon
// Absent people per day
const absentOn = (dateStr: string): CalendarEntry[] =>
entries.filter(e => e.start_date <= dateStr && e.end_date >= dateStr)
const isHoliday = (dateStr: string): string | null =>
holidays.find(h => h.date === dateStr)?.name ?? null
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center gap-4'>
<button onClick={prevMonth} className='p-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-gray-600'></button>
<h1 className='text-2xl font-bold text-gray-900 min-w-[220px] text-center'>
{MONTHS[month]} {year}
</h1>
<button onClick={nextMonth} className='p-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-gray-600'></button>
<button
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}
className='ml-2 px-3 py-1.5 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50'
>
Heute
</button>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{/* Calendar grid */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden'>
{/* Weekday headers */}
<div className='grid grid-cols-7 border-b border-gray-100'>
{WEEKDAYS.map(d => (
<div key={d} className={`text-center text-xs font-semibold py-3 ${d === 'Sa' || d === 'So' ? 'text-gray-400' : 'text-gray-600'}`}>
{d}
</div>
))}
</div>
{/* Days */}
<div className='grid grid-cols-7'>
{/* Leading empty cells */}
{Array.from({ length: firstDow }).map((_, i) => (
<div key={`empty-${i}`} className='min-h-[100px] border-b border-r border-gray-50 bg-gray-50/30' />
))}
{days.map(day => {
const ds = isoDate(day)
const dow = dayOfWeek(day)
const isToday = ds === isoDate(today)
const isWeekend = dow >= 5
const holiday = isHoliday(ds)
const absent = absentOn(ds)
return (
<div
key={ds}
className={`min-h-[100px] border-b border-r border-gray-100 p-1.5 ${isWeekend ? 'bg-gray-50/60' : ''} ${holiday ? 'bg-amber-50/60' : ''}`}
>
<div className='flex items-center justify-between mb-1'>
<span className={`text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full ${
isToday ? 'bg-blue-600 text-white' : isWeekend ? 'text-gray-400' : 'text-gray-700'
}`}>
{day.getDate()}
</span>
{holiday && (
<span
className='text-xs text-amber-600 truncate max-w-[70px] cursor-help'
title={holiday}
>
{holiday.length > 8 ? holiday.slice(0, 8) + '…' : holiday}
</span>
)}
</div>
{/* Absence chips */}
<div className='space-y-0.5'>
{absent.slice(0, 3).map(e => (
<div
key={e.absence_id}
className='text-xs px-1.5 py-0.5 rounded truncate cursor-pointer opacity-90 hover:opacity-100'
style={{ backgroundColor: e.type_color + '33', color: e.type_color, borderLeft: `3px solid ${e.type_color}` }}
title={`${e.user_name} ${e.type_name}${e.status === 'pending' ? ' (ausstehend)' : ''}`}
onMouseEnter={() => setTooltip(`${e.user_name}: ${e.type_name}`)}
onMouseLeave={() => setTooltip(null)}
>
{e.user_name.split(' ')[0]}
{e.status === 'pending' && ' ?'}
</div>
))}
{absent.length > 3 && (
<div className='text-xs text-gray-400 px-1'>+{absent.length - 3} weitere</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-4 text-sm text-gray-600'>
<div className='flex items-center gap-1.5'>
<div className='w-3 h-3 rounded-sm bg-amber-100 border border-amber-200' />
Feiertag
</div>
<div className='flex items-center gap-1.5'>
<div className='w-3 h-3 rounded-sm bg-gray-100 border border-gray-200' />
Wochenende
</div>
<div className='flex items-center gap-1.5 text-gray-400'>
<span className='font-medium'>Vorname ?</span>
= ausstehend
</div>
</div>
{/* Summary: who's absent this month */}
{entries.length > 0 && (
<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'>Abwesenheiten im {MONTHS[month]}</h2>
<div className='space-y-2'>
{Array.from(new Set(entries.map(e => e.user_id))).map(uid => {
const userEntries = entries.filter(e => e.user_id === uid)
const name = userEntries[0].user_name
return (
<div key={uid} className='flex items-start gap-3'>
<div className='w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-xs font-bold text-blue-700 flex-shrink-0'>
{name.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
<div>
<p className='text-sm font-medium text-gray-800'>{name}</p>
<div className='flex flex-wrap gap-1.5 mt-0.5'>
{userEntries.map(e => (
<span
key={e.absence_id}
className='text-xs px-2 py-0.5 rounded-full'
style={{ backgroundColor: e.type_color + '22', color: e.type_color }}
>
{e.type_name} · {new Date(e.start_date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
{e.start_date !== e.end_date && ` ${new Date(e.end_date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`}
{e.status === 'pending' && ' (ausstehend)'}
</span>
))}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Tooltip overlay (hidden, just accessibility) */}
{tooltip && (
<div className='fixed bottom-4 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-sm px-4 py-2 rounded-lg shadow-lg pointer-events-none z-50'>
{tooltip}
</div>
)}
</Layout>
)
}
+560
View File
@@ -0,0 +1,560 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string
}
interface CompanyOut {
id: string
name: string
slug: string
plan: string
country: string
state: string | null
settings: Record<string, unknown>
personnel_number_required: boolean
personnel_number_mode: 'manual' | 'auto'
personnel_number_next: number
}
const BUNDESLAENDER = [
{ code: 'BB', name: 'Brandenburg' },
{ code: 'BE', name: 'Berlin' },
{ code: 'BW', name: 'Baden-Württemberg' },
{ code: 'BY', name: 'Bayern' },
{ code: 'HB', name: 'Bremen' },
{ code: 'HE', name: 'Hessen' },
{ code: 'HH', name: 'Hamburg' },
{ code: 'MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'NI', name: 'Niedersachsen' },
{ code: 'NW', name: 'Nordrhein-Westfalen' },
{ code: 'RP', name: 'Rheinland-Pfalz' },
{ code: 'SH', name: 'Schleswig-Holstein' },
{ code: 'SL', name: 'Saarland' },
{ code: 'SN', name: 'Sachsen' },
{ code: 'ST', name: 'Sachsen-Anhalt' },
{ code: 'TH', name: 'Thüringen' },
]
export function CompanySettingsPage() {
const [me, setMe] = useState<UserOut | null>(null)
const [company, setCompany] = useState<CompanyOut | null>(null)
const [name, setName] = useState('')
const [state, setState] = useState('')
// Arbeitszeitregeln
const [coreStart, setCoreStart] = useState('8')
const [coreEnd, setCoreEnd] = useState('17')
// Resturlaub-Verfall
const [carryoverMonth, setCarryoverMonth] = useState('')
const [carryoverDay, setCarryoverDay] = useState('')
// Personalnummern
const [pnRequired, setPnRequired] = useState(false)
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
const [pnNext, setPnNext] = useState(1)
// Busylight
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
const [blBusy, setBlBusy] = useState(false)
const [blCopied, setBlCopied] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<UserOut>('/auth/me').then(setMe).catch(() => {})
api.get<CompanyOut>('/companies/me').then(c => {
setCompany(c)
setName(c.name)
setState(c.state ?? '')
const s = c.settings as Record<string, string>
setCoreStart(s.core_start ?? '8')
setCoreEnd(s.core_end ?? '17')
setCarryoverMonth(s.carryover_expires_month?.toString() ?? '')
setCarryoverDay(s.carryover_expires_day?.toString() ?? '')
setPnRequired(c.personnel_number_required ?? false)
setPnMode(c.personnel_number_mode ?? 'manual')
setPnNext(c.personnel_number_next ?? 1)
}).catch(() => {})
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
.catch(() => {})
}, [])
async function rotateBusylightToken() {
setBlBusy(true)
setError(null)
try {
const res = await api.post<{ token: string; created_at: string }>(
'/companies/me/busylight-token/rotate', {}
)
setBlPlaintext(res.token)
setBlStatus({ configured: true, created_at: res.created_at })
setBlCopied(false)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Generieren des Tokens')
} finally {
setBlBusy(false)
}
}
async function revokeBusylightToken() {
if (!confirm('Token wirklich deaktivieren? busylight kann danach keine Daten mehr abrufen.')) return
setBlBusy(true)
setError(null)
try {
await api.del('/companies/me/busylight-token')
setBlStatus({ configured: false, created_at: null })
setBlPlaintext(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren')
} finally {
setBlBusy(false)
}
}
async function copyBusylightToken() {
if (!blPlaintext) return
try {
await navigator.clipboard.writeText(blPlaintext)
setBlCopied(true)
setTimeout(() => setBlCopied(false), 2000)
} catch {
/* clipboard nicht verfügbar User kann manuell markieren */
}
}
async function save() {
setSaving(true)
setError(null)
setSaved(false)
try {
const updated = await api.patch<CompanyOut>('/companies/me', {
name: name.trim() || undefined,
state: state || null,
settings: {
...(company?.settings ?? {}),
core_start: coreStart,
core_end: coreEnd,
carryover_expires_month: carryoverMonth ? parseInt(carryoverMonth) : null,
carryover_expires_day: carryoverDay ? parseInt(carryoverDay) : null,
},
personnel_number_required: pnRequired,
personnel_number_mode: pnMode,
personnel_number_next: pnNext,
})
setCompany(updated)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN'
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString())
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Firmen-Einstellungen</h1>
{/* Allgemein */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<h2 className="font-semibold text-gray-700">Allgemein</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
disabled={!isAdmin}
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 disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bundesland
<span className="ml-1 text-xs text-gray-400 font-normal">(für Feiertagskalender)</span>
</label>
<select
value={state}
onChange={e => setState(e.target.value)}
disabled={!isAdmin}
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 disabled:bg-gray-50 disabled:text-gray-500"
>
<option value=""> Bundesland wählen </option>
{BUNDESLAENDER.map(bl => (
<option key={bl.code} value={bl.code}>{bl.name}</option>
))}
</select>
{state && (
<p className="mt-1 text-xs text-blue-600">
Feiertage für <strong>{BUNDESLAENDER.find(b => b.code === state)?.name}</strong> werden im Kalender und bei der Stundenauswertung berücksichtigt.
</p>
)}
{!state && (
<p className="mt-1 text-xs text-amber-600">
Ohne Bundesland können keine Feiertage automatisch erkannt werden.
</p>
)}
</div>
</div>
{/* Arbeitszeitregeln */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Arbeitszeitregeln</h2>
<p className="text-xs text-gray-400 mt-0.5">
Grundlage für die Auswertung von Sonderstunden im Bericht (für den Steuerberater).
</p>
</div>
{/* Kernarbeitszeit */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kernarbeitszeit</label>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Von</span>
<select
value={coreStart}
onChange={e => setCoreStart(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
{hourOptions.map(h => (
<option key={h} value={h}>{h.padStart(2, '0')}:00 Uhr</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">bis</span>
<select
value={coreEnd}
onChange={e => setCoreEnd(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
{hourOptions.map(h => (
<option key={h} value={h}>{h.padStart(2, '0')}:00 Uhr</option>
))}
</select>
</div>
</div>
<p className="mt-1 text-xs text-gray-400">
Stunden außerhalb dieser Zeit gelten als Überstunden/Nachtarbeit.
</p>
</div>
{/* Nachtarbeit gesetzlich fixiert */}
<div className="rounded-lg bg-gray-50 border border-gray-200 p-4 space-y-2">
<p className="text-sm font-medium text-gray-700">Nachtarbeit gesetzlich fixiert</p>
<div className="grid grid-cols-1 gap-1 text-xs text-gray-600">
<div className="flex justify-between">
<span>20:0024:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between">
<span>00:0004:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">40 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between">
<span>04:0006:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between mt-1">
<span>Sonntagsarbeit (00:0024:00)</span>
<span className="font-medium text-purple-700">50 % Zuschlag</span>
</div>
<div className="flex justify-between">
<span>Feiertagsarbeit (gesetzliche Feiertage)</span>
<span className="font-medium text-red-700">125 % / 150 % Zuschlag</span>
</div>
</div>
<p className="text-xs text-gray-400 pt-1">
Diese Werte sind durch §3b EStG gesetzlich vorgegeben und können nicht geändert werden.
Bei Überschneidung gilt stets der höchste Zuschlag.
</p>
</div>
</div>
{/* Urlaubsregeln */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Urlaubsregeln</h2>
<p className="text-xs text-gray-400 mt-0.5">
Nicht genommener Urlaub aus dem Vorjahr (Resturlaub) wird automatisch übertragen.
Hier kannst du festlegen, bis wann er genommen werden muss.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Resturlaub verfällt am
</label>
<div className="flex items-center gap-3">
<select
value={carryoverDay}
onChange={e => setCarryoverDay(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
<option value=""> Tag </option>
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
<option key={d} value={d}>{d}.</option>
))}
</select>
<select
value={carryoverMonth}
onChange={e => setCarryoverMonth(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
<option value=""> Monat </option>
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
<option key={i+1} value={i+1}>{m}</option>
))}
</select>
{(carryoverDay || carryoverMonth) && (
<button
onClick={() => { setCarryoverDay(''); setCarryoverMonth('') }}
className="text-xs text-gray-400 hover:text-red-500"
title="Verfall deaktivieren"
>
Kein Verfall
</button>
)}
</div>
{carryoverDay && carryoverMonth ? (
<p className="mt-2 text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
Resturlaub aus dem Vorjahr verfällt jährlich am <strong>{carryoverDay}. {['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][parseInt(carryoverMonth)-1]}</strong>.
Im Urlaubskonto der Mitarbeiter wird das Datum und ob er bereits verfallen ist angezeigt.
</p>
) : (
<p className="mt-2 text-xs text-gray-400">
Kein Verfall konfiguriert Resturlaub wird unbegrenzt übertragen.
</p>
)}
</div>
</div>
{/* Personalnummern */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Personalnummern</h2>
<p className="text-xs text-gray-400 mt-0.5">
Eindeutige numerische ID pro Mitarbeiter. Nach Deaktivierung bleibt die Nummer reserviert (wird nie neu vergeben).
</p>
</div>
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
checked={pnRequired}
onChange={e => setPnRequired(e.target.checked)}
disabled={!isAdmin}
className="mt-1 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-800">Personalnummer ist Pflicht</span>
<p className="text-xs text-gray-400 mt-0.5">
Gilt nur für neu angelegte Mitarbeiter. Bestandsuser ohne Nummer bleiben weiterhin nutzbar.
</p>
</div>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Vergabe-Modus</label>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="pn_mode"
value="manual"
checked={pnMode === 'manual'}
onChange={() => setPnMode('manual')}
disabled={!isAdmin}
className="mt-1 w-4 h-4 text-blue-600"
/>
<div>
<span className="text-sm text-gray-800">Manuell</span>
<p className="text-xs text-gray-400">Admin gibt Personalnr. beim Anlegen manuell ein.</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="pn_mode"
value="auto"
checked={pnMode === 'auto'}
onChange={() => setPnMode('auto')}
disabled={!isAdmin}
className="mt-1 w-4 h-4 text-blue-600"
/>
<div>
<span className="text-sm text-gray-800">Automatisch (fortlaufend)</span>
<p className="text-xs text-gray-400">Server vergibt nächste freie Nummer (4-stellig zero-padded). Manueller Override bleibt möglich.</p>
</div>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nächste Auto-Nummer
<span className="ml-1 text-xs text-gray-400 font-normal">(nur Auto-Modus)</span>
</label>
<input
type="number"
min={1}
value={pnNext}
onChange={e => setPnNext(parseInt(e.target.value) || 1)}
disabled={!isAdmin || pnMode !== 'auto'}
className="w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
/>
<p className="mt-1 text-xs text-gray-400">
Nächste Vergabe: <strong className="font-mono">{String(pnNext).padStart(4, '0')}</strong>
</p>
</div>
</div>
{/* Busylight-Integration */}
{isAdmin && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Busylight-Integration</h2>
<p className="text-xs text-gray-400 mt-0.5">
Externes Busylight-System ruft Anwesenheits-Daten via Bearer-Token ab. Endpoint:{' '}
<code className="font-mono text-[11px] bg-gray-100 px-1 py-0.5 rounded">GET /api/v1/busylight/users</code>.
</p>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700 select-none">
Test-Befehl anzeigen (curl)
</summary>
<div className="mt-2 rounded-lg bg-gray-900 text-gray-100 px-3 py-2 font-mono text-[11px] leading-relaxed whitespace-pre overflow-x-auto">
{`curl -H "Authorization: Bearer <TOKEN>" \\\n ${window.location.origin}/api/v1/busylight/users`}
</div>
<p className="mt-1 text-gray-400">
<code className="font-mono">&lt;TOKEN&gt;</code> durch den beim Generieren angezeigten Klartext-Token ersetzen.
</p>
</details>
<div className="text-sm">
{blStatus?.configured ? (
<div className="flex items-center gap-2 text-green-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
<span>
Token aktiv
{blStatus.created_at && (
<span className="text-gray-400 ml-1">
(seit {new Date(blStatus.created_at).toLocaleString('de-DE')})
</span>
)}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-gray-300" />
<span>Kein Token konfiguriert</span>
</div>
)}
</div>
{blPlaintext && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-2">
<p className="text-xs font-semibold text-amber-800">
Token jetzt kopieren wird nur einmal angezeigt.
</p>
<div className="flex items-stretch gap-2">
<input
type="text"
readOnly
value={blPlaintext}
onFocus={e => e.currentTarget.select()}
className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white"
/>
<button
onClick={copyBusylightToken}
className="px-3 py-2 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700"
>
{blCopied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
<p className="text-xs text-amber-700">
In busylight als <code className="font-mono">Authorization: Bearer </code>-Header hinterlegen.
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={rotateBusylightToken}
disabled={blBusy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{blStatus?.configured ? 'Neuen Token generieren' : 'Token generieren'}
</button>
{blStatus?.configured && (
<button
onClick={revokeBusylightToken}
disabled={blBusy}
className="px-4 py-2 bg-white border border-red-300 text-red-700 text-sm font-medium rounded-lg hover:bg-red-50 disabled:opacity-50"
>
Deaktivieren
</button>
)}
{blStatus?.configured && (
<span className="text-xs text-gray-400">
Beim Neugenerieren wird der bisherige Token sofort ungültig.
</span>
)}
</div>
</div>
)}
{/* Firmen-Info (readonly) */}
{company && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="font-semibold text-gray-700 mb-4">Firmen-Info</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<dt className="text-gray-500">Firmen-ID</dt>
<dd className="text-gray-700 font-mono text-xs">{company.id}</dd>
<dt className="text-gray-500">Slug</dt>
<dd className="text-gray-700">{company.slug}</dd>
<dt className="text-gray-500">Plan</dt>
<dd className="text-gray-700 capitalize">{company.plan}</dd>
<dt className="text-gray-500">Land</dt>
<dd className="text-gray-700">{company.country}</dd>
</dl>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{isAdmin && (
<div className="flex items-center gap-3">
<button
onClick={save}
disabled={saving}
className="px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Speichern…' : 'Speichern'}
</button>
{saved && (
<span className="text-sm text-green-600 font-medium">Gespeichert</span>
)}
</div>
)}
</div>
</Layout>
)
}
+297
View File
@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
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
}
interface EmployeeDashboard {
today_open: boolean
today_start: string | null
today_hours_so_far: number | null
week_hours_worked: number
week_hours_expected: number
week_overtime: number
vacation_remaining_days: number | null
vacation_used_days: number
vacation_entitled_days: number
pending_absences: number
overtime_balance_hours: number | null
schedule_name: string | null
}
interface UpcomingAbsence {
user_name: string
absence_type: string
start_date: string
end_date: string
working_days: number
}
interface CompanyDashboard {
total_employees: number
active_today: number
attendance_rate: number
month_hours_worked: number
month_hours_expected: number
month_overtime: number
pending_time_approvals: number
pending_absence_approvals: number
upcoming_absences: UpcomingAbsence[]
}
function StatCard({
label, value, sub, highlight,
}: {
label: string
value: string | number
sub?: string
highlight?: 'green' | 'red' | 'yellow'
}) {
const colors = {
green: 'border-l-4 border-green-400',
red: 'border-l-4 border-red-400',
yellow: 'border-l-4 border-yellow-400',
}
return (
<div className={`bg-white rounded-xl shadow-sm p-4 ${highlight ? colors[highlight] : 'border border-gray-100'}`}>
<p className='text-xs text-gray-500 uppercase tracking-wide font-medium'>{label}</p>
<p className='text-2xl font-bold text-gray-800 mt-1'>{value}</p>
{sub && <p className='text-xs text-gray-400 mt-0.5'>{sub}</p>}
</div>
)
}
export function DashboardPage() {
const { logout } = useAuth()
const [user, setUser] = useState<UserOut | null>(null)
const [emp, setEmp] = useState<EmployeeDashboard | null>(null)
const [comp, setComp] = useState<CompanyDashboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function load() {
try {
const [me, empDash] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<EmployeeDashboard>('/dashboard/me'),
])
setUser(me)
setEmp(empDash)
const adminRoles = ['COMPANY_ADMIN', 'SUPER_ADMIN']
if (adminRoles.includes(me.role)) {
try {
setComp(await api.get<CompanyDashboard>('/dashboard/company'))
} catch {
// kein Admin-Zugriff still ignorieren
}
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) {
return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Spinner />
</div>
)
}
if (error) {
return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<div className='bg-white rounded-xl shadow p-6 text-center'>
<p className='text-red-600 font-medium'>{error}</p>
<button onClick={logout} className='mt-4 text-sm text-gray-500 underline'>
Abmelden
</button>
</div>
</div>
)
}
const overtimePositive = (emp?.week_overtime ?? 0) >= 0
return (
<Layout userRole={user?.role ?? ''} userName={`${user?.first_name ?? ''} ${user?.last_name ?? ''}`}>
<div className='space-y-8'>
{/* Heutiger Status */}
{emp && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium ${
emp.today_open
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-gray-100 text-gray-600 border border-gray-200'
}`}>
<span className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${
emp.today_open ? 'bg-green-500 animate-pulse' : 'bg-gray-400'
}`} />
{emp.today_open
? <>
Heute eingestempelt um {emp.today_start?.slice(0, 5) ?? '—'}
{emp.today_hours_so_far != null && (
<span className='text-green-600 font-semibold ml-1'>
· {emp.today_hours_so_far.toFixed(1)} h bisher
</span>
)}
</>
: 'Heute noch nicht eingestempelt'
}
</div>
)}
{/* Meine Übersicht */}
{emp && (
<section>
<h2 className='text-base font-semibold text-gray-700 mb-3'>
Meine Übersicht
{emp.schedule_name && (
<span className='ml-2 text-xs font-normal text-gray-400'>
· {emp.schedule_name}
</span>
)}
</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3'>
<StatCard
label='Diese Woche'
value={`${emp.week_hours_worked.toFixed(1)} h`}
sub={`von ${emp.week_hours_expected.toFixed(0)} h Soll`}
/>
<StatCard
label='Überstunden'
value={`${overtimePositive ? '+' : ''}${emp.week_overtime.toFixed(1)} h`}
highlight={overtimePositive ? 'green' : 'red'}
/>
<StatCard
label='Urlaub verbleibend'
value={emp.vacation_remaining_days ?? '—'}
sub={`${emp.vacation_used_days} genutzt / ${emp.vacation_entitled_days} Tage`}
/>
{emp.overtime_balance_hours != null && (
<StatCard
label='Überstunden-Guthaben'
value={`${emp.overtime_balance_hours >= 0 ? '+' : ''}${emp.overtime_balance_hours.toFixed(1)} h`}
sub='verfügbar für Freizeitausgleich'
highlight={emp.overtime_balance_hours > 0 ? 'green' : undefined}
/>
)}
{emp.pending_absences > 0 && (
<StatCard
label='Offene Anträge'
value={emp.pending_absences}
highlight='yellow'
/>
)}
</div>
</section>
)}
{/* Unternehmens-Dashboard (nur Admin) */}
{comp && (
<section>
<h2 className='text-base font-semibold text-gray-700 mb-3'>Unternehmensübersicht</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4'>
<StatCard label='Mitarbeiter' value={comp.total_employees} />
<StatCard
label='Heute anwesend'
value={comp.active_today}
sub={`${comp.attendance_rate.toFixed(0)} % Anwesenheitsrate`}
highlight={comp.attendance_rate >= 70 ? 'green' : 'yellow'}
/>
<StatCard
label='Monat gearbeitet'
value={`${comp.month_hours_worked.toFixed(0)} h`}
sub={`von ${comp.month_hours_expected.toFixed(0)} h Soll`}
/>
<StatCard
label='Monat Überstunden'
value={`${comp.month_overtime >= 0 ? '+' : ''}${comp.month_overtime.toFixed(1)} h`}
highlight={comp.month_overtime >= 0 ? 'green' : 'red'}
/>
</div>
{/* Ausstehende Genehmigungen */}
{(comp.pending_time_approvals > 0 || comp.pending_absence_approvals > 0) && (
<div className='flex flex-wrap gap-2 mb-4'>
{comp.pending_time_approvals > 0 && (
<Link to='/time?status=pending' className='bg-yellow-100 text-yellow-800 text-xs px-3 py-1.5 rounded-full font-medium hover:bg-yellow-200 transition-colors'>
{comp.pending_time_approvals} Zeiteinträge warten auf Genehmigung
</Link>
)}
{comp.pending_absence_approvals > 0 && (
<Link to='/absences?status=pending' className='bg-yellow-100 text-yellow-800 text-xs px-3 py-1.5 rounded-full font-medium hover:bg-yellow-200 transition-colors'>
{comp.pending_absence_approvals} Abwesenheitsanträge warten auf Genehmigung
</Link>
)}
</div>
)}
{/* Kommende Abwesenheiten */}
{comp.upcoming_absences.length > 0 && (
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='px-4 py-3 border-b border-gray-100'>
<h3 className='text-sm font-semibold text-gray-700'>
Kommende Abwesenheiten (nächste 14 Tage)
</h3>
</div>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Mitarbeiter', 'Typ', 'Von', 'Bis', 'Tage'].map(h => (
<th key={h} className='px-4 py-2.5 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{comp.upcoming_absences.map((a, i) => (
<tr key={i} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-2.5 font-medium text-gray-800'>{a.user_name}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.absence_type}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.start_date}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.end_date}</td>
<td className='px-4 py-2.5 text-gray-600'>{a.working_days}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
)}
{/* API Docs Link (nur dev) */}
<div className='text-center pt-4 border-t border-gray-200'>
<a
href='/docs'
target='_blank'
rel='noreferrer'
className='text-xs text-gray-400 hover:text-blue-500 transition-colors'
>
API Dokumentation
</a>
</div>
</div>
</Layout>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client'
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/auth/password-reset', { email })
setSent(true)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler')
} finally {
setLoading(false)
}
}
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4'>
<div className='w-full max-w-sm bg-white rounded-2xl shadow-lg p-8'>
<div className='flex items-center gap-2 mb-6'>
<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='text-lg font-bold text-gray-800'>TimeMaster</span>
</div>
{sent ? (
<div>
<h1 className='text-xl font-semibold text-gray-800 mb-2'>E-Mail verschickt</h1>
<p className='text-sm text-gray-500 mb-6'>
Falls <strong>{email}</strong> bei uns registriert ist, erhältst du in Kürze einen
Link zum Zurücksetzen deines Passworts. Der Link ist 1 Stunde gültig.
</p>
<Link to='/login' className='text-sm text-blue-600 hover:underline'>
Zurück zur Anmeldung
</Link>
</div>
) : (
<>
<h1 className='text-xl font-semibold text-gray-800 mb-1'>Passwort vergessen?</h1>
<p className='text-sm text-gray-500 mb-6'>
Gib deine E-Mail-Adresse ein. Du erhältst einen Reset-Link, sofern ein lokales Konto existiert.
</p>
<form onSubmit={submit} className='space-y-4'>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>E-Mail-Adresse</span>
<input
type='email'
value={email}
onChange={e => setEmail(e.target.value)}
required
autoFocus
placeholder='max@muster-gmbh.de'
className='mt-1 w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
/>
</label>
{error && (
<div className='text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5'>
{error}
</div>
)}
<button
type='submit'
disabled={loading}
className='w-full py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'
>
{loading ? 'Sende…' : 'Reset-Link anfordern'}
</button>
</form>
<p className='text-center text-sm text-gray-500 mt-4'>
<Link to='/login' className='text-blue-600 hover:underline'> Zurück zur Anmeldung</Link>
</p>
</>
)}
</div>
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
}
interface PreviewEntry {
kind: 'time' | 'absence'
date_from: string
date_to: string
start: string | null
end: string | null
break_minutes: number
worked_hours: number | null
absence_type: string | null
note: string | null
skipped: boolean
skip_reason: string | null
}
interface PreviewResponse {
preview: PreviewEntry[]
time_count: number
absence_count: number
skip_count: number
errors: string[]
}
interface RunResponse {
time_imported: number
absence_imported: number
skipped: number
errors: string[]
}
export default function ImportPage() {
const [me, setMe] = useState<UserOut | null>(null)
const [users, setUsers] = useState<UserOut[]>([])
const [targetUserId, setTargetUserId] = useState('')
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<PreviewResponse | null>(null)
const [result, setResult] = useState<RunResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<UserOut>('/auth/me').then(setMe).catch(() => {})
api.get<{ total: number; items: UserOut[] }>('/users/?limit=500')
.then(r => setUsers(r.items)).catch(() => {})
}, [])
async function loadPreview() {
if (!targetUserId || !file) return
setLoading(true)
setError(null)
setPreview(null)
setResult(null)
try {
const form = new FormData()
form.append('user_id', targetUserId)
form.append('file', file)
const r = await api.postForm<PreviewResponse>('/import/kimai/preview', form)
setPreview(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Vorschau')
} finally {
setLoading(false)
}
}
async function runImport() {
if (!targetUserId || !file) return
setLoading(true)
setError(null)
setResult(null)
try {
const form = new FormData()
form.append('user_id', targetUserId)
form.append('file', file)
const r = await api.postForm<RunResponse>('/import/kimai/run', form)
setResult(r)
setPreview(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Import')
} finally {
setLoading(false)
}
}
const selectedUser = users.find(u => u.id === targetUserId)
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-5xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Kimai-Import</h1>
{/* Konfiguration */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold text-gray-700">Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mitarbeiter (Ziel)
</label>
<select
value={targetUserId}
onChange={e => { setTargetUserId(e.target.value); setPreview(null); setResult(null) }}
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=""> Mitarbeiter wählen </option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name} ({u.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kimai CSV-Datei
</label>
<input
type="file"
accept=".csv"
onChange={e => { setFile(e.target.files?.[0] ?? null); setPreview(null); setResult(null) }}
className="w-full text-sm text-gray-700 border border-gray-300 rounded-lg px-3 py-2 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={loadPreview}
disabled={!targetUserId || !file || loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-blue-700"
>
{loading && !preview ? 'Lade Vorschau…' : 'Vorschau laden'}
</button>
{preview && (
<button
onClick={runImport}
disabled={loading || (preview.time_count + preview.absence_count === 0)}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-green-700"
>
{loading ? 'Importiere…' : `Import starten (${preview.time_count + preview.absence_count} Einträge)`}
</button>
)}
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{/* Ergebnis nach Import */}
{result && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-2">
<h2 className="font-semibold text-green-800 text-lg">Import abgeschlossen</h2>
<div className="flex gap-6 text-sm text-green-700">
<span><strong>{result.time_imported}</strong> Zeiteinträge importiert</span>
<span><strong>{result.absence_imported}</strong> Abwesenheiten importiert</span>
{result.skipped > 0 && <span className="text-amber-600"><strong>{result.skipped}</strong> übersprungen</span>}
</div>
{result.errors.length > 0 && (
<ul className="mt-2 text-sm text-red-600 space-y-1">
{result.errors.map((e, i) => <li key={i}> {e}</li>)}
</ul>
)}
</div>
)}
{/* Vorschau-Tabelle */}
{preview && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="font-semibold text-gray-700">
Vorschau für <span className="text-blue-600">
{selectedUser ? `${selectedUser.first_name} ${selectedUser.last_name}` : ''}
</span>
</h2>
<div className="flex gap-4 text-sm text-gray-500">
<span className="text-blue-600 font-medium">{preview.time_count} Zeiteinträge</span>
<span className="text-purple-600 font-medium">{preview.absence_count} Abwesenheiten</span>
{preview.skip_count > 0 && (
<span className="text-amber-600 font-medium">{preview.skip_count} übersprungen</span>
)}
</div>
</div>
{preview.errors.length > 0 && (
<div className="px-6 py-3 bg-red-50 border-b border-red-100">
{preview.errors.map((e, i) => (
<p key={i} className="text-sm text-red-600"> {e}</p>
))}
</div>
)}
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Datum</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Beginn</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Ende</th>
<th className="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase">Stunden</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Notiz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{preview.preview.map((row, i) => (
<tr key={i} className={row.skipped ? 'opacity-40 bg-gray-50' : ''}>
<td className="px-4 py-2">
{row.kind === 'time' ? (
<span className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">Zeit</span>
) : (
<span className="px-2 py-0.5 rounded-full text-xs bg-purple-100 text-purple-700">
{row.absence_type ?? 'Abwesenheit'}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-700">
{row.date_from}
{row.date_to !== row.date_from && (
<span className="text-gray-400"> {row.date_to}</span>
)}
</td>
<td className="px-4 py-2 text-gray-700">{row.start ?? ''}</td>
<td className="px-4 py-2 text-gray-700">
{row.end ?? ''}
{row.break_minutes > 0 && (
<span className="ml-1 text-xs text-gray-400">({row.break_minutes}m Pause)</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-700">
{row.worked_hours != null ? `${row.worked_hours.toFixed(2)} h` : ''}
</td>
<td className="px-4 py-2 text-gray-500 max-w-xs truncate" title={row.note ?? ''}>
{row.note ?? ''}
</td>
<td className="px-4 py-2">
{row.skipped ? (
<span className="text-xs text-amber-600" title={row.skip_reason ?? ''}>
Übersprungen
</span>
) : (
<span className="text-xs text-green-600">Import</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Layout>
)
}
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import { Modal } from '../components/Modal'
interface KioskDevice {
id: string
company_id: string
name: string
location: string | null
is_active: boolean
last_seen_at: string | null
created_at: string
}
interface Me {
first_name: string
last_name: string
role: string
}
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
export function KioskDevicesPage() {
const [me, setMe] = useState<Me | null>(null)
const [devices, setDevices] = useState<KioskDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Create modal
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
const [createLocation, setCreateLocation] = useState('')
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState('')
// Token display modal (after create / rotate)
const [shownToken, setShownToken] = useState<{ deviceName: string; token: string } | null>(null)
const [tokenCopied, setTokenCopied] = useState(false)
// Edit modal
const [editDevice, setEditDevice] = useState<KioskDevice | null>(null)
const [editName, setEditName] = useState('')
const [editLocation, setEditLocation] = useState('')
const [editActive, setEditActive] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
async function load() {
try {
const [meData, devicesData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<KioskDevice[]>('/kiosk/devices'),
])
setMe(meData)
setDevices(devicesData)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
async function handleCreate() {
setCreateError('')
if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return }
setCreateLoading(true)
try {
const result = await api.post<KioskDevice & { token: string }>('/kiosk/devices', {
name: createName.trim(),
location: createLocation.trim() || null,
})
setShownToken({ deviceName: result.name, token: result.token })
setShowCreate(false)
setCreateName('')
setCreateLocation('')
load()
} catch (e: unknown) {
setCreateError(e instanceof Error ? e.message : 'Fehler')
} finally {
setCreateLoading(false)
}
}
async function handleEdit() {
if (!editDevice) return
setEditError('')
if (!editName.trim()) { setEditError('Name ist erforderlich.'); return }
setEditLoading(true)
try {
await api.patch(`/kiosk/devices/${editDevice.id}`, {
name: editName.trim(),
location: editLocation.trim() || null,
is_active: editActive,
})
setEditDevice(null)
load()
} catch (e: unknown) {
setEditError(e instanceof Error ? e.message : 'Fehler')
} finally {
setEditLoading(false)
}
}
async function handleRotateToken(device: KioskDevice) {
if (!confirm(`Token für „${device.name}" wirklich rotieren? Das alte Token wird sofort ungültig.`)) return
try {
const result = await api.post<KioskDevice & { token: string }>(`/kiosk/devices/${device.id}/rotate-token`, {})
setShownToken({ deviceName: result.name, token: result.token })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleDelete(device: KioskDevice) {
if (!confirm(`Gerät „${device.name}" wirklich löschen?`)) return
try {
await api.del(`/kiosk/devices/${device.id}`)
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
function openEdit(device: KioskDevice) {
setEditDevice(device)
setEditName(device.name)
setEditLocation(device.location ?? '')
setEditActive(device.is_active)
setEditError('')
}
function copyToken(token: string) {
navigator.clipboard.writeText(token).then(() => {
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 2000)
})
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
if (loading) return <div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
if (error) return <div className='min-h-screen bg-gray-50 flex items-center justify-center'><p className='text-red-600'>{error}</p></div>
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<div>
<h1 className='text-xl font-bold text-gray-800'>Kiosk-Geräte</h1>
<p className='text-sm text-gray-500 mt-0.5'>{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert</p>
</div>
<button
onClick={() => { setShowCreate(true); setCreateName(''); setCreateLocation(''); setCreateError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Gerät hinzufügen
</button>
</div>
{/* Info-Hinweis */}
<div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800'>
Kiosk-Geräte authentifizieren sich per <code className='bg-blue-100 px-1 rounded text-xs font-mono'>X-Kiosk-Token</code> Header.
Der Token wird nur einmalig bei der Erstellung angezeigt bitte sofort sichern.
</div>
{/* Tabelle */}
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', 'Angelegt', ''].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{devices.map(d => (
<tr key={d.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td>
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td>
<td className='px-4 py-3'>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${
d.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'
}`}>
{d.is_active ? 'Aktiv' : 'Deaktiviert'}
</span>
</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_seen_at)}</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.created_at)}</td>
<td className='px-4 py-3'>
<div className='flex gap-3 justify-end'>
<button onClick={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
<button onClick={() => handleRotateToken(d)} className='text-xs text-yellow-600 hover:underline'>Token rotieren</button>
<button onClick={() => handleDelete(d)} className='text-xs text-red-500 hover:underline'>Löschen</button>
</div>
</td>
</tr>
))}
{devices.length === 0 && (
<tr>
<td colSpan={6} className='px-4 py-12 text-center text-gray-400'>
Noch keine Kiosk-Geräte angelegt.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Gerät erstellen Modal */}
{showCreate && (
<Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={createName}
onChange={e => setCreateName(e.target.value)}
className={inputClass}
placeholder='z.B. Eingang Büro'
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={createLocation}
onChange={e => setCreateLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
{createError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{createError}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowCreate(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={handleCreate} disabled={createLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{createLoading ? 'Erstelle…' : 'Erstellen'}
</button>
</div>
</div>
</Modal>
)}
{/* Token-Anzeige Modal */}
{shownToken && (
<Modal title='Gerät-Token jetzt sichern!' onClose={() => { setShownToken(null); setTokenCopied(false) }}>
<div className='space-y-4'>
<p className='text-sm text-gray-600'>
Token für <strong>{shownToken.deviceName}</strong>. Dieser Token wird <strong>nur einmalig</strong> angezeigt und kann danach nicht mehr abgerufen werden.
</p>
<div className='bg-gray-900 rounded-lg px-4 py-3 font-mono text-xs text-green-400 break-all select-all'>
{shownToken.token}
</div>
<button
onClick={() => copyToken(shownToken.token)}
className={`w-full py-2 text-sm font-semibold rounded-lg transition-colors ${
tokenCopied
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tokenCopied ? 'Kopiert!' : 'In Zwischenablage kopieren'}
</button>
<div className='flex justify-end'>
<button
onClick={() => { setShownToken(null); setTokenCopied(false) }}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700'
>
Token gesichert Schließen
</button>
</div>
</div>
</Modal>
)}
{/* Bearbeiten Modal */}
{editDevice && (
<Modal title={`Gerät bearbeiten ${editDevice.name}`} onClose={() => setEditDevice(null)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className={inputClass}
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={editLocation}
onChange={e => setEditLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='checkbox'
checked={editActive}
onChange={e => setEditActive(e.target.checked)}
className='w-4 h-4 rounded border-gray-300 text-blue-600'
/>
<span className='text-sm text-gray-700'>Gerät aktiv</span>
</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={() => setEditDevice(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={handleEdit} disabled={editLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{editLoading ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
interface Me {
first_name: string
last_name: string
role: string
}
interface LdapConfig {
id: string
enabled: boolean
host: string
port: number
use_ssl: boolean
use_tls: boolean
bind_dn: string
base_dn: string
user_search_filter: string
attr_email: string
attr_firstname: string
attr_lastname: string
attr_username: string
attr_department: string | null
last_sync_at: string | null
}
interface LdapUserPreview {
dn: string
email: string
first_name: string
last_name: string
department: string | null
}
interface SyncResult {
created: number
updated: number
deactivated: number
errors: string[]
}
type FormState = {
host: string
port: string
use_ssl: boolean
use_tls: boolean
bind_dn: string
bind_password: string
base_dn: string
user_search_filter: string
attr_email: string
attr_firstname: string
attr_lastname: string
attr_username: string
attr_department: string
enabled: boolean
}
const DEFAULTS: FormState = {
host: '', port: '389', use_ssl: false, use_tls: false,
bind_dn: '', bind_password: '', base_dn: '',
user_search_filter: '(objectClass=person)',
attr_email: 'mail', attr_firstname: 'givenName',
attr_lastname: 'sn', attr_username: 'sAMAccountName',
attr_department: '', enabled: false,
}
function configToForm(c: LdapConfig): FormState {
return {
host: c.host, port: String(c.port),
use_ssl: c.use_ssl, use_tls: c.use_tls,
bind_dn: c.bind_dn, bind_password: '',
base_dn: c.base_dn, user_search_filter: c.user_search_filter,
attr_email: c.attr_email, attr_firstname: c.attr_firstname,
attr_lastname: c.attr_lastname, attr_username: c.attr_username,
attr_department: c.attr_department ?? '', enabled: c.enabled,
}
}
export function LdapSettingsPage() {
const [me, setMe] = useState<Me | null>(null)
const [config, setConfig] = useState<LdapConfig | null>(null)
const [form, setForm] = useState<FormState>(DEFAULTS)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState('')
const [saveErr, setSaveErr] = useState('')
const [testing, setTesting] = useState(false)
const [testMsg, setTestMsg] = useState('')
const [testOk, setTestOk] = useState<boolean | null>(null)
const [previewing, setPreviewing] = useState(false)
const [preview, setPreview] = useState<LdapUserPreview[] | null>(null)
const [syncing, setSyncing] = useState(false)
const [syncResult, setSyncResult] = useState<SyncResult | null>(null)
async function load() {
try {
const [meData, cfgData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<LdapConfig | null>('/ldap/config'),
])
setMe(meData)
if (cfgData) {
setConfig(cfgData)
setForm(configToForm(cfgData))
}
} catch (e: unknown) {
console.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
function upd(field: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
}
async function handleSave() {
setSaveMsg(''); setSaveErr(''); setSaving(true)
try {
const payload: Record<string, unknown> = {
...form,
port: parseInt(form.port),
attr_department: form.attr_department || null,
}
if (!form.bind_password) delete payload.bind_password
if (config) {
await api.patch('/ldap/config', payload)
} else {
await api.post('/ldap/config', payload)
}
setSaveMsg('Gespeichert.')
load()
} catch (e: unknown) {
setSaveErr(e instanceof Error ? e.message : 'Fehler')
} finally {
setSaving(false)
}
}
async function handleTest() {
setTesting(true); setTestMsg(''); setTestOk(null)
try {
const res = await api.post<{ success: boolean; message: string }>('/ldap/test', {})
setTestOk(res.success)
setTestMsg(res.message)
} catch (e: unknown) {
setTestOk(false)
setTestMsg(e instanceof Error ? e.message : 'Fehler')
} finally {
setTesting(false)
}
}
async function handlePreview() {
setPreviewing(true); setPreview(null)
try {
const data = await api.get<LdapUserPreview[]>('/ldap/preview')
setPreview(data)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
} finally {
setPreviewing(false)
}
}
async function handleSync() {
if (!confirm('LDAP-Benutzer synchronisieren? Neue Benutzer werden angelegt, nicht gefundene deaktiviert.')) return
setSyncing(true); setSyncResult(null)
try {
const res = await api.post<SyncResult>('/ldap/sync', { default_role: 'EMPLOYEE' })
setSyncResult(res)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
} finally {
setSyncing(false)
}
}
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
const labelClass = 'block text-xs font-medium text-gray-700 mb-1'
if (loading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='max-w-2xl space-y-6'>
<div>
<h1 className='text-xl font-bold text-gray-800'>LDAP-Anbindung</h1>
<p className='text-sm text-gray-500 mt-0.5'>
Verbindet TimeMaster mit einem ActiveDirectory oder OpenLDAP-Server.
</p>
</div>
{/* Status Banner */}
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium border ${
config?.enabled
? 'bg-green-50 text-green-700 border-green-200'
: 'bg-gray-100 text-gray-600 border-gray-200'
}`}>
<span className={`h-2 w-2 rounded-full flex-shrink-0 ${config?.enabled ? 'bg-green-500' : 'bg-gray-400'}`} />
{config?.enabled ? 'LDAP aktiv' : 'LDAP deaktiviert'}
{config?.last_sync_at && (
<span className='ml-auto text-xs font-normal'>
Letzter Sync: {new Date(config.last_sync_at).toLocaleString('de-DE')}
</span>
)}
</div>
{/* Form */}
<div className='bg-white rounded-xl border border-gray-100 shadow-sm p-6 space-y-4'>
<h2 className='text-sm font-semibold text-gray-700'>Server-Konfiguration</h2>
<div className='flex gap-3'>
<div className='flex-1'>
<label className={labelClass}>Host *</label>
<input value={form.host} onChange={upd('host')} className={inputClass} placeholder='ldap.firma.de' />
</div>
<div className='w-24'>
<label className={labelClass}>Port</label>
<input value={form.port} onChange={upd('port')} className={inputClass} placeholder='389' />
</div>
</div>
<div className='flex gap-6'>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.use_ssl} onChange={upd('use_ssl')} className='rounded' />
SSL (LDAPS)
</label>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.use_tls} onChange={upd('use_tls')} className='rounded' />
STARTTLS
</label>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.enabled} onChange={upd('enabled')} className='rounded' />
Aktiviert
</label>
</div>
<div>
<label className={labelClass}>Bind DN *</label>
<input value={form.bind_dn} onChange={upd('bind_dn')} className={inputClass}
placeholder='cn=service,dc=firma,dc=de' />
</div>
<div>
<label className={labelClass}>Bind Passwort {config ? '(leer lassen = unverändert)' : '*'}</label>
<input type='password' value={form.bind_password} onChange={upd('bind_password')} className={inputClass} />
</div>
<div>
<label className={labelClass}>Base DN *</label>
<input value={form.base_dn} onChange={upd('base_dn')} className={inputClass}
placeholder='ou=users,dc=firma,dc=de' />
</div>
<div>
<label className={labelClass}>Suchfilter</label>
<input value={form.user_search_filter} onChange={upd('user_search_filter')} className={inputClass} />
</div>
<h2 className='text-sm font-semibold text-gray-700 pt-2'>Attribut-Mapping</h2>
<div className='grid grid-cols-2 gap-3'>
{([
['attr_email', 'E-Mail Attribut'],
['attr_firstname', 'Vorname Attribut'],
['attr_lastname', 'Nachname Attribut'],
['attr_username', 'Benutzername Attribut'],
['attr_department', 'Abteilung Attribut (optional)'],
] as [keyof FormState, string][]).map(([field, label]) => (
<div key={field}>
<label className={labelClass}>{label}</label>
<input value={form[field] as string} onChange={upd(field)} className={inputClass} />
</div>
))}
</div>
{saveErr && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{saveErr}</p>}
{saveMsg && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{saveMsg}</p>}
<div className='flex gap-2 pt-2'>
<button
onClick={handleSave}
disabled={saving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors'
>
{saving ? 'Speichere…' : config ? 'Aktualisieren' : 'Konfiguration speichern'}
</button>
</div>
</div>
{/* Test + Sync (only if config exists) */}
{config && (
<div className='bg-white rounded-xl border border-gray-100 shadow-sm p-6 space-y-4'>
<h2 className='text-sm font-semibold text-gray-700'>Verbindung & Synchronisation</h2>
<div className='flex flex-wrap gap-2'>
<button
onClick={handleTest}
disabled={testing}
className='px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors'
>
{testing ? 'Teste…' : 'Verbindung testen'}
</button>
<button
onClick={handlePreview}
disabled={previewing}
className='px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors'
>
{previewing ? 'Lädt…' : 'Vorschau (50 Benutzer)'}
</button>
<button
onClick={handleSync}
disabled={syncing}
className='px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors'
>
{syncing ? 'Synchronisiere…' : 'Jetzt synchronisieren'}
</button>
</div>
{testMsg && (
<div className={`text-sm px-3 py-2 rounded border ${
testOk ? 'text-green-700 bg-green-50 border-green-200' : 'text-red-700 bg-red-50 border-red-200'
}`}>
{testOk ? '✓ ' : '✗ '}{testMsg}
</div>
)}
{syncResult && (
<div className='text-sm bg-blue-50 border border-blue-200 rounded px-3 py-2 space-y-1'>
<p className='font-medium text-blue-800'>Sync abgeschlossen</p>
<p className='text-blue-700'>Erstellt: {syncResult.created} · Aktualisiert: {syncResult.updated} · Deaktiviert: {syncResult.deactivated}</p>
{syncResult.errors.length > 0 && (
<p className='text-red-600'>Fehler: {syncResult.errors.join(', ')}</p>
)}
</div>
)}
{preview && (
<div className='overflow-hidden rounded-lg border border-gray-200'>
<div className='px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600'>
{preview.length} Benutzer gefunden
</div>
<div className='overflow-x-auto max-h-64'>
<table className='w-full text-xs'>
<thead className='bg-gray-50 text-gray-500'>
<tr>
{['E-Mail', 'Vorname', 'Nachname', 'DN'].map(h => (
<th key={h} className='px-3 py-2 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{preview.map((u, i) => (
<tr key={i} className='hover:bg-gray-50'>
<td className='px-3 py-1.5 text-gray-700'>{u.email}</td>
<td className='px-3 py-1.5 text-gray-600'>{u.first_name}</td>
<td className='px-3 py-1.5 text-gray-600'>{u.last_name}</td>
<td className='px-3 py-1.5 text-gray-400 font-mono truncate max-w-xs'>{u.dn}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
</Layout>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function LoginPage() {
const { login } = useAuth()
const location = useLocation()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const successMsg = (location.state as { message?: string } | null)?.message
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen')
} finally {
setLoading(false)
}
}
const inputClass =
'mt-1 w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4'>
<div className='w-full max-w-sm bg-white rounded-2xl shadow-lg p-8'>
{/* Logo */}
<div className='flex items-center gap-2 mb-6'>
<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='text-lg font-bold text-gray-800'>TimeMaster</span>
</div>
<h1 className='text-xl font-semibold text-gray-800 mb-1'>Anmelden</h1>
<p className='text-sm text-gray-500 mb-6'>Willkommen zurück</p>
{successMsg && (
<div className='text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2.5 mb-4'>
{successMsg}
</div>
)}
<form onSubmit={handleSubmit} className='space-y-4'>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>E-Mail-Adresse</span>
<input
type='email'
value={email}
onChange={e => setEmail(e.target.value)}
required
autoFocus
className={inputClass}
placeholder='max@muster-gmbh.de'
/>
</label>
<label className='block'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium text-gray-700'>Passwort</span>
<Link to='/forgot-password' className='text-xs text-blue-600 hover:underline'>
Vergessen?
</Link>
</div>
<input
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
required
className={inputClass}
/>
</label>
{error && (
<div className='text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5'>
{error}
</div>
)}
<button
type='submit'
disabled={loading}
className='w-full py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors'
>
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
<p className='text-center text-sm text-gray-500 mt-6'>
Noch kein Konto?{' '}
<Link to='/register' className='text-blue-600 hover:underline font-medium'>
Jetzt registrieren
</Link>
</p>
</div>
</div>
)
}
+279
View File
@@ -0,0 +1,279 @@
import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string; personnel_number: string | null
totp_enabled?: boolean
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin', COMPANY_ADMIN: 'Administrator',
HR: 'HR', MANAGER: 'Manager', EMPLOYEE: 'Mitarbeiter',
}
// ── TOTP-Setup-Schritte ───────────────────────────────────────────────────────
type TotpStep = 'idle' | 'setup' | 'confirm' | 'done'
function TotpSection({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
const [step, setStep] = useState<TotpStep>('idle')
const [qrDataUrl, setQrDataUrl] = useState('')
const [secret, setSecret] = useState('')
const [code, setCode] = useState('')
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [showDisable, setShowDisable] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(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'
const startSetup = async () => {
setLoading(true); setError('')
try {
const res = await api.post<{ secret: string; otpauth_uri: string }>('/auth/totp/setup', {})
setSecret(res.secret)
// Secret in DB speichern (ohne Aktivierung)
await api.post('/auth/totp/setup/save', {})
const dataUrl = await QRCode.toDataURL(res.otpauth_uri, { width: 200, margin: 2 })
setQrDataUrl(dataUrl)
setStep('setup')
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const confirmCode = async () => {
if (code.length !== 6) { setError('Code muss 6 Ziffern haben'); return }
setLoading(true); setError('')
try {
await api.post('/auth/totp/confirm', { code })
setStep('done')
setCode('')
onToggle()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Ungültiger Code') }
finally { setLoading(false) }
}
const disableTotp = async () => {
setLoading(true); setError('')
try {
await api.post('/auth/totp/disable', { password: disablePw, code: disableCode })
setShowDisable(false); setDisablePw(''); setDisableCode('')
onToggle()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
// Aktiv-Zustand
if (enabled && step !== 'done') {
return (
<div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='w-2.5 h-2.5 rounded-full bg-green-500 inline-block' />
<span className='text-sm font-medium text-green-700'>Aktiv</span>
</div>
<button onClick={() => setShowDisable(!showDisable)}
className='text-sm text-red-500 hover:text-red-700 hover:underline'>
Deaktivieren
</button>
</div>
{showDisable && (
<div className='mt-4 space-y-3 border-t border-gray-100 pt-4'>
<p className='text-sm text-gray-600'>Zur Bestätigung Passwort und aktuellen TOTP-Code eingeben:</p>
<input type='password' placeholder='Aktuelles Passwort' value={disablePw}
onChange={e => setDisablePw(e.target.value)} className={inp} />
<input type='text' inputMode='numeric' placeholder='6-stelliger Code' maxLength={6}
value={disableCode} onChange={e => setDisableCode(e.target.value.replace(/\D/g, ''))} className={inp} />
{error && <p className='text-sm text-red-600'>{error}</p>}
<div className='flex gap-2'>
<button onClick={disableTotp} disabled={loading || !disablePw || disableCode.length < 6}
className='flex-1 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50'>
{loading ? 'Bitte warten…' : '2FA deaktivieren'}
</button>
<button onClick={() => { setShowDisable(false); setError('') }}
className='px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50'>
Abbrechen
</button>
</div>
</div>
)}
</div>
)
}
// Setup-Flow
if (step === 'idle') {
return (
<div>
<p className='text-sm text-gray-500 mb-4'>
Mit einem Einmalpasswort (TOTP) aus einer Authenticator-App wird dein Konto zusätzlich abgesichert.
Kompatibel mit Google Authenticator, Authy, und anderen TOTP-Apps.
</p>
<button onClick={startSetup} disabled={loading}
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{loading ? 'Bitte warten…' : '2FA einrichten'}
</button>
{error && <p className='text-sm text-red-600 mt-2'>{error}</p>}
</div>
)
}
if (step === 'setup') {
return (
<div className='space-y-4'>
<p className='text-sm text-gray-600'>
<strong>Schritt 1:</strong> Scanne den QR-Code mit deiner Authenticator-App.
</p>
<div className='flex justify-center'>
{qrDataUrl && <img src={qrDataUrl} alt='TOTP QR-Code' className='rounded-lg border border-gray-200 p-2' />}
</div>
<details className='text-xs text-gray-400'>
<summary className='cursor-pointer hover:text-gray-600'>Secret manuell eingeben</summary>
<code className='block mt-2 bg-gray-50 rounded px-3 py-2 text-gray-700 font-mono break-all select-all'>
{secret}
</code>
</details>
<p className='text-sm text-gray-600'>
<strong>Schritt 2:</strong> Gib den 6-stelligen Code aus der App ein:
</p>
<input type='text' inputMode='numeric' placeholder='123456' maxLength={6}
value={code} onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
className={`${inp} text-center text-xl tracking-widest font-mono`} autoFocus />
{error && <p className='text-sm text-red-600'>{error}</p>}
<div className='flex gap-2'>
<button onClick={confirmCode} disabled={loading || code.length < 6}
className='flex-1 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50'>
{loading ? 'Prüfen…' : 'Bestätigen & aktivieren'}
</button>
<button onClick={() => { setStep('idle'); setCode(''); setError('') }}
className='px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50'>
Abbrechen
</button>
</div>
</div>
)
}
// done
return (
<div className='flex items-center gap-2 text-green-700'>
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
<span className='text-sm font-medium'>Zwei-Faktor-Authentifizierung erfolgreich aktiviert!</span>
</div>
)
}
// ── Hauptseite ────────────────────────────────────────────────────────────────
export function ProfilePage() {
const [me, setMe] = useState<UserOut | null>(null)
const [totpEnabled, setTotpEnabled] = useState(false)
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadMe = () => {
api.get<UserOut>('/auth/me').then(u => {
setMe(u)
setTotpEnabled(u.totp_enabled ?? false)
}).catch(() => {})
}
useEffect(() => { loadMe() }, [])
async function changePassword(e: React.FormEvent) {
e.preventDefault()
if (newPw !== confirmPw) { setError('Passwörter stimmen nicht überein'); return }
if (newPw.length < 8) { setError('Mindestens 8 Zeichen'); return }
setSaving(true); setError(null); setSuccess(false)
try {
await api.post('/auth/change-password', { current_password: currentPw, new_password: newPw })
setSuccess(true)
setCurrentPw(''); setNewPw(''); setConfirmPw('')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts')
} finally {
setSaving(false)
}
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className='max-w-lg mx-auto space-y-6'>
<h1 className='text-2xl font-bold text-gray-900'>Mein Profil</h1>
{/* Profil-Info */}
{me && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<div className='flex items-center gap-4'>
<div className='w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xl'>
{me.first_name[0]}{me.last_name[0]}
</div>
<div>
<p className='text-lg font-semibold text-gray-900'>{me.first_name} {me.last_name}</p>
<p className='text-sm text-gray-500'>{me.email}</p>
<span className='inline-block mt-1 text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 font-medium'>
{ROLE_LABELS[me.role] ?? me.role}
</span>
</div>
</div>
{me.personnel_number && (
<div className='mt-4 pt-4 border-t border-gray-100 flex items-center justify-between'>
<span className='text-sm text-gray-500'>Personalnummer</span>
<span className='text-sm font-mono font-semibold text-gray-800'>{me.personnel_number}</span>
</div>
)}
</div>
)}
{/* Zwei-Faktor-Authentifizierung */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<div className='mb-4'>
<h2 className='font-semibold text-gray-800'>Zwei-Faktor-Authentifizierung</h2>
<p className='text-sm text-gray-400 mt-0.5'>TOTP · Google Authenticator, Authy, etc.</p>
</div>
<TotpSection enabled={totpEnabled} onToggle={loadMe} />
</div>
{/* Passwort ändern */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<h2 className='font-semibold text-gray-700 mb-4'>Passwort ändern</h2>
<form onSubmit={changePassword} className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Aktuelles Passwort</label>
<input type='password' value={currentPw} onChange={e => setCurrentPw(e.target.value)}
required autoComplete='current-password'
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'>Neues Passwort</label>
<input type='password' value={newPw} onChange={e => setNewPw(e.target.value)}
required autoComplete='new-password'
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' />
<p className='mt-1 text-xs text-gray-400'>Mindestens 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Neues Passwort bestätigen</label>
<input type='password' value={confirmPw} onChange={e => setConfirmPw(e.target.value)}
required autoComplete='new-password'
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>
{error && <p className='text-sm text-red-600'>{error}</p>}
{success && <p className='text-sm text-green-600 font-medium'>Passwort erfolgreich geändert</p>}
<button type='submit' disabled={saving || !currentPw || !newPw || !confirmPw}
className='w-full py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Passwort ändern'}
</button>
</form>
</div>
</div>
</Layout>
)
}
+319
View File
@@ -0,0 +1,319 @@
import { useEffect, useState } 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
}
interface ProjectOut {
id: string
name: string
description: string | null
color: string
budget_hours: number | null
is_active: boolean
created_at: string
updated_at: string
}
interface ProjectTimeReport {
project_id: string
project_name: string
project_color: string
total_hours: number
entry_count: number
budget_hours: number | null
budget_used_pct: number | null
}
const COLORS = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#06B6D4', '#84CC16', '#F97316', '#6B7280',
]
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
const EMPTY_FORM = { name: '', description: '', color: '#3B82F6', budget_hours: '' }
function BudgetBar({ used, budget }: { used: number; budget: number }) {
const pct = Math.min(100, Math.round((used / budget) * 100))
const color = pct >= 100 ? 'bg-red-500' : pct >= 80 ? 'bg-amber-400' : 'bg-green-500'
return (
<div className='mt-1'>
<div className='flex justify-between text-xs text-gray-500 mb-0.5'>
<span>{used}h / {budget}h</span>
<span className={pct >= 100 ? 'text-red-600 font-semibold' : ''}>{pct}%</span>
</div>
<div className='h-1.5 bg-gray-100 rounded-full overflow-hidden'>
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export function ProjectsPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [projects, setProjects] = useState<ProjectOut[]>([])
const [summary, setSummary] = useState<ProjectTimeReport[]>([])
const [loading, setLoading] = useState(true)
const [showInactive, setShowInactive] = useState(false)
const [modal, setModal] = useState<'create' | 'edit' | null>(null)
const [editProject, setEditProject] = useState<ProjectOut | null>(null)
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const isManager = user ? MANAGER_ROLES.includes(user.role) : false
const load = async () => {
try {
const [me, list] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<{ total: number; items: ProjectOut[] }>(`/projects?include_inactive=${showInactive}`),
])
setUser(me)
setProjects(list.items)
if (MANAGER_ROLES.includes(me.role)) {
const s = await api.get<ProjectTimeReport[]>('/projects/report/summary').catch(() => [])
setSummary(s)
}
} catch { /* ignore */ }
finally { setLoading(false) }
}
useEffect(() => { load() }, [showInactive])
const openCreate = () => {
setEditProject(null)
setForm(EMPTY_FORM)
setError('')
setModal('create')
}
const openEdit = (p: ProjectOut) => {
setEditProject(p)
setForm({ name: p.name, description: p.description ?? '', color: p.color, budget_hours: p.budget_hours?.toString() ?? '' })
setError('')
setModal('edit')
}
const save = async () => {
setSaving(true); setError('')
try {
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
color: form.color,
budget_hours: form.budget_hours ? parseFloat(form.budget_hours) : null,
}
if (modal === 'create') {
await api.post('/projects', payload)
} else if (editProject) {
await api.patch(`/projects/${editProject.id}`, payload)
}
setModal(null)
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
const deactivate = async (p: ProjectOut) => {
if (!confirm(`Projekt "${p.name}" deaktivieren?`)) return
await api.patch(`/projects/${p.id}`, { is_active: false })
await load()
}
const reactivate = async (p: ProjectOut) => {
await api.patch(`/projects/${p.id}`, { is_active: true })
await load()
}
const getSummary = (id: string) => summary.find(s => s.project_id === id)
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'
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='max-w-4xl space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold text-gray-900'>Projekte</h1>
<p className='text-sm text-gray-500 mt-1'>Zeiterfassung nach Projekten aufschlüsseln</p>
</div>
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-sm text-gray-600 cursor-pointer'>
<input type='checkbox' checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className='rounded' />
Inaktive anzeigen
</label>
{isManager && (
<button onClick={openCreate}
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700'>
+ Neues Projekt
</button>
)}
</div>
</div>
{/* Projektkarten */}
{projects.length === 0 ? (
<div className='bg-white rounded-xl border border-gray-200 p-12 text-center'>
<p className='text-gray-400 text-sm'>Noch keine Projekte angelegt.</p>
{isManager && (
<button onClick={openCreate} className='mt-3 text-blue-600 text-sm hover:underline'>
Erstes Projekt erstellen
</button>
)}
</div>
) : (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{projects.map(p => {
const s = getSummary(p.id)
return (
<div key={p.id}
className={`bg-white rounded-xl border shadow-sm p-5 space-y-3 ${!p.is_active ? 'opacity-60' : 'border-gray-200'}`}>
{/* Farbbalken + Name */}
<div className='flex items-start gap-3'>
<div className='w-3 h-3 rounded-full mt-1 shrink-0' style={{ backgroundColor: p.color }} />
<div className='flex-1 min-w-0'>
<h3 className='font-semibold text-gray-800 truncate'>{p.name}</h3>
{p.description && (
<p className='text-xs text-gray-400 mt-0.5 line-clamp-2'>{p.description}</p>
)}
</div>
{!p.is_active && (
<span className='text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full'>Inaktiv</span>
)}
</div>
{/* Stunden */}
{s && (
<div>
<p className='text-2xl font-bold text-gray-900'>{s.total_hours}h</p>
<p className='text-xs text-gray-400'>{s.entry_count} Einträge (genehmigt)</p>
{s.budget_hours && (
<BudgetBar used={s.total_hours} budget={s.budget_hours} />
)}
</div>
)}
{!s && p.budget_hours && (
<p className='text-xs text-gray-400'>Budget: {p.budget_hours}h</p>
)}
{/* Aktionen */}
{isManager && (
<div className='flex gap-2 pt-1 border-t border-gray-100'>
<button onClick={() => openEdit(p)}
className='text-xs text-blue-600 hover:underline'>
Bearbeiten
</button>
{p.is_active ? (
<button onClick={() => deactivate(p)}
className='text-xs text-gray-400 hover:text-red-600 hover:underline ml-auto'>
Deaktivieren
</button>
) : (
<button onClick={() => reactivate(p)}
className='text-xs text-green-600 hover:underline ml-auto'>
Reaktivieren
</button>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* Gesamt-Summary für Manager */}
{isManager && summary.length > 0 && (
<div className='bg-white rounded-xl border border-gray-200 shadow-sm'>
<div className='px-5 py-4 border-b border-gray-100'>
<h2 className='text-base font-semibold text-gray-800'>Gesamtübersicht (genehmigte Stunden)</h2>
</div>
<div className='divide-y divide-gray-50'>
{summary.map(s => (
<div key={s.project_id} className='flex items-center gap-3 px-5 py-3'>
<div className='w-2.5 h-2.5 rounded-full shrink-0' style={{ backgroundColor: s.project_color }} />
<span className='flex-1 text-sm text-gray-700'>{s.project_name}</span>
{s.budget_hours && (
<div className='w-32'>
<BudgetBar used={s.total_hours} budget={s.budget_hours} />
</div>
)}
<span className='text-sm font-semibold text-gray-800 w-16 text-right'>{s.total_hours}h</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Modal: Erstellen / Bearbeiten */}
{modal && (
<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-md p-6 space-y-4'>
<h2 className='text-lg font-semibold text-gray-800'>
{modal === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten'}
</h2>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Name *</label>
<input type='text' value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder='z.B. Website-Redesign' className={inp} autoFocus />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Beschreibung</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={2} placeholder='Optionale Projektbeschreibung' className={inp} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-2'>Farbe</label>
<div className='flex flex-wrap gap-2'>
{COLORS.map(c => (
<button key={c} type='button' onClick={() => setForm(f => ({ ...f, color: c }))}
className={`w-7 h-7 rounded-full border-2 transition-transform ${form.color === c ? 'border-gray-800 scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} />
))}
<input type='color' value={form.color} onChange={e => setForm(f => ({ ...f, color: e.target.value }))}
className='w-7 h-7 rounded cursor-pointer border border-gray-200' title='Eigene Farbe' />
</div>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Stundenbudget (optional)</label>
<input type='number' value={form.budget_hours} onChange={e => setForm(f => ({ ...f, budget_hours: e.target.value }))}
placeholder='z.B. 40' min='0.1' step='0.5' className={inp} />
<p className='text-xs text-gray-400 mt-1'>Fortschrittsbalken wird angezeigt wenn Budget gesetzt.</p>
</div>
<div className='flex gap-2 pt-2'>
<button onClick={save} disabled={saving || !form.name.trim()}
className='flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Speichern'}
</button>
<button onClick={() => setModal(null)}
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>
)}
</Layout>
)
}
+254
View File
@@ -0,0 +1,254 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { api } from '../api/client'
type FormData = {
company_name: string
first_name: string
last_name: string
email: string
password: string
password_confirm: string
}
const INITIAL: FormData = {
company_name: '', first_name: '', last_name: '',
email: '', password: '', password_confirm: '',
}
const STEPS = ['Unternehmen', 'Persönliche Daten', 'Passwort']
export function RegisterPage() {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [form, setForm] = useState<FormData>(INITIAL)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const update = (field: keyof FormData) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [field]: e.target.value }))
function validate(): string {
if (step === 1 && form.company_name.trim().length < 2)
return 'Unternehmensname muss mindestens 2 Zeichen haben.'
if (step === 2) {
if (!form.first_name.trim()) return 'Vorname ist erforderlich.'
if (!form.last_name.trim()) return 'Nachname ist erforderlich.'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
return 'Bitte eine gültige E-Mail-Adresse eingeben.'
}
if (step === 3) {
if (form.password.length < 8)
return 'Passwort muss mindestens 8 Zeichen haben.'
if (!/[A-Z]/.test(form.password))
return 'Passwort muss mindestens einen Großbuchstaben enthalten.'
if (!/\d/.test(form.password))
return 'Passwort muss mindestens eine Ziffer enthalten.'
if (form.password !== form.password_confirm)
return 'Passwörter stimmen nicht überein.'
}
return ''
}
async function handleNext() {
const err = validate()
if (err) { setError(err); return }
setError('')
if (step < 3) { setStep(s => s + 1); return }
setLoading(true)
try {
const data = await api.post<{ access_token: string; refresh_token: string }>(
'/auth/register',
{
company_name: form.company_name,
first_name: form.first_name,
last_name: form.last_name,
email: form.email,
password: form.password,
},
)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
navigate('/dashboard')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleNext()
}
const inputClass =
'mt-1 w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4'>
<div className='w-full max-w-md bg-white rounded-2xl shadow-lg p-8'>
{/* Logo / Title */}
<div className='mb-6'>
<div className='flex items-center gap-2 mb-1'>
<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='text-lg font-bold text-gray-800'>TimeMaster</span>
</div>
<h1 className='text-xl font-semibold text-gray-800 mt-3'>Konto erstellen</h1>
<p className='text-sm text-gray-500'>Schritt {step} von {STEPS.length}</p>
</div>
{/* Fortschrittsbalken */}
<div className='flex gap-1.5 mb-6'>
{STEPS.map((label, i) => (
<div key={label} className='flex-1'>
<div className={`h-1 rounded-full transition-colors ${
i + 1 <= step ? 'bg-blue-500' : 'bg-gray-200'
}`} />
<p className={`text-xs mt-1 font-medium ${
i + 1 <= step ? 'text-blue-600' : 'text-gray-400'
}`}>{label}</p>
</div>
))}
</div>
{/* Schritt 1: Unternehmen */}
{step === 1 && (
<div className='space-y-4' onKeyDown={handleKeyDown}>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>Unternehmensname *</span>
<input
value={form.company_name}
onChange={update('company_name')}
className={inputClass}
placeholder='Muster GmbH'
autoFocus
/>
</label>
<p className='text-xs text-gray-400'>
Der Name deines Unternehmens, wie er für Mitarbeiter angezeigt wird.
</p>
</div>
)}
{/* Schritt 2: Persönliche Daten */}
{step === 2 && (
<div className='space-y-4' onKeyDown={handleKeyDown}>
<div className='flex gap-3'>
<label className='block flex-1'>
<span className='text-sm font-medium text-gray-700'>Vorname *</span>
<input
value={form.first_name}
onChange={update('first_name')}
className={inputClass}
placeholder='Max'
autoFocus
/>
</label>
<label className='block flex-1'>
<span className='text-sm font-medium text-gray-700'>Nachname *</span>
<input
value={form.last_name}
onChange={update('last_name')}
className={inputClass}
placeholder='Mustermann'
/>
</label>
</div>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>E-Mail-Adresse *</span>
<input
type='email'
value={form.email}
onChange={update('email')}
className={inputClass}
placeholder='max@muster-gmbh.de'
/>
</label>
<p className='text-xs text-gray-400'>
Du wirst Administrator deines Unternehmens.
</p>
</div>
)}
{/* Schritt 3: Passwort */}
{step === 3 && (
<div className='space-y-4' onKeyDown={handleKeyDown}>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>Passwort *</span>
<input
type='password'
value={form.password}
onChange={update('password')}
className={inputClass}
placeholder='Mindestens 8 Zeichen'
autoFocus
/>
</label>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>Passwort bestätigen *</span>
<input
type='password'
value={form.password_confirm}
onChange={update('password_confirm')}
className={inputClass}
placeholder='Passwort wiederholen'
/>
</label>
<ul className='text-xs text-gray-400 space-y-1'>
<li className={form.password.length >= 8 ? 'text-green-600' : ''}>
{form.password.length >= 8 ? '✓' : '·'} Mindestens 8 Zeichen
</li>
<li className={/[A-Z]/.test(form.password) ? 'text-green-600' : ''}>
{/[A-Z]/.test(form.password) ? '✓' : '·'} Mindestens ein Großbuchstabe
</li>
<li className={/\d/.test(form.password) ? 'text-green-600' : ''}>
{/\d/.test(form.password) ? '✓' : '·'} Mindestens eine Ziffer
</li>
</ul>
</div>
)}
{/* Fehler */}
{error && (
<div className='mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5'>
{error}
</div>
)}
{/* Buttons */}
<div className='flex justify-between items-center mt-6'>
{step > 1 ? (
<button
onClick={() => { setError(''); setStep(s => s - 1) }}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors'
>
Zurück
</button>
) : (
<span />
)}
<button
onClick={handleNext}
disabled={loading}
className='px-6 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors'
>
{loading ? 'Wird erstellt…' : step < 3 ? 'Weiter →' : 'Konto erstellen'}
</button>
</div>
<p className='text-center text-sm text-gray-500 mt-6'>
Bereits ein Konto?{' '}
<Link to='/login' className='text-blue-600 hover:underline font-medium'>
Anmelden
</Link>
</p>
</div>
</div>
)
}
+808
View File
@@ -0,0 +1,808 @@
import { useEffect, useState } 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
}
interface UserListItem { id: string; full_name: string; email: string }
// ── Backend shapes ──────────────────────────────────────────────────────────
interface HoursBreakdown {
normal_hours: number
night_25_hours: number
night_40_hours: number
sunday_hours: number
holiday_125_hours: number
holiday_150_hours: number
holiday_name: string | null
}
interface TimeReportRow {
date: string; user_id: string; user_name: string; personnel_number: string | null; department: string | null
start_time: string; end_time: string | null; break_minutes: number
worked_hours: number | null; status: string; source: string; note: string | null
breakdown: HoursBreakdown | null
}
interface TimeReport { date_from: string; date_to: string; total_rows: number; total_hours: number; rows: TimeReportRow[] }
interface AbsenceReportRow {
user_id: string; user_name: string; personnel_number: string | null; department: string | null
absence_type: string; start_date: string; end_date: string
working_days: number; status: string; note: string | null
}
interface AbsenceReport { date_from: string; date_to: string; total_rows: number; total_days: number; rows: AbsenceReportRow[] }
interface OvertimeReportRow {
user_id: string; user_name: string; personnel_number: string | null; department: string | null
hours_worked: number; hours_expected: number; overtime_hours: number
}
interface OvertimeReport { date_from: string; date_to: string; total_employees: number; total_overtime: number; rows: OvertimeReportRow[] }
interface DayEntry {
start_time: string; end_time: string; break_minutes: number
hours_worked: number; status: string; arbzg_warnings: string[]
breakdown: HoursBreakdown | null
}
interface OvertimeDay {
date: string; weekday: string
hours_worked: number; hours_expected: number; overtime: number
entries: DayEntry[]
}
interface OvertimeWeek {
week_nr: number; week_start: string; week_end: string
hours_worked: number; hours_expected: number; overtime: number
days: OvertimeDay[]
}
interface OvertimeReportRowDetailed extends OvertimeReportRow {
weeks: OvertimeWeek[]
arbzg_violation_days: number
special_hours_total: HoursBreakdown | null
}
interface OvertimeReportDetailed { date_from: string; date_to: string; total_employees: number; total_overtime: number; rows: OvertimeReportRowDetailed[] }
interface SickStatsRow {
user_id: string; user_name: string; personnel_number: string | null
episodes: number; total_days: number; bradford_factor: number; certificates_overdue: number
}
// ── Helpers ─────────────────────────────────────────────────────────────────
type ReportType = 'time' | 'absences' | 'overtime' | 'sick'
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
const STATUS_DE: Record<string, string> = {
open: 'Offen', pending: 'Prüfung', approved: 'Genehmigt',
rejected: 'Abgelehnt', auto: 'Auto', cancelled: 'Storniert',
}
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', cancelled: 'bg-gray-100 text-gray-400',
}
function fmt(t: string | null): string {
if (!t) return ''
if (/^\d{2}:\d{2}(:\d{2})?$/.test(t)) return t.slice(0, 5)
const d = new Date(t)
return isNaN(d.getTime()) ? t.slice(0, 5) : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function fmtDate(s: string) { return new Date(s + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) }
function fmtH(h: number) {
const hrs = Math.floor(Math.abs(h)); const min = Math.round((Math.abs(h) - hrs) * 60)
return `${h < 0 ? '-' : ''}${hrs}h ${min.toString().padStart(2, '0')}m`
}
function monthRange(offset = 0): [string, string] {
const d = new Date(); d.setMonth(d.getMonth() + offset, 1)
const from = d.toISOString().slice(0, 8) + '01'
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0)
return [from, last.toISOString().slice(0, 10)]
}
function quarterRange(): [string, string] {
const d = new Date(); const q = Math.floor(d.getMonth() / 3)
const from = new Date(d.getFullYear(), q * 3, 1).toISOString().slice(0, 10)
const to = new Date(d.getFullYear(), q * 3 + 3, 0).toISOString().slice(0, 10)
return [from, to]
}
// Group rows by user
function groupBy<T extends { user_id: string; user_name: string }>(rows: T[]): Map<string, T[]> {
const m = new Map<string, T[]>()
for (const r of rows) {
if (!m.has(r.user_id)) m.set(r.user_id, [])
m.get(r.user_id)!.push(r)
}
return m
}
export function ReportsPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [colleagues, setColleagues] = useState<UserListItem[]>([])
const [type, setType] = useState<ReportType>('time')
const [dateFrom, setDateFrom] = useState(monthRange()[0])
const [dateTo, setDateTo] = useState(monthRange()[1])
const [filterUser, setFilterUser] = useState('')
const [timeReport, setTimeReport] = useState<TimeReport | null>(null)
const [absenceReport, setAbsenceReport] = useState<AbsenceReport | null>(null)
const [overtimeReport, setOvertimeReport] = useState<OvertimeReport | null>(null)
const [overtimeDetail, setOvertimeDetail] = useState<OvertimeReportDetailed | null>(null)
const [overtimeView, setOvertimeView] = useState<'simple' | 'detail'>('simple')
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
const [sickStats, setSickStats] = useState<SickStatsRow[] | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const isManager = MANAGER_ROLES.includes(user?.role ?? '')
useEffect(() => {
api.get<UserOut>('/auth/me').then(me => {
setUser(me)
if (MANAGER_ROLES.includes(me.role)) {
api.get<{ items: UserListItem[] }>('/users/?limit=500').then(r => setColleagues(r.items)).catch(() => {})
}
}).catch(() => {})
}, [])
const run = async () => {
setLoading(true); setError('')
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}`
try {
if (type === 'time') {
setTimeReport(await api.get<TimeReport>(`/reports/time${p}`))
setAbsenceReport(null); setOvertimeReport(null); setSickStats(null)
} else if (type === 'absences') {
setAbsenceReport(await api.get<AbsenceReport>(`/reports/absences${p}`))
setTimeReport(null); setOvertimeReport(null); setSickStats(null)
} else if (type === 'sick') {
const params = filterUser ? `?user_id=${filterUser}` : ''
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
setSickStats(stats)
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null)
} else {
const [simple, detail] = await Promise.all([
api.get<OvertimeReport>(`/reports/overtime${p}`),
api.get<OvertimeReportDetailed>(`/reports/overtime/detail${p}`),
])
setOvertimeReport(simple)
setOvertimeDetail(detail)
setExpandedUsers(new Set())
setExpandedWeeks(new Set())
setTimeReport(null); setAbsenceReport(null); setSickStats(null)
}
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}&format=${format}`
const ep = type === 'time' ? `/reports/time/export${p}` : type === 'absences' ? `/reports/absences/export${p}` : `/reports/overtime/export${p}`
const token = localStorage.getItem('access_token')
const res = await fetch(`/api/v1${ep}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) { setError('Export fehlgeschlagen'); return }
const blob = await res.blob()
const a = document.createElement('a'); a.href = URL.createObjectURL(blob)
a.download = `${type}_${dateFrom}_${dateTo}.${format}`; a.click()
}
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats)
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
<style>{`@media print { header,nav,.no-print{display:none!important} body{font-size:11px} }`}</style>
<div className='space-y-5'>
<div className='flex items-center justify-between no-print'>
<h1 className='text-2xl font-bold text-gray-900'>Berichte</h1>
</div>
{/* ── Controls ───────────────────────────────────────────────────── */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5 no-print space-y-4'>
{/* Report type */}
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen']] as [ReportType,string][]).map(([v,l]) => (
<button key={v} onClick={() => setType(v)}
className={`px-4 py-2 text-sm font-medium transition-colors ${type===v ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
{l}
</button>
))}
</div>
<div className='flex flex-wrap gap-3 items-end'>
{/* Quick-select */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
<div className='flex gap-1.5'>
{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => (
<button key={l as string} onClick={() => setQuick(f as string, t as string)}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
{l as string}
</button>
))}
</div>
</div>
{/* Date range */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
<input type='date' value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Bis</p>
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
{/* Employee filter (manager only) */}
{isManager && colleagues.length > 0 && (
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
<select value={filterUser} onChange={e => setFilterUser(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'>
<option value=''>Alle</option>
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name}</option>)}
</select>
</div>
)}
<button onClick={run} disabled={loading}
className='px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2'>
{loading && <Spinner />} Auswerten
</button>
</div>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 no-print'>{error}</div>}
{/* ── Results ────────────────────────────────────────────────────── */}
{hasResult && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
{/* Header */}
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3'>
<div>
<h2 className='font-semibold text-gray-800'>
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : 'Krankmeldungen'}
</h2>
<p className='text-xs text-gray-400 mt-0.5'>
{type === 'sick' ? 'Rolling 12 Monate ab heute' : `${fmtDate(dateFrom)} ${fmtDate(dateTo)}`}
</p>
</div>
<div className='flex gap-2 no-print'>
<button onClick={() => window.print()} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>Drucken</button>
{type !== 'sick' && (<>
<button onClick={() => download('csv')} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>CSV</button>
<button onClick={() => download('xlsx')} className='text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700'>Excel</button>
<button onClick={() => download('pdf')} className='text-xs px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700'>PDF</button>
</>)}
</div>
</div>
{/* ── KPI row ── */}
{timeReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Einträge</p><p className='text-2xl font-bold text-gray-800'>{timeReport.total_rows}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Stunden</p><p className='text-2xl font-bold text-blue-700'>{fmtH(timeReport.total_hours)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter</p><p className='text-2xl font-bold text-gray-800'>{new Set(timeReport.rows.map(r => r.user_id)).size}</p></div>
</div>
)}
{absenceReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Anträge</p><p className='text-2xl font-bold text-gray-800'>{absenceReport.total_rows}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Arbeitstage</p><p className='text-2xl font-bold text-orange-600'>{absenceReport.total_days}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter betroffen</p><p className='text-2xl font-bold text-gray-800'>{new Set(absenceReport.rows.map(r => r.user_id)).size}</p></div>
</div>
)}
{overtimeReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter</p><p className='text-2xl font-bold text-gray-800'>{overtimeReport.total_employees}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Überstunden</p>
<p className={`text-2xl font-bold ${overtimeReport.total_overtime >= 0 ? 'text-green-600' : 'text-red-600'}`}>{fmtH(overtimeReport.total_overtime)}</p>
</div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Periode</p><p className='text-sm font-medium text-gray-700 mt-1'>{fmtDate(overtimeReport.date_from)} {fmtDate(overtimeReport.date_to)}</p></div>
</div>
)}
{sickStats && (() => {
const totalDays = sickStats.reduce((s, r) => s + r.total_days, 0)
const totalEpisodes = sickStats.reduce((s, r) => s + r.episodes, 0)
const overdue = sickStats.reduce((s, r) => s + r.certificates_overdue, 0)
const avgBradford = sickStats.length ? sickStats.reduce((s, r) => s + r.bradford_factor, 0) / sickStats.length : 0
return (
<div className='grid grid-cols-4 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'> Bradford-Index</p><p className='text-2xl font-bold text-orange-600'>{avgBradford.toFixed(1)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Σ Kranktage</p><p className='text-2xl font-bold text-gray-800'>{totalDays.toFixed(1)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Σ Episoden</p><p className='text-2xl font-bold text-gray-800'>{totalEpisodes}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>AU überfällig</p><p className={`text-2xl font-bold ${overdue > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overdue}</p></div>
</div>
)
})()}
{/* ── Time table (grouped by employee) ── */}
{timeReport && (() => {
const groups = groupBy(timeReport.rows)
return (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide sticky top-0'>
<tr>
<th className='px-4 py-3 text-left'>Datum</th>
{!filterUser && <th className='px-4 py-3 text-left'>Mitarbeiter</th>}
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-left'>Beginn</th>
<th className='px-4 py-3 text-left'>Ende</th>
<th className='px-4 py-3 text-right'>Pause</th>
<th className='px-4 py-3 text-right'>Netto</th>
<th className='px-4 py-3 text-left'>Status</th>
<th className='px-4 py-3 text-left'>Notiz</th>
</tr>
</thead>
<tbody>
{Array.from(groups.entries()).map(([uid, rows], gi) => {
const subtotal = rows.reduce((s, r) => s + (r.worked_hours ?? 0), 0)
return (
<>
{/* Employee header row */}
{!filterUser && (
<tr key={`hdr-${uid}`} className={`${gi > 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}>
<td colSpan={isManager ? 9 : 8} className='px-4 py-2'>
<span className='font-semibold text-gray-700 text-sm'>{rows[0].user_name}</span>
{rows[0].department && <span className='ml-2 text-xs text-gray-400'>{rows[0].department}</span>}
<span className='ml-3 text-xs font-medium text-blue-600'>{fmtH(subtotal)} gesamt</span>
</td>
</tr>
)}
{rows.map((row, i) => {
const bd = row.breakdown
const hasSonder = bd && (
bd.night_25_hours > 0 || bd.night_40_hours > 0 ||
bd.sunday_hours > 0 || bd.holiday_125_hours > 0 || bd.holiday_150_hours > 0
)
const colSpan = (isManager ? 2 : 1) + (!filterUser ? 1 : 0) + 6
return (
<>
<tr key={`${uid}-${i}`} className='border-t border-gray-50 hover:bg-gray-50/50'>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>
{new Date(row.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</td>
{!filterUser && <td className='px-4 py-2 text-gray-600 text-xs'>{row.user_name}</td>}
{isManager && <td className='px-4 py-2 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-2 text-gray-700'>{fmt(row.start_time)}</td>
<td className='px-4 py-2 text-gray-700'>{fmt(row.end_time)}</td>
<td className='px-4 py-2 text-right text-gray-600'>{row.break_minutes} min</td>
<td className='px-4 py-2 text-right font-medium text-gray-800'>{row.worked_hours != null ? `${row.worked_hours.toFixed(2)}h` : ''}</td>
<td className='px-4 py-2'>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[row.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[row.status] ?? row.status}
</span>
</td>
<td className='px-4 py-2 text-gray-400 text-xs max-w-xs truncate'>{row.note ?? ''}</td>
</tr>
{hasSonder && bd && (
<tr key={`${uid}-${i}-bd`} className='bg-amber-50 border-t border-amber-100'>
<td colSpan={colSpan} className='px-6 py-1.5'>
<div className='flex flex-wrap gap-x-5 gap-y-0.5 text-xs text-amber-800'>
{bd.holiday_name && (
<span className='font-semibold text-red-700'>🗓 {bd.holiday_name}</span>
)}
{bd.normal_hours > 0 && (
<span>Normal: <strong>{bd.normal_hours.toFixed(2)}h</strong></span>
)}
{bd.night_25_hours > 0 && (
<span className='text-blue-700'>Nacht 25%: <strong>{bd.night_25_hours.toFixed(2)}h</strong></span>
)}
{bd.night_40_hours > 0 && (
<span className='text-blue-900 font-semibold'>Nacht 40%: <strong>{bd.night_40_hours.toFixed(2)}h</strong></span>
)}
{bd.sunday_hours > 0 && (
<span className='text-purple-700'>Sonntag 50%: <strong>{bd.sunday_hours.toFixed(2)}h</strong></span>
)}
{bd.holiday_125_hours > 0 && (
<span className='text-red-700'>Feiertag 125%: <strong>{bd.holiday_125_hours.toFixed(2)}h</strong></span>
)}
{bd.holiday_150_hours > 0 && (
<span className='text-red-700 font-semibold'>Feiertag 150%: <strong>{bd.holiday_150_hours.toFixed(2)}h</strong></span>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</>
)
})}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? (filterUser ? 4 : 5) : (filterUser ? 3 : 4)} className='px-4 py-3 text-right text-gray-700'>Gesamt:</td>
<td className='px-4 py-3 text-right text-gray-900'>{fmtH(timeReport.total_hours)}</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
)
})()}
{/* ── Absence table (grouped by employee) ── */}
{absenceReport && (() => {
const groups = groupBy(absenceReport.rows)
return (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide'>
<tr>
{!filterUser && <th className='px-4 py-3 text-left'>Mitarbeiter</th>}
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-left'>Art</th>
<th className='px-4 py-3 text-left'>Von</th>
<th className='px-4 py-3 text-left'>Bis</th>
<th className='px-4 py-3 text-right'>Tage</th>
<th className='px-4 py-3 text-left'>Status</th>
<th className='px-4 py-3 text-left'>Notiz</th>
</tr>
</thead>
<tbody>
{Array.from(groups.entries()).map(([uid, rows], gi) => {
const subtotal = rows.reduce((s, r) => s + r.working_days, 0)
return (
<>
{!filterUser && (
<tr key={`hdr-${uid}`} className={`${gi > 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}>
<td colSpan={isManager ? 8 : 7} className='px-4 py-2'>
<span className='font-semibold text-gray-700 text-sm'>{rows[0].user_name}</span>
{rows[0].department && <span className='ml-2 text-xs text-gray-400'>{rows[0].department}</span>}
<span className='ml-3 text-xs font-medium text-orange-600'>{subtotal} Tage gesamt</span>
</td>
</tr>
)}
{rows.map((row, i) => (
<tr key={`${uid}-${i}`} className='border-t border-gray-50 hover:bg-gray-50/50'>
{!filterUser && <td className='px-4 py-2 text-gray-600 text-xs'>{row.user_name}</td>}
{isManager && <td className='px-4 py-2 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-2 font-medium text-gray-800'>{row.absence_type}</td>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>{fmtDate(row.start_date)}</td>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>{fmtDate(row.end_date)}</td>
<td className='px-4 py-2 text-right font-medium text-gray-800'>{row.working_days}</td>
<td className='px-4 py-2'>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[row.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[row.status] ?? row.status}
</span>
</td>
<td className='px-4 py-2 text-gray-400 text-xs'>{row.note ?? ''}</td>
</tr>
))}
</>
)
})}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? (filterUser ? 4 : 5) : (filterUser ? 3 : 4)} className='px-4 py-3 text-right text-gray-700'>Gesamt Arbeitstage:</td>
<td className='px-4 py-3 text-right text-gray-900'>{absenceReport.total_days}</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
)
})()}
{/* ── Overtime table ── */}
{overtimeReport && (
<div>
{/* Einfach / Erweitert Toggle */}
<div className='px-6 py-3 border-b border-gray-100 flex items-center gap-2 no-print'>
<span className='text-xs text-gray-500 font-medium'>Ansicht:</span>
<div className='flex rounded-lg border border-gray-300 overflow-hidden'>
<button onClick={() => setOvertimeView('simple')}
className={`px-3 py-1 text-xs font-medium transition-colors ${overtimeView === 'simple' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
Einfach
</button>
<button onClick={() => setOvertimeView('detail')}
className={`px-3 py-1 text-xs font-medium transition-colors ${overtimeView === 'detail' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
Erweitert
</button>
</div>
</div>
{/* Einfache Ansicht */}
{overtimeView === 'simple' && (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide'>
<tr>
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-right'>Soll</th>
<th className='px-4 py-3 text-right'>Ist</th>
<th className='px-4 py-3 text-right'>Überstunden</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-50'>
{overtimeReport.rows.map((row, i) => (
<tr key={i} className='hover:bg-gray-50/50'>
<td className='px-4 py-3 font-medium text-gray-800'>{row.user_name}</td>
{isManager && <td className='px-4 py-3 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-3 text-right text-gray-600'>{fmtH(row.hours_expected)}</td>
<td className='px-4 py-3 text-right text-gray-800 font-medium'>{fmtH(row.hours_worked)}</td>
<td className='px-4 py-3 text-right'>
<span className={`font-bold ${row.overtime_hours > 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}>
{row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
</span>
</td>
</tr>
))}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? 2 : 1} className='px-4 py-3'></td>
<td className='px-4 py-3 text-right text-gray-700'>{fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_expected, 0))}</td>
<td className='px-4 py-3 text-right text-gray-900'>{fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_worked, 0))}</td>
<td className={`px-4 py-3 text-right font-bold ${overtimeReport.total_overtime >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{overtimeReport.total_overtime > 0 ? '+' : ''}{fmtH(overtimeReport.total_overtime)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Erweiterte Ansicht */}
{overtimeView === 'detail' && overtimeDetail && (
<div className='divide-y divide-gray-100'>
{overtimeDetail.rows.map(row => {
const userOpen = expandedUsers.has(row.user_id)
const toggleUser = () => setExpandedUsers(prev => {
const next = new Set(prev)
next.has(row.user_id) ? next.delete(row.user_id) : next.add(row.user_id)
return next
})
return (
<div key={row.user_id}>
{/* Mitarbeiter-Zeile (klickbar) */}
<div
onClick={toggleUser}
className='flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors select-none'
>
<span className={`text-gray-400 transition-transform text-xs ${userOpen ? 'rotate-90' : ''}`}></span>
<span className='font-semibold text-gray-800 flex-1'>{row.user_name}</span>
{isManager && <span className='text-xs text-gray-400 w-28'>{row.department ?? ''}</span>}
{row.arbzg_violation_days > 0 && (
<span className='text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium'>
{row.arbzg_violation_days}× ArbZG
</span>
)}
<span className='text-xs text-gray-500 w-20 text-right'>Soll {fmtH(row.hours_expected)}</span>
<span className='text-xs text-gray-700 font-medium w-20 text-right'>Ist {fmtH(row.hours_worked)}</span>
<span className={`text-sm font-bold w-24 text-right ${row.overtime_hours > 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}>
{row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
</span>
</div>
{/* Sonderstunden-Zusammenfassung */}
{userOpen && row.special_hours_total && (
(() => {
const s = row.special_hours_total
const hasSonder = s.night_25_hours > 0 || s.night_40_hours > 0 || s.sunday_hours > 0 || s.holiday_125_hours > 0 || s.holiday_150_hours > 0
return hasSonder ? (
<div className='mx-4 mb-2 bg-amber-50 border border-amber-200 rounded-lg px-4 py-2 flex flex-wrap gap-x-5 gap-y-1 text-xs text-amber-800'>
<span className='font-semibold'>Sonderstunden gesamt:</span>
{s.normal_hours > 0 && <span>Normal: <strong>{s.normal_hours.toFixed(1)}h</strong></span>}
{s.night_25_hours > 0 && <span className='text-blue-700'>Nacht 25%: <strong>{s.night_25_hours.toFixed(1)}h</strong></span>}
{s.night_40_hours > 0 && <span className='text-blue-900 font-semibold'>Nacht 40%: <strong>{s.night_40_hours.toFixed(1)}h</strong></span>}
{s.sunday_hours > 0 && <span className='text-purple-700'>Sonntag 50%: <strong>{s.sunday_hours.toFixed(1)}h</strong></span>}
{s.holiday_125_hours > 0 && <span className='text-red-700'>Feiertag 125%: <strong>{s.holiday_125_hours.toFixed(1)}h</strong></span>}
{s.holiday_150_hours > 0 && <span className='text-red-700 font-semibold'>Feiertag 150%: <strong>{s.holiday_150_hours.toFixed(1)}h</strong></span>}
</div>
) : null
})()
)}
{/* Wochen */}
{userOpen && row.weeks.map(week => {
const weekKey = `${row.user_id}-${week.week_nr}`
const weekOpen = expandedWeeks.has(weekKey)
const toggleWeek = (e: React.MouseEvent) => {
e.stopPropagation()
setExpandedWeeks(prev => {
const next = new Set(prev)
next.has(weekKey) ? next.delete(weekKey) : next.add(weekKey)
return next
})
}
return (
<div key={week.week_nr} className='ml-8 border-l-2 border-gray-100'>
{/* Wochen-Zeile */}
<div
onClick={toggleWeek}
className='flex items-center gap-3 px-4 py-2 cursor-pointer hover:bg-blue-50/40 transition-colors select-none'
>
<span className={`text-gray-300 transition-transform text-xs ${weekOpen ? 'rotate-90' : ''}`}></span>
<span className='text-xs font-medium text-gray-600 flex-1'>
KW {week.week_nr} &nbsp;·&nbsp;
{new Date(week.week_start + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} {new Date(week.week_end + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
</span>
<span className='text-xs text-gray-400 w-20 text-right'>Soll {fmtH(week.hours_expected)}</span>
<span className='text-xs text-gray-600 font-medium w-20 text-right'>Ist {fmtH(week.hours_worked)}</span>
<span className={`text-xs font-bold w-24 text-right ${week.overtime > 0 ? 'text-green-600' : week.overtime < 0 ? 'text-red-500' : 'text-gray-400'}`}>
{week.overtime > 0 ? '+' : ''}{fmtH(week.overtime)}
</span>
</div>
{/* Tages-Tabelle */}
{weekOpen && (
<div className='ml-4 mb-2 overflow-x-auto'>
<table className='w-full text-xs'>
<thead className='text-gray-400 uppercase tracking-wide'>
<tr>
<th className='px-3 py-1.5 text-left'>Tag</th>
<th className='px-3 py-1.5 text-left'>Datum</th>
<th className='px-3 py-1.5 text-right'>Beginn</th>
<th className='px-3 py-1.5 text-right'>Ende</th>
<th className='px-3 py-1.5 text-right'>Pause</th>
<th className='px-3 py-1.5 text-right'>Ist</th>
<th className='px-3 py-1.5 text-right'>Soll</th>
<th className='px-3 py-1.5 text-right'>Diff</th>
<th className='px-3 py-1.5 text-left'>Status</th>
</tr>
</thead>
<tbody>
{week.days.map((day, di) => {
const isWeekend = ['Sa', 'So'].includes(day.weekday)
const hasEntries = day.entries.length > 0
const multiEntry = day.entries.length > 1
const dateStr = new Date(day.date + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
return (
<>
{/* Tageszeile (Summary wenn mehrere Einträge, sonst direkt Daten) */}
{(!hasEntries || multiEntry) && (
<tr key={`day-${di}`} className={`border-t-2 border-gray-200 ${isWeekend ? 'bg-gray-50/50' : 'bg-gray-50/30'}`}>
<td className={`px-3 py-1.5 font-semibold ${isWeekend ? 'text-gray-400' : 'text-gray-700'}`}>{day.weekday}</td>
<td className='px-3 py-1.5 text-gray-500'>{dateStr}</td>
<td className='px-3 py-1.5 text-right text-gray-400 italic text-xs' colSpan={3}>{multiEntry ? `${day.entries.length} Einträge` : ''}</td>
<td className='px-3 py-1.5 text-right font-semibold text-gray-700'>{hasEntries ? fmtH(day.hours_worked) : ''}</td>
<td className='px-3 py-1.5 text-right text-gray-400'>{day.hours_expected > 0 ? fmtH(day.hours_expected) : ''}</td>
<td className={`px-3 py-1.5 text-right font-bold ${day.overtime > 0 ? 'text-green-600' : day.overtime < 0 && day.hours_expected > 0 ? 'text-red-500' : 'text-gray-400'}`}>
{hasEntries || day.hours_expected > 0 ? (day.overtime > 0 ? '+' : '') + fmtH(day.overtime) : ''}
</td>
<td />
</tr>
)}
{/* Einzelne Einträge */}
{day.entries.map((entry, ei) => {
const hasWarnings = entry.arbzg_warnings.length > 0
const hasSonder = entry.breakdown && (
entry.breakdown.night_25_hours > 0 || entry.breakdown.night_40_hours > 0 ||
entry.breakdown.sunday_hours > 0 || entry.breakdown.holiday_125_hours > 0 || entry.breakdown.holiday_150_hours > 0
)
return (
<>
<tr key={`${di}-${ei}`} className={`border-t border-gray-50 ${isWeekend ? 'bg-gray-50/40' : ''} ${hasWarnings ? 'bg-red-50/20' : ''} ${multiEntry ? 'pl-4' : ''}`}>
<td className={`px-3 py-1.5 font-medium ${isWeekend ? 'text-gray-400' : 'text-gray-600'}`}>
{!multiEntry ? day.weekday : <span className='text-gray-300 pl-2'></span>}
</td>
<td className='px-3 py-1.5 text-gray-500'>{!multiEntry ? dateStr : ''}</td>
<td className='px-3 py-1.5 text-right text-gray-600'>{fmt(entry.start_time)}</td>
<td className='px-3 py-1.5 text-right text-gray-600'>{fmt(entry.end_time)}</td>
<td className='px-3 py-1.5 text-right text-gray-500'>{entry.break_minutes > 0 ? `${entry.break_minutes} min` : ''}</td>
<td className='px-3 py-1.5 text-right font-medium text-gray-700'>{fmtH(entry.hours_worked)}</td>
{!multiEntry ? <>
<td className='px-3 py-1.5 text-right text-gray-400'>{day.hours_expected > 0 ? fmtH(day.hours_expected) : ''}</td>
<td className={`px-3 py-1.5 text-right font-semibold ${day.overtime > 0 ? 'text-green-600' : day.overtime < 0 && day.hours_expected > 0 ? 'text-red-500' : 'text-gray-400'}`}>
{(day.overtime > 0 ? '+' : '') + fmtH(day.overtime)}
</td>
</> : <><td /><td /></>}
<td className='px-3 py-1.5'>
<span className={`px-1.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[entry.status] ?? entry.status}
</span>
</td>
</tr>
{(hasWarnings || hasSonder) && (
<tr key={`${di}-${ei}-meta`} className='bg-amber-50/60'>
<td colSpan={9} className='px-3 py-1 text-xs'>
{hasWarnings && (
<div className='flex flex-wrap gap-2 text-red-600 font-medium'>
{entry.arbzg_warnings.map((w, wi) => <span key={wi}> {w}</span>)}
</div>
)}
{hasSonder && entry.breakdown && (
<div className='flex flex-wrap gap-x-4 text-amber-700 mt-0.5'>
{entry.breakdown.night_25_hours > 0 && <span>Nacht 25%: {entry.breakdown.night_25_hours.toFixed(2)}h</span>}
{entry.breakdown.night_40_hours > 0 && <span>Nacht 40%: {entry.breakdown.night_40_hours.toFixed(2)}h</span>}
{entry.breakdown.sunday_hours > 0 && <span>Sonntag: {entry.breakdown.sunday_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_125_hours > 0 && <span>Feiertag 125%: {entry.breakdown.holiday_125_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_150_hours > 0 && <span>Feiertag 150%: {entry.breakdown.holiday_150_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_name && <span className='text-red-600 font-semibold'>{entry.breakdown.holiday_name}</span>}
</div>
)}
</td>
</tr>
)}
</>
)
})}
</>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)
})}
{/* Footer */}
<div className='flex items-center px-4 py-3 bg-gray-50 font-semibold text-sm'>
<span className='flex-1 text-gray-700'>Gesamt</span>
{isManager && <span className='w-28'></span>}
<span className='text-gray-600 w-20 text-right'>{fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_expected, 0))}</span>
<span className='text-gray-900 w-20 text-right'>{fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_worked, 0))}</span>
<span className={`font-bold w-24 text-right ${overtimeDetail.total_overtime >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{overtimeDetail.total_overtime > 0 ? '+' : ''}{fmtH(overtimeDetail.total_overtime)}
</span>
</div>
</div>
)}
</div>
)}
{/* ── Sick stats table ── */}
{sickStats && (
<div className='overflow-x-auto'>
{sickStats.length === 0 ? (
<p className='text-center text-gray-400 py-12 text-sm'>Keine Krankmeldungen im Zeitraum.</p>
) : (
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide'>
<tr>
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
<th className='px-4 py-3 text-left'>Pers.-Nr.</th>
<th className='px-4 py-3 text-right'>Episoden</th>
<th className='px-4 py-3 text-right'>Tage</th>
<th className='px-4 py-3 text-right'>Bradford</th>
<th className='px-4 py-3 text-right'>AU offen</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{[...sickStats].sort((a, b) => b.bradford_factor - a.bradford_factor).map(r => (
<tr key={r.user_id} className='hover:bg-gray-50'>
<td className='px-4 py-3 font-medium text-gray-800'>{r.user_name}</td>
<td className='px-4 py-3 text-gray-500'>{r.personnel_number ?? ''}</td>
<td className='px-4 py-3 text-right text-gray-700'>{r.episodes}</td>
<td className='px-4 py-3 text-right text-gray-700'>{r.total_days.toFixed(1)}</td>
<td className={`px-4 py-3 text-right font-semibold ${r.bradford_factor >= 200 ? 'text-red-600' : r.bradford_factor >= 50 ? 'text-orange-600' : 'text-gray-700'}`}>
{r.bradford_factor.toFixed(1)}
</td>
<td className={`px-4 py-3 text-right ${r.certificates_overdue > 0 ? 'text-red-600 font-bold' : 'text-gray-400'}`}>
{r.certificates_overdue}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className='text-xs text-gray-400 px-4 py-3 border-t border-gray-100'>
Bradford-Faktor: Episoden² × Tage. Stufen: ab 50 = Aufmerksamkeit, ab 200 = Handlungsbedarf.
</p>
</div>
)}
</div>
)}
</div>
</Layout>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { api } from '../api/client'
export function ResetPasswordPage() {
const [params] = useSearchParams()
const token = params.get('token') ?? ''
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [password2, setPassword2] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (password !== password2) { setError('Passwörter stimmen nicht überein.'); return }
if (password.length < 8) { setError('Mindestens 8 Zeichen erforderlich.'); return }
setError('')
setLoading(true)
try {
await api.post('/auth/password-reset/confirm', { token, new_password: password })
navigate('/login', { state: { message: 'Passwort erfolgreich geändert. Bitte melde dich an.' } })
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Fehler beim Zurücksetzen')
} finally {
setLoading(false)
}
}
if (!token) return (
<div className='min-h-screen flex items-center justify-center p-4'>
<div className='text-center'>
<p className='text-gray-600 mb-4'>Ungültiger oder fehlender Reset-Link.</p>
<Link to='/login' className='text-blue-600 hover:underline'>Zurück zur Anmeldung</Link>
</div>
</div>
)
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4'>
<div className='w-full max-w-sm bg-white rounded-2xl shadow-lg p-8'>
<div className='flex items-center gap-2 mb-6'>
<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='text-lg font-bold text-gray-800'>TimeMaster</span>
</div>
<h1 className='text-xl font-semibold text-gray-800 mb-1'>Neues Passwort</h1>
<p className='text-sm text-gray-500 mb-6'>Wähle ein sicheres Passwort (min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer).</p>
<form onSubmit={submit} className='space-y-4'>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>Neues Passwort</span>
<input
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
required
autoFocus
className='mt-1 w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
/>
</label>
<label className='block'>
<span className='text-sm font-medium text-gray-700'>Passwort wiederholen</span>
<input
type='password'
value={password2}
onChange={e => setPassword2(e.target.value)}
required
className='mt-1 w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
/>
</label>
{error && (
<div className='text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5'>{error}</div>
)}
<button
type='submit'
disabled={loading}
className='w-full py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'
>
{loading ? 'Speichern…' : 'Passwort speichern'}
</button>
</form>
</div>
</div>
)
}
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useState } 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
}
interface SmtpConfigOut {
id: string
company_id: string
host: string
port: number
use_tls: boolean
use_starttls: boolean
username: string | null
from_email: string
from_name: string
is_enabled: boolean
}
const DEFAULT_FORM = {
host: '',
port: 587,
use_tls: false,
use_starttls: true,
username: '',
password: '',
from_email: '',
from_name: 'TimeMaster',
is_enabled: true,
}
export function SmtpSettingsPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [config, setConfig] = useState<SmtpConfigOut | null>(null)
const [form, setForm] = useState(DEFAULT_FORM)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testEmail, setTestEmail] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
async function load() {
try {
const [me, cfg] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<SmtpConfigOut | null>('/smtp/config'),
])
setUser(me)
setTestEmail(me.email)
if (cfg) {
setConfig(cfg)
setForm({
host: cfg.host,
port: cfg.port,
use_tls: cfg.use_tls,
use_starttls: cfg.use_starttls,
username: cfg.username ?? '',
password: '', // nie vom Server zurückgegeben
from_email: cfg.from_email,
from_name: cfg.from_name,
is_enabled: cfg.is_enabled,
})
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
load()
}, [])
const save = async () => {
setSaving(true)
setError('')
setSuccess('')
try {
const body = {
...form,
username: form.username || null,
password: form.password || null,
}
const saved = await api.post<SmtpConfigOut>('/smtp/config', body)
setConfig(saved)
setForm(f => ({ ...f, password: '' }))
setSuccess('SMTP-Konfiguration gespeichert.')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const test = async () => {
if (!testEmail) return
setTesting(true)
setError('')
setSuccess('')
try {
const res = await api.post<{ message: string }>('/smtp/test', { to: testEmail })
setSuccess(res.message)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'SMTP-Test fehlgeschlagen')
} finally {
setTesting(false)
}
}
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.type === 'checkbox' ? e.target.checked : e.target.type === 'number' ? Number(e.target.value) : e.target.value
setForm(f => ({ ...f, [key]: val }))
}
const inputCls = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='max-w-2xl space-y-6'>
<h1 className='text-2xl font-bold text-gray-900'>SMTP-Konfiguration</h1>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{success && <div className='bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-700'>{success}</div>}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5'>
<h2 className='text-base font-semibold text-gray-800'>Postausgangs-Server</h2>
<div className='grid grid-cols-3 gap-3'>
<div className='col-span-2'>
<label className='block text-sm font-medium text-gray-700 mb-1'>SMTP-Server *</label>
<input type='text' value={form.host} onChange={set('host')} placeholder='mail.example.de' className={inputCls} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Port *</label>
<input type='number' value={form.port} onChange={set('port')} min={1} max={65535} className={inputCls} />
</div>
</div>
<div className='flex gap-6'>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.use_starttls} onChange={set('use_starttls')} className='rounded' />
STARTTLS (Port 587)
</label>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.use_tls} onChange={set('use_tls')} className='rounded' />
SSL/TLS (Port 465)
</label>
</div>
<div className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Benutzername</label>
<input type='text' value={form.username} onChange={set('username')} placeholder='user@example.de' className={inputCls} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>
Passwort {config ? '(leer lassen = unverändert)' : ''}
</label>
<input type='password' value={form.password} onChange={set('password')} placeholder='••••••••' className={inputCls} autoComplete='new-password' />
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Absender-E-Mail *</label>
<input type='email' value={form.from_email} onChange={set('from_email')} placeholder='noreply@example.de' className={inputCls} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Absender-Name *</label>
<input type='text' value={form.from_name} onChange={set('from_name')} placeholder='TimeMaster' className={inputCls} />
</div>
</div>
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
<input type='checkbox' checked={form.is_enabled} onChange={set('is_enabled')} className='rounded' />
SMTP aktiviert (E-Mails werden tatsächlich versendet)
</label>
<div className='flex justify-end'>
<button
onClick={save}
disabled={saving || !form.host || !form.from_email}
className='px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
>
{saving ? 'Speichern…' : 'Konfiguration speichern'}
</button>
</div>
</div>
{/* Test */}
{config && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<h2 className='text-base font-semibold text-gray-800 mb-4'>Test-E-Mail senden</h2>
<div className='flex gap-3'>
<input
type='email'
value={testEmail}
onChange={e => setTestEmail(e.target.value)}
placeholder='test@example.de'
className={`flex-1 ${inputCls}`}
/>
<button
onClick={test}
disabled={testing || !testEmail}
className='px-5 py-2 bg-gray-700 text-white rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50'
>
{testing ? 'Sende…' : 'Test senden'}
</button>
</div>
<p className='text-xs text-gray-400 mt-2'>
Sendet eine Test-E-Mail mit der aktuell <em>gespeicherten</em> Konfiguration.
</p>
</div>
)}
{/* Preset-Hilfe */}
<div className='bg-blue-50 border border-blue-100 rounded-xl p-5'>
<p className='text-sm font-semibold text-blue-800 mb-2'>Typische Einstellungen</p>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-blue-700'>
{[
{ name: 'Gmail', host: 'smtp.gmail.com', port: '587', tls: 'STARTTLS' },
{ name: 'Outlook/Office 365', host: 'smtp.office365.com', port: '587', tls: 'STARTTLS' },
{ name: 'Ionos / 1&1', host: 'smtp.ionos.de', port: '587', tls: 'STARTTLS' },
{ name: 'Strato', host: 'smtp.strato.de', port: '465', tls: 'SSL/TLS' },
].map(p => (
<div key={p.name} className='bg-white rounded-lg px-3 py-2'>
<span className='font-medium'>{p.name}</span>: {p.host}:{p.port} ({p.tls})
</div>
))}
</div>
</div>
</div>
</Layout>
)
}
+664
View File
@@ -0,0 +1,664 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
company_id: string
can_manual_time_entry: boolean
}
interface TimeEntryOut {
id: string
user_id: string
date: string // "YYYY-MM-DD"
start_time: string // "HH:MM:SS"
end_time: string | null // "HH:MM:SS" oder null wenn offen
break_minutes: number
worked_hours: number | null
note: string | null
correction_note: string | null
status: string
source: string
}
interface TimeEntryWithWarnings {
entry: TimeEntryOut
warnings: string[]
}
interface TimeEntryListResponse {
total: number
items: TimeEntryOut[]
}
interface BalanceResponse {
user_id: string
period_start: string
period_end: string
total_hours_worked: number
expected_hours: number
overtime_hours: number
approved_entries: number
pending_entries: number
}
interface TodayStatus {
today_open: boolean
today_start: string | null
today_hours_so_far: number | null
}
const STATUS_LABELS: Record<string, string> = {
open: 'Offen',
pending: 'Zur Prüfung',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
auto: 'Auto',
}
const STATUS_COLORS: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700',
pending: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
auto: 'bg-gray-100 text-gray-600',
}
function fmt(iso: string | null): string {
if (!iso) return ''
// Backend kann reines time-Objekt liefern ("HH:MM:SS") oder ISO-Datetime
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
const d = new Date(iso)
if (isNaN(d.getTime())) return iso.slice(0, 5)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function fmtH(h: number | null): string {
if (h === null || h === undefined) return ''
const hrs = Math.floor(h)
const min = Math.round((h - hrs) * 60)
return `${hrs}h ${min}m`
}
export function TimeTrackingPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
const [balance, setBalance] = useState<BalanceResponse | null>(null)
const [entries, setEntries] = useState<TimeEntryOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [stamping, setStamping] = useState(false)
const [note, setNote] = useState('')
const [warnings, setWarnings] = useState<string[]>([])
const [error, setError] = useState('')
// Edit modal
const [editEntry, setEditEntry] = useState<TimeEntryOut | null>(null)
const [editStart, setEditStart] = useState('')
const [editEnd, setEditEnd] = useState('')
const [editBreak, setEditBreak] = useState(0)
const [editNote, setEditNote] = useState('')
const [editCorrectionNote, setEditCorrectionNote] = useState('')
const [editSaving, setEditSaving] = useState(false)
const [editError, setEditError] = useState('')
// Duplicate modal
const [dupEntry, setDupEntry] = useState<TimeEntryOut | null>(null)
const [dupDate, setDupDate] = useState('')
const [dupStart, setDupStart] = useState('')
const [dupEnd, setDupEnd] = useState('')
const [dupBreak, setDupBreak] = useState(0)
const [dupNote, setDupNote] = useState('')
const [dupSaving, setDupSaving] = useState(false)
const [dupError, setDupError] = useState('')
// New manual entry modal
const [showNew, setShowNew] = useState(false)
const [newDate, setNewDate] = useState('')
const [newStart, setNewStart] = useState('')
const [newEnd, setNewEnd] = useState('')
const [newBreak, setNewBreak] = useState(0)
const [newNote, setNewNote] = useState('')
const [newSaving, setNewSaving] = useState(false)
const [newError, setNewError] = useState('')
const [liveSeconds, setLiveSeconds] = useState(0)
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [me, dash, bal, list] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<TodayStatus>('/dashboard/me'),
api.get<BalanceResponse>('/time/balance/me'),
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
])
setUser(me)
setDashboard(dash)
setBalance(bal)
setEntries(list.items)
setTotal(list.total)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// Live-Ticker: läuft nur wenn eingestempelt
useEffect(() => {
if (tickerRef.current) clearInterval(tickerRef.current)
if (dashboard?.today_open && dashboard.today_start) {
// Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen
const startStr = dashboard.today_start
const today = new Date()
const [h, m, s] = startStr.split(':').map(Number)
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs) / 1000)))
update()
tickerRef.current = setInterval(update, 1000)
} else {
setLiveSeconds(0)
}
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
}, [dashboard?.today_open, dashboard?.today_start])
const stampIn = async () => {
setStamping(true); setError(''); setWarnings([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-in', { note: note || null })
if (res.warnings.length) setWarnings(res.warnings)
setNote('')
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const stampOut = async () => {
setStamping(true); setError(''); setWarnings([])
try {
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-out', { note: note || null })
if (res.warnings.length) setWarnings(res.warnings)
setNote('')
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakStart = async () => {
setStamping(true); setError('')
try { await api.post('/time/break-start', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const breakEnd = async () => {
setStamping(true); setError('')
try { await api.post('/time/break-end', {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setStamping(false) }
}
const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '')
const canManual = isManager || (user?.can_manual_time_entry ?? false)
const deleteEntry = async (id: string) => {
if (!confirm('Eintrag wirklich löschen?')) return
setError('')
try {
await api.del(`/time/entries/${id}`)
await load()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Löschen') }
}
const openDuplicate = (entry: TimeEntryOut) => {
setDupEntry(entry)
setDupDate(new Date().toISOString().slice(0, 10)) // heute als Standard
setDupStart(fmt(entry.start_time))
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
setDupBreak(entry.break_minutes)
setDupNote(entry.note ?? '')
setDupError('')
}
const saveDuplicate = async () => {
if (!dupDate || !dupStart) { setDupError('Datum und Startzeit sind Pflicht.'); return }
setDupSaving(true); setDupError('')
try {
const payload: Record<string, unknown> = {
date: dupDate,
start_time: dupStart,
break_minutes: dupBreak,
note: dupNote || null,
}
if (dupEnd) payload.end_time = dupEnd
await api.post('/time/entries', payload)
setDupEntry(null)
await load()
} catch (e: unknown) { setDupError(e instanceof Error ? e.message : 'Fehler') }
finally { setDupSaving(false) }
}
const openNew = () => {
setNewDate(new Date().toISOString().slice(0, 10))
setNewStart(''); setNewEnd(''); setNewBreak(0); setNewNote(''); setNewError('')
setShowNew(true)
}
const saveNew = async () => {
if (!newDate || !newStart) { setNewError('Datum und Startzeit sind Pflicht.'); return }
setNewSaving(true); setNewError('')
try {
const payload: Record<string, unknown> = {
date: newDate, start_time: newStart, break_minutes: newBreak, note: newNote || null,
}
if (newEnd) payload.end_time = newEnd
await api.post('/time/entries', payload)
setShowNew(false)
await load()
} catch (e: unknown) { setNewError(e instanceof Error ? e.message : 'Fehler') }
finally { setNewSaving(false) }
}
const openEdit = (entry: TimeEntryOut) => {
setEditEntry(entry)
setEditStart(fmt(entry.start_time))
setEditEnd(entry.end_time ? fmt(entry.end_time) : '')
setEditBreak(entry.break_minutes)
setEditNote(entry.note ?? '')
setEditCorrectionNote('')
setEditError('')
}
const saveEdit = async () => {
if (!editEntry) return
if (!editStart) { setEditError('Startzeit fehlt.'); return }
if (editEntry.status === 'approved' && !editCorrectionNote.trim()) {
setEditError('Änderungsgrund ist bei genehmigten Einträgen Pflicht.')
return
}
setEditSaving(true); setEditError('')
try {
const payload: Record<string, unknown> = {
start_time: editStart,
break_minutes: editBreak,
note: editNote || null,
}
if (editEnd) payload.end_time = editEnd
if (editEntry.status === 'approved') payload.correction_note = editCorrectionNote.trim()
await api.patch(`/time/entries/${editEntry.id}`, payload)
setEditEntry(null)
await load()
} catch (e: unknown) { setEditError(e instanceof Error ? e.message : 'Fehler') }
finally { setEditSaving(false) }
}
if (loading) return (
<Layout userRole='' userName=''>
<div className='flex justify-center py-20'><Spinner /></div>
</Layout>
)
if (!user) return (
<Layout userRole='' userName=''>
{error && <div className='m-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700'>{error}</div>}
</Layout>
)
const isOpen = dashboard?.today_open ?? false
const inp = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
<h1 className='text-2xl font-bold text-gray-900'>Zeiterfassung</h1>
{warnings.length > 0 && (
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-4'>
<p className='font-medium text-yellow-800 mb-1'>Hinweise:</p>
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{error && (
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
)}
{/* Stempeluhr */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<h2 className='text-lg font-semibold text-gray-800 mb-4'>Stempeluhr</h2>
<div className='grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4'>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Status</p>
<p className={`text-lg font-bold ${isOpen ? 'text-green-600' : 'text-gray-500'}`}>
{isOpen ? 'Eingestempelt' : 'Ausgestempelt'}
</p>
</div>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Beginn</p>
<p className='text-lg font-bold text-gray-800'>{fmt(dashboard?.today_start ?? null)}</p>
</div>
<div className='text-center p-4 bg-gray-50 rounded-lg'>
<p className='text-xs text-gray-500 mb-1'>Bisher heute</p>
<p className='text-lg font-bold text-gray-800'>
{dashboard?.today_open && liveSeconds > 0
? `${Math.floor(liveSeconds / 3600)}h ${Math.floor((liveSeconds % 3600) / 60)}m ${liveSeconds % 60}s`
: fmtH(dashboard?.today_hours_so_far ?? null)}
</p>
</div>
</div>
<div className='flex gap-2 mb-3'>
<input
type='text'
placeholder='Notiz (optional)'
value={note}
onChange={e => setNote(e.target.value)}
className='flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
<div className='flex flex-wrap gap-2'>
{!isOpen ? (
<button onClick={stampIn} disabled={stamping}
className='px-6 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors'>
{stamping ? '...' : 'Einstempeln'}
</button>
) : (
<>
<button onClick={stampOut} disabled={stamping}
className='px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors'>
{stamping ? '...' : 'Ausstempeln'}
</button>
<button onClick={breakStart} disabled={stamping}
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
Pause starten
</button>
<button onClick={breakEnd} disabled={stamping}
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
Pause beenden
</button>
</>
)}
</div>
</div>
{/* Balance */}
{balance && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3'>
Monat ({new Date(balance.period_start).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })})
</h2>
<div className='grid grid-cols-2 sm:grid-cols-4 gap-4'>
{[
{ label: 'Gearbeitet', value: fmtH(balance.total_hours_worked) },
{ label: 'Soll', value: fmtH(balance.expected_hours) },
{ label: 'Überstunden', value: fmtH(balance.overtime_hours), ot: balance.overtime_hours },
{ label: 'Genehmigte Einträge', value: String(balance.approved_entries) },
].map(({ label, value, ot }) => (
<div key={label}>
<p className='text-xs text-gray-500'>{label}</p>
<p className={`text-xl font-bold mt-1 ${
ot !== undefined ? (ot > 0 ? 'text-green-600' : ot < 0 ? 'text-red-600' : 'text-gray-800') : 'text-gray-800'
}`}>{value}</p>
</div>
))}
</div>
</div>
)}
{/* Recent entries */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between'>
<h2 className='text-lg font-semibold text-gray-800'>Letzte Einträge</h2>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-400'>{total} gesamt</span>
{canManual && (
<button
onClick={openNew}
className='flex items-center gap-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-3.5 h-3.5' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z' clipRule='evenodd' />
</svg>
Neuer Eintrag
</button>
)}
</div>
</div>
<div className='divide-y divide-gray-50'>
{entries.length === 0 ? (
<p className='text-center text-gray-400 py-8 text-sm'>Noch keine Einträge</p>
) : (
entries.map(entry => (
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
<div className='flex-1'>
<p className='text-sm font-medium text-gray-800'>
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<p className='text-xs text-gray-500'>
{fmt(entry.start_time)} {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
</p>
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
{entry.correction_note && <p className='text-xs text-orange-500 mt-0.5'> {entry.correction_note}</p>}
</div>
<div className='flex items-center gap-3'>
<div className='text-right'>
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[entry.status] ?? entry.status}
</span>
</div>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => openEdit(entry)}
title={entry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Bearbeiten'}
className={`transition-colors p-1 ${entry.status === 'approved' ? 'text-orange-400 hover:text-orange-600' : 'text-gray-400 hover:text-blue-600'}`}
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z' />
</svg>
</button>
)}
<button
onClick={() => openDuplicate(entry)}
title='Eintrag duplizieren'
className='transition-colors p-1 text-gray-400 hover:text-purple-600'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path d='M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z' />
<path d='M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z' />
</svg>
</button>
{(entry.status !== 'approved' || isManager) && (
<button
onClick={() => deleteEntry(entry.id)}
title='Eintrag löschen'
className='transition-colors p-1 text-gray-400 hover:text-red-600'
>
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z' clipRule='evenodd' />
</svg>
</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Duplicate Modal */}
{dupEntry && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
<div className='flex justify-between items-center mb-4'>
<h2 className='text-base font-semibold text-gray-800'>Eintrag duplizieren</h2>
<button onClick={() => setDupEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<p className='text-xs text-gray-500 mb-4'>
Kopie von: {new Date(dupEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Datum *</span>
<input type='date' value={dupDate} onChange={e => setDupDate(e.target.value)} className={inp} />
</label>
<div className='grid grid-cols-2 gap-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={dupStart} onChange={e => setDupStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={dupEnd} onChange={e => setDupEnd(e.target.value)} className={inp} />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={dupBreak}
onChange={e => setDupBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={dupNote} onChange={e => setDupNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{dupError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{dupError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setDupEntry(null)}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={saveDuplicate} disabled={dupSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
{dupSaving ? 'Wird erstellt…' : 'Duplizieren'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editEntry && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
<div className='flex justify-between items-center mb-4'>
<h2 className='text-base font-semibold text-gray-800'>
{editEntry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Eintrag bearbeiten'}
</h2>
<button onClick={() => setEditEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<p className='text-xs text-gray-500 mb-4'>
{new Date(editEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
<div className='space-y-3'>
<div className='grid grid-cols-2 gap-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={editStart} onChange={e => setEditStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={editEnd} onChange={e => setEditEnd(e.target.value)} className={inp} />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={editBreak}
onChange={e => setEditBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={editNote} onChange={e => setEditNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{editEntry.status === 'approved' && (
<label className='block'>
<span className='text-xs font-medium text-red-600'>Änderungsgrund *</span>
<input type='text' value={editCorrectionNote}
onChange={e => setEditCorrectionNote(e.target.value)}
placeholder='z. B. Tippfehler bei Stempelzeit korrigiert'
className={`${inp} border-red-300 focus:ring-red-400`} />
<p className='text-xs text-gray-400 mt-1'>Wird im Änderungsprotokoll gespeichert.</p>
</label>
)}
{editError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditEntry(null)}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={saveEdit} disabled={editSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{editSaving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Neuer manueller Eintrag Modal */}
{showNew && (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
<h2 className='text-base font-semibold text-gray-800 mb-4'>Neuer Eintrag</h2>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Datum *</span>
<input type='date' value={newDate} onChange={e => setNewDate(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
<input type='time' value={newStart} onChange={e => setNewStart(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ende</span>
<input type='time' value={newEnd} onChange={e => setNewEnd(e.target.value)} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
<input type='number' min={0} max={480} value={newBreak}
onChange={e => setNewBreak(Number(e.target.value))} className={inp} />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<input type='text' value={newNote} onChange={e => setNewNote(e.target.value)}
placeholder='Optional' className={inp} />
</label>
{newError && (
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{newError}</p>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowNew(false)}
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={saveNew} disabled={newSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{newSaving ? 'Speichere…' : 'Eintrag anlegen'}
</button>
</div>
</div>
</div>
</div>
)}
</Layout>
)
}
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface Me {
id: string
first_name: string
last_name: string
role: string
}
interface ImportRowResult {
row: number
email: string
personnel_number: string | null
action: 'created' | 'reactivated' | 'error' | 'skipped'
message: string | null
}
interface ImportResult {
total_rows: number
created: number
reactivated: number
errors: number
items: ImportRowResult[]
}
const ACTION_LABEL: Record<string, { text: string; color: string }> = {
created: { text: 'Neu', color: 'bg-green-100 text-green-700' },
reactivated: { text: 'Reaktiviert', color: 'bg-blue-100 text-blue-700' },
error: { text: 'Fehler', color: 'bg-red-100 text-red-700' },
skipped: { text: 'Übersprungen', color: 'bg-gray-100 text-gray-600' },
}
export default function UserImportPage() {
const [me, setMe] = useState<Me | null>(null)
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<ImportResult | null>(null)
const [result, setResult] = useState<ImportResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {})
}, [])
function downloadTemplate() {
// Endpoint mit Auth-Header öffnen → wir laden via fetch und triggern Download
const token = localStorage.getItem('access_token')
fetch('/api/v1/users/import-template.csv', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'user-import-template.csv'
a.click()
URL.revokeObjectURL(url)
})
.catch(e => setError(e instanceof Error ? e.message : 'Download fehlgeschlagen'))
}
async function loadPreview() {
if (!file) return
setLoading(true)
setError(null)
setResult(null)
setPreview(null)
try {
const form = new FormData()
form.append('file', file)
const r = await api.postForm<ImportResult>('/users/import/preview', form)
setPreview(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler bei der Vorschau')
} finally {
setLoading(false)
}
}
async function applyImport() {
if (!file) return
if (!confirm(`Import wirklich ausführen? ${preview?.total_rows ?? '?'} Zeilen werden verarbeitet.`)) return
setLoading(true)
setError(null)
try {
const form = new FormData()
form.append('file', file)
const r = await api.postForm<ImportResult>('/users/import/apply', form)
setResult(r)
setPreview(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Import')
} finally {
setLoading(false)
}
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className='max-w-4xl mx-auto space-y-6'>
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold text-gray-900'>Mitarbeiter-Import (CSV)</h1>
<button
onClick={downloadTemplate}
className='px-4 py-2 text-sm font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50'
>
Vorlage herunterladen
</button>
</div>
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4'>
<p className='text-sm text-gray-600'>
Lade eine CSV-Datei mit Mitarbeitern hoch. Pflicht-Spalten: <code className='font-mono text-xs bg-gray-100 px-1 rounded'>email, first_name, last_name</code>.
Optional: <code className='font-mono text-xs bg-gray-100 px-1 rounded'>role, personnel_number, kuerzel</code>.
</p>
<ul className='text-xs text-gray-500 list-disc pl-5 space-y-1'>
<li>Doppelte E-Mail im Import Fehler.</li>
<li>Existiert die E-Mail bereits aber deaktiviert Reaktivierung mit neuen Daten.</li>
<li>Leere Personalnr. Auto-Vergabe (auch im manuellen Modus).</li>
<li>Personalnr. nur Ziffern; Konflikt mit reservierten Nummern wird abgelehnt.</li>
</ul>
<div className='flex items-center gap-3 pt-2'>
<input
type='file'
accept='.csv,text/csv'
onChange={e => { setFile(e.target.files?.[0] ?? null); setPreview(null); setResult(null) }}
className='text-sm'
/>
<button
onClick={loadPreview}
disabled={!file || loading}
className='px-4 py-2 bg-gray-700 text-white text-sm font-medium rounded-lg hover:bg-gray-800 disabled:opacity-50'
>
{loading && !preview ? 'Prüfe…' : 'Vorschau'}
</button>
{preview && (
<button
onClick={applyImport}
disabled={loading || preview.errors > 0}
className='px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50'
title={preview.errors > 0 ? 'Erst alle Fehler beheben' : ''}
>
{loading ? 'Importiere…' : `Importieren (${preview.total_rows - preview.errors})`}
</button>
)}
</div>
{error && (
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
)}
</div>
{(preview || result) && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<div className='flex items-center justify-between mb-4'>
<h2 className='font-semibold text-gray-700'>{result ? 'Import-Ergebnis' : 'Vorschau'}</h2>
<div className='flex gap-2 text-xs'>
<span className='px-2 py-1 rounded bg-green-50 text-green-700'>
{(result ?? preview)!.created} Neu
</span>
<span className='px-2 py-1 rounded bg-blue-50 text-blue-700'>
{(result ?? preview)!.reactivated} Reaktiviert
</span>
<span className='px-2 py-1 rounded bg-red-50 text-red-700'>
{(result ?? preview)!.errors} Fehler
</span>
</div>
</div>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
<th className='px-3 py-2 text-left'>Zeile</th>
<th className='px-3 py-2 text-left'>Aktion</th>
<th className='px-3 py-2 text-left'>E-Mail</th>
<th className='px-3 py-2 text-left'>Pers.-Nr.</th>
<th className='px-3 py-2 text-left'>Meldung</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{(result ?? preview)!.items.map((it, i) => (
<tr key={i} className={it.action === 'error' ? 'bg-red-50/50' : ''}>
<td className='px-3 py-2 text-gray-500'>{it.row}</td>
<td className='px-3 py-2'>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${ACTION_LABEL[it.action]?.color ?? 'bg-gray-100 text-gray-600'}`}>
{ACTION_LABEL[it.action]?.text ?? it.action}
</span>
</td>
<td className='px-3 py-2 text-gray-700'>{it.email || '—'}</td>
<td className='px-3 py-2 font-mono text-xs text-gray-600'>{it.personnel_number || '—'}</td>
<td className='px-3 py-2 text-xs text-gray-500'>{it.message || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Layout>
)
}
+604
View File
@@ -0,0 +1,604 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import { Modal } from '../components/Modal'
interface UserOut {
id: string
email: string
first_name: string
last_name: string
full_name: string
role: string
is_active: boolean
auth_provider: string
department_id: string | null
work_schedule_id: string | null
kuerzel: string | null
personnel_number: string | null
can_manual_time_entry: boolean
last_login: string | null
created_at: string
}
interface CompanyOut {
id: string
personnel_number_required: boolean
personnel_number_mode: 'manual' | 'auto'
personnel_number_next: number
}
interface WorkScheduleOut {
id: string
name: string
mon_h: number
tue_h: number
wed_h: number
thu_h: number
fri_h: number
sat_h: number
sun_h: number
}
interface Me {
first_name: string
last_name: string
role: string
}
interface UserListResponse {
total: number
items: UserOut[]
}
const ROLES = ['EMPLOYEE', 'MANAGER', 'HR', 'COMPANY_ADMIN']
const ROLE_LABELS: Record<string, string> = {
COMPANY_ADMIN: 'Administrator',
HR: 'HR',
MANAGER: 'Manager',
EMPLOYEE: 'Mitarbeiter',
}
const ROLE_DESCRIPTIONS: Record<string, string> = {
EMPLOYEE: 'Kann eigene Zeiten erfassen, Abwesenheiten beantragen und den eigenen Kalender einsehen.',
MANAGER: 'Wie Mitarbeiter, zusätzlich: kann Abwesenheiten seines Teams genehmigen oder ablehnen und Berichte einsehen.',
HR: 'Wie Manager, zusätzlich: kann Mitarbeiter verwalten, alle Abwesenheiten bearbeiten und Berichte für alle einsehen.',
COMPANY_ADMIN: 'Vollzugriff: alle Einstellungen, Mitarbeiterverwaltung, Arbeitspläne, LDAP, SMTP und CalDAV konfigurieren.',
}
function Badge({ text, color }: { text: string; color: string }) {
return (
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
{text}
</span>
)
}
interface InviteForm {
email: string
first_name: string
last_name: string
role: string
personnel_number: string
initial_password: string
}
const EMPTY_INVITE: InviteForm = { email: '', first_name: '', last_name: '', role: 'EMPLOYEE', personnel_number: '', initial_password: '' }
export function UsersPage() {
const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
// Invite modal
const [showInvite, setShowInvite] = useState(false)
const [invite, setInvite] = useState<InviteForm>(EMPTY_INVITE)
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteError, setInviteError] = useState('')
const [inviteSuccess, setInviteSuccess] = useState('')
// Work schedules
const [workSchedules, setWorkSchedules] = useState<WorkScheduleOut[]>([])
// Edit role modal
const [editUser, setEditUser] = useState<UserOut | null>(null)
const [editRole, setEditRole] = useState('')
const [editScheduleId, setEditScheduleId] = useState<string | null>(null)
const [editKuerzel, setEditKuerzel] = useState<string>('')
const [editPersonnelNr, setEditPersonnelNr] = useState<string>('')
const [editCanManual, setEditCanManual] = useState(false)
const [editLoading, setEditLoading] = useState(false)
// Personalnummer-Verfügbarkeitsprüfung
const [personnelCheck, setPersonnelCheck] = useState<'idle' | 'checking' | 'free' | 'taken' | 'invalid'>('idle')
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
const [company, setCompany] = useState<CompanyOut | null>(null)
async function load() {
try {
const [meData, listData, schedulesData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<UserListResponse>('/users'),
api.get<WorkScheduleOut[]>('/time/schedules').catch(() => [] as WorkScheduleOut[]),
])
setMe(meData)
setUsers(listData.items)
setTotal(listData.total)
setWorkSchedules(schedulesData)
// Company-Settings laden (für Auto-Modus + Pflicht)
try {
const meFull = await api.get<{ company_id: string }>('/users/me')
if (meFull.company_id) {
const c = await api.get<CompanyOut>(`/companies/${meFull.company_id}`)
setCompany(c)
}
} catch { /* nicht kritisch */ }
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
function weeklyHours(s: WorkScheduleOut): number {
return Number(s.mon_h) + Number(s.tue_h) + Number(s.wed_h) + Number(s.thu_h) + Number(s.fri_h) + Number(s.sat_h) + Number(s.sun_h)
}
useEffect(() => { load() }, [])
async function handleInvite() {
setInviteError('')
setInviteSuccess('')
if (!invite.email || !invite.first_name || !invite.last_name) {
setInviteError('Alle Felder sind erforderlich.')
return
}
setInviteLoading(true)
try {
const payload = { ...invite, initial_password: invite.initial_password || undefined }
await api.post('/users/invite', payload)
const msg = invite.initial_password
? `Benutzer ${invite.email} wurde direkt angelegt und ist sofort aktiv.`
: `Einladung an ${invite.email} gesendet.`
setInviteSuccess(msg)
setInvite(EMPTY_INVITE)
load()
} catch (e: unknown) {
setInviteError(e instanceof Error ? e.message : 'Fehler')
} finally {
setInviteLoading(false)
}
}
async function handleDeactivate(user: UserOut) {
if (!confirm(`${user.full_name} wirklich deaktivieren?`)) return
try {
await api.patch(`/users/${user.id}`, { is_active: false })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleReactivate(user: UserOut) {
try {
await api.patch(`/users/${user.id}`, { is_active: true })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleEditRole() {
if (!editUser) return
setEditLoading(true)
try {
const payload: Record<string, unknown> = {
role: editRole,
work_schedule_id: editScheduleId || null,
kuerzel: editKuerzel.trim() || null,
can_manual_time_entry: editCanManual,
}
// Personalnr. nur senden wenn geändert
const newPn = editPersonnelNr.trim()
if (newPn && newPn !== (editUser.personnel_number || '')) {
payload.personnel_number = newPn
}
await api.patch(`/users/${editUser.id}`, payload)
setEditUser(null)
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
} finally {
setEditLoading(false)
}
}
async function suggestPersonnelNumber(target: 'invite' | 'edit') {
try {
const r = await api.get<{ next: string }>('/users/next-personnel-number')
if (target === 'invite') setInvite(f => ({ ...f, personnel_number: r.next }))
else setEditPersonnelNr(r.next)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
// Debounced Live-Verfügbarkeitsprüfung
function checkPersonnelNumber(value: string, currentUserId?: string) {
setPersonnelCheck('idle')
const v = value.trim()
if (!v) return
if (!/^[0-9]+$/.test(v)) {
setPersonnelCheck('invalid')
return
}
setPersonnelCheck('checking')
const handle = setTimeout(async () => {
try {
const u = await api.get<UserOut>(`/users/by-personnel/${encodeURIComponent(v)}`)
if (u.id === currentUserId) setPersonnelCheck('free')
else setPersonnelCheck('taken')
} catch {
// 404 = frei
setPersonnelCheck('free')
}
}, 400)
return () => clearTimeout(handle)
}
const filtered = users.filter(u =>
`${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase())
)
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
if (loading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
if (error) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<p className='text-red-600'>{error}</p>
</div>
)
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<div>
<h1 className='text-xl font-bold text-gray-800'>Mitarbeiterverwaltung</h1>
<p className='text-sm text-gray-500 mt-0.5'>{total} Mitarbeiter gesamt</p>
</div>
<button
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Einladen
</button>
</div>
{/* Search */}
<input
type='text'
placeholder='Name oder E-Mail suchen…'
value={search}
onChange={e => setSearch(e.target.value)}
className={inputClass}
/>
{/* Table */}
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Name', 'Pers.-Nr.', 'E-Mail', 'Rolle', 'Auth', 'Status', 'Letzter Login', ''].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{filtered.map(u => (
<tr key={u.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3'>
<div className='font-medium text-gray-800'>{u.full_name}</div>
{u.work_schedule_id && (
<div className='text-xs text-gray-400 mt-0.5'>
{workSchedules.find(s => s.id === u.work_schedule_id)
? `${workSchedules.find(s => s.id === u.work_schedule_id)!.name} (${weeklyHours(workSchedules.find(s => s.id === u.work_schedule_id)!)}h/Wo.)`
: ''}
</div>
)}
</td>
<td className='px-4 py-3 text-gray-600 font-mono text-xs'>{u.personnel_number || '—'}</td>
<td className='px-4 py-3 text-gray-600'>{u.email}</td>
<td className='px-4 py-3'>
<Badge
text={ROLE_LABELS[u.role] ?? u.role}
color={u.role === 'COMPANY_ADMIN' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}
/>
</td>
<td className='px-4 py-3'>
<Badge
text={u.auth_provider === 'ldap' ? 'LDAP' : 'Lokal'}
color={u.auth_provider === 'ldap' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}
/>
</td>
<td className='px-4 py-3'>
<Badge
text={u.is_active ? 'Aktiv' : 'Inaktiv'}
color={u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
/>
</td>
<td className='px-4 py-3 text-gray-500'>
{u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'}
</td>
<td className='px-4 py-3'>
<div className='flex gap-2 justify-end'>
<button
onClick={() => { setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id); setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? ''); setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle') }}
className='text-xs text-blue-600 hover:underline'
>
Bearbeiten
</button>
{u.is_active ? (
<button
onClick={() => handleDeactivate(u)}
className='text-xs text-red-500 hover:underline'
>
Deaktivieren
</button>
) : (
<button
onClick={() => handleReactivate(u)}
className='text-xs text-green-600 hover:underline'
>
Reaktivieren
</button>
)}
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={8} className='px-4 py-8 text-center text-gray-400'>
Keine Mitarbeiter gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Invite Modal */}
{showInvite && (
<Modal title='Mitarbeiter einladen' onClose={() => setShowInvite(false)}>
<div className='space-y-3'>
<div className='flex gap-3'>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Vorname *</span>
<input value={invite.first_name} onChange={e => setInvite(f => ({ ...f, first_name: e.target.value }))} className={inputClass} placeholder='Max' />
</label>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Nachname *</span>
<input value={invite.last_name} onChange={e => setInvite(f => ({ ...f, last_name: e.target.value }))} className={inputClass} placeholder='Mustermann' />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>E-Mail *</span>
<input type='email' value={invite.email} onChange={e => setInvite(f => ({ ...f, email: e.target.value }))} className={inputClass} placeholder='max@firma.de' />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Rolle</span>
<select value={invite.role} onChange={e => setInvite(f => ({ ...f, role: e.target.value }))} className={inputClass}>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r] ?? r}</option>)}
</select>
{ROLE_DESCRIPTIONS[invite.role] && (
<p className='mt-1.5 text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2 leading-relaxed'>
{ROLE_DESCRIPTIONS[invite.role]}
</p>
)}
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>
Personalnummer {company?.personnel_number_required && <span className='text-red-500'>*</span>}
<span className='ml-1 font-normal text-gray-400'>(nur Ziffern)</span>
</span>
<div className='flex gap-2 items-stretch'>
<input
type='text'
inputMode='numeric'
value={invite.personnel_number}
onChange={e => {
const v = e.target.value.replace(/[^0-9]/g, '')
setInvite(f => ({ ...f, personnel_number: v }))
checkPersonnelNumber(v)
}}
className={inputClass}
placeholder={company?.personnel_number_mode === 'auto' ? 'Leer = Auto-Vergabe' : 'z.B. 1042'}
maxLength={50}
/>
<button
type='button'
onClick={() => suggestPersonnelNumber('invite')}
className='px-3 py-2 text-xs font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 whitespace-nowrap'
>
Vorschlagen
</button>
</div>
{invite.personnel_number && (
<p className={`mt-1 text-xs ${
personnelCheck === 'taken' ? 'text-red-600' :
personnelCheck === 'invalid' ? 'text-red-600' :
personnelCheck === 'free' ? 'text-green-600' :
'text-gray-400'
}`}>
{personnelCheck === 'checking' && 'Prüfe Verfügbarkeit…'}
{personnelCheck === 'free' && '✓ Personalnr. frei'}
{personnelCheck === 'taken' && '✗ Personalnr. bereits vergeben'}
{personnelCheck === 'invalid' && 'Nur Ziffern erlaubt'}
</p>
)}
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>
Passwort direkt setzen
<span className='ml-1 font-normal text-gray-400'>(optional leer = E-Mail-Einladung)</span>
</span>
<input
type='password'
value={invite.initial_password}
onChange={e => setInvite(f => ({ ...f, initial_password: e.target.value }))}
className={inputClass}
placeholder='Min. 8 Zeichen, 1 Großbuchstabe, 1 Zahl'
autoComplete='new-password'
/>
{invite.initial_password && (
<p className='mt-1 text-xs text-blue-600'>
Benutzer wird sofort aktiv angelegt, kein E-Mail-Versand nötig.
</p>
)}
</label>
{inviteError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{inviteError}</p>}
{inviteSuccess && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{inviteSuccess}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowInvite(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={handleInvite} disabled={inviteLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{inviteLoading ? 'Sende…' : 'Einladen'}
</button>
</div>
</div>
</Modal>
)}
{/* Edit User Modal */}
{editUser && (
<Modal title={`Mitarbeiter bearbeiten ${editUser.full_name}`} onClose={() => setEditUser(null)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Rolle</span>
<select value={editRole} onChange={e => setEditRole(e.target.value)} className={inputClass}>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r] ?? r}</option>)}
</select>
{ROLE_DESCRIPTIONS[editRole] && (
<p className='mt-1.5 text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2 leading-relaxed'>
{ROLE_DESCRIPTIONS[editRole]}
</p>
)}
</label>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Arbeitszeitmodell</label>
<select
value={editScheduleId ?? ''}
onChange={e => setEditScheduleId(e.target.value || null)}
className={inputClass}
>
<option value=''> Kein Modell zugewiesen </option>
{workSchedules.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({weeklyHours(s)}h/Woche)
</option>
))}
</select>
{workSchedules.length === 0 && (
<p className='mt-1 text-xs text-gray-400'>Noch keine Arbeitszeitmodelle angelegt.</p>
)}
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>
Kürzel
<span className='ml-1 font-normal text-gray-400'>(für CalDAV-Kalender, Platzhalter $kuerzel)</span>
</label>
<input
type='text'
value={editKuerzel}
onChange={e => setEditKuerzel(e.target.value)}
placeholder={`${editUser?.first_name?.[0] ?? ''}${editUser?.last_name?.[0] ?? ''}`}
maxLength={20}
className={inputClass}
/>
<p className='mt-1 text-xs text-gray-400'>
Frei wählbar, z.B. "PP" oder "PatP". Leer = Initialen werden automatisch verwendet.
</p>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>
Personalnummer
<span className='ml-1 font-normal text-gray-400'>(nur Ziffern, eindeutig pro Firma)</span>
</label>
<div className='flex gap-2 items-stretch'>
<input
type='text'
inputMode='numeric'
value={editPersonnelNr}
onChange={e => {
const v = e.target.value.replace(/[^0-9]/g, '')
setEditPersonnelNr(v)
checkPersonnelNumber(v, editUser?.id)
}}
className={inputClass}
maxLength={50}
/>
<button
type='button'
onClick={() => suggestPersonnelNumber('edit')}
className='px-3 py-2 text-xs font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 whitespace-nowrap'
>
Vorschlagen
</button>
</div>
{editPersonnelNr && editPersonnelNr !== (editUser?.personnel_number ?? '') && (
<p className={`mt-1 text-xs ${
personnelCheck === 'taken' ? 'text-red-600' :
personnelCheck === 'invalid' ? 'text-red-600' :
personnelCheck === 'free' ? 'text-green-600' :
'text-gray-400'
}`}>
{personnelCheck === 'checking' && 'Prüfe Verfügbarkeit…'}
{personnelCheck === 'free' && '✓ Personalnr. frei'}
{personnelCheck === 'taken' && '✗ Personalnr. bereits vergeben'}
{personnelCheck === 'invalid' && 'Nur Ziffern erlaubt'}
</p>
)}
{editUser?.personnel_number && (
<p className='mt-1 text-xs text-gray-400'>
Aktuelle Nr.: {editUser.personnel_number} (bleibt nach Deaktivierung reserviert)
</p>
)}
</div>
<div className='border-t border-gray-100 pt-3'>
<label className='flex items-center gap-3 cursor-pointer select-none'>
<input
type='checkbox'
checked={editCanManual}
onChange={e => setEditCanManual(e.target.checked)}
className='w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
/>
<div>
<span className='text-sm font-medium text-gray-800'>Manuelle Zeiterfassung erlaubt</span>
<p className='text-xs text-gray-400 mt-0.5'>Mitarbeiter kann eigene Zeiteinträge manuell anlegen (nicht nur stempeln).</p>
</div>
</label>
</div>
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditUser(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={handleEditRole} disabled={editLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{editLoading ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}
+310
View File
@@ -0,0 +1,310 @@
import { useEffect, useState, useCallback } 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
}
interface WorkScheduleOut {
id: string
company_id: string
name: string
mon_h: number; tue_h: number; wed_h: number; thu_h: number
fri_h: number; sat_h: number; sun_h: number
valid_from: string
}
interface UserListResponse {
total: number
items: UserOut[]
}
const DAYS = [
{ key: 'mon_h', label: 'Mo' },
{ key: 'tue_h', label: 'Di' },
{ key: 'wed_h', label: 'Mi' },
{ key: 'thu_h', label: 'Do' },
{ key: 'fri_h', label: 'Fr' },
{ key: 'sat_h', label: 'Sa' },
{ key: 'sun_h', label: 'So' },
] as const
const EMPTY_FORM = { name: '', mon_h: 8, tue_h: 8, wed_h: 8, thu_h: 8, fri_h: 8, sat_h: 0, sun_h: 0, valid_from: new Date().toISOString().slice(0, 10) }
export function WorkSchedulePage() {
const [user, setUser] = useState<UserOut | null>(null)
const [schedules, setSchedules] = useState<WorkScheduleOut[]>([])
const [users, setUsers] = useState<UserOut[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [assignModal, setAssignModal] = useState<string | null>(null) // scheduleId
const [assigning, setAssigning] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [me, sched, ul] = await Promise.all([
api.get<UserOut>('/auth/me'),
api.get<WorkScheduleOut[]>('/time/schedules'),
api.get<UserListResponse>('/users/?limit=200&active_only=true'),
])
setUser(me)
setSchedules(sched)
setUsers(ul.items)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const openCreate = () => {
setEditId(null)
setForm(EMPTY_FORM)
setShowForm(true)
setError('')
}
const openEdit = (s: WorkScheduleOut) => {
setEditId(s.id)
setForm({ name: s.name, mon_h: s.mon_h, tue_h: s.tue_h, wed_h: s.wed_h, thu_h: s.thu_h, fri_h: s.fri_h, sat_h: s.sat_h, sun_h: s.sun_h, valid_from: s.valid_from })
setShowForm(true)
setError('')
}
const save = async () => {
setSaving(true)
setError('')
try {
if (editId) {
await api.patch(`/time/schedules/${editId}`, form)
} else {
await api.post('/time/schedules', form)
}
setShowForm(false)
setSuccess(editId ? 'Arbeitsplan aktualisiert.' : 'Arbeitsplan erstellt.')
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
} finally {
setSaving(false)
}
}
const del = async (id: string) => {
if (!confirm('Arbeitsplan wirklich löschen?')) return
setError('')
try {
await api.del(`/time/schedules/${id}`)
setSuccess('Arbeitsplan gelöscht.')
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler')
}
}
const assignSchedule = async (userId: string, scheduleId: string | null) => {
setAssigning(true)
try {
await api.patch(`/users/${userId}`, { work_schedule_id: scheduleId })
await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler bei Zuweisung')
} finally {
setAssigning(false)
}
}
const totalH = (s: typeof form) => [s.mon_h, s.tue_h, s.wed_h, s.thu_h, s.fri_h, s.sat_h, s.sun_h].reduce((a, b) => a + Number(b), 0)
const inputCls = 'border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
if (loading) return <Layout userRole='' userName=''><div className='flex justify-center py-20'><Spinner /></div></Layout>
if (!user) return null
return (
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
<div className='space-y-6'>
<div className='flex items-center justify-between'>
<h1 className='text-2xl font-bold text-gray-900'>Arbeitspläne</h1>
<button onClick={openCreate} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700'>
+ Neuer Arbeitsplan
</button>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>}
{success && <div className='bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-700'>{success}</div>}
{/* Schedule list */}
<div className='grid gap-4'>
{schedules.length === 0 && (
<div className='bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400 text-sm'>
Noch keine Arbeitspläne. Klicke auf + Neuer Arbeitsplan".
</div>
)}
{schedules.map(s => {
const weekly = [s.mon_h, s.tue_h, s.wed_h, s.thu_h, s.fri_h, s.sat_h, s.sun_h].reduce((a, b) => a + Number(b), 0)
const assigned = users.filter(u => (u as UserOut & { work_schedule_id?: string }).work_schedule_id === s.id)
return (
<div key={s.id} className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<div className='flex items-start justify-between gap-4'>
<div>
<h3 className='font-semibold text-gray-800'>{s.name}</h3>
<p className='text-xs text-gray-500 mt-0.5'>Gültig ab {new Date(s.valid_from).toLocaleDateString('de-DE')} · {weekly}h/Woche</p>
</div>
<div className='flex gap-2'>
<button onClick={() => setAssignModal(s.id)} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
Zuweisen
</button>
<button onClick={() => openEdit(s)} className='text-xs px-3 py-1.5 border border-blue-300 rounded-lg text-blue-600 hover:bg-blue-50'>
Bearbeiten
</button>
<button onClick={() => del(s.id)} className='text-xs px-3 py-1.5 border border-red-300 rounded-lg text-red-600 hover:bg-red-50'>
Löschen
</button>
</div>
</div>
{/* Day grid */}
<div className='flex gap-2 mt-4'>
{DAYS.map(d => (
<div key={d.key} className={`flex-1 text-center rounded-lg py-2 ${Number(s[d.key]) > 0 ? 'bg-blue-50' : 'bg-gray-50'}`}>
<p className='text-xs text-gray-500'>{d.label}</p>
<p className={`text-sm font-bold ${Number(s[d.key]) > 0 ? 'text-blue-700' : 'text-gray-300'}`}>{Number(s[d.key])}h</p>
</div>
))}
</div>
{/* Assigned users */}
{assigned.length > 0 && (
<p className='text-xs text-gray-400 mt-3'>
Zugewiesen: {assigned.map(u => `${u.first_name} ${u.last_name}`).join(', ')}
</p>
)}
</div>
)
})}
</div>
{/* Users without schedule */}
{(() => {
const unassigned = users.filter(u => !(u as UserOut & { work_schedule_id?: string }).work_schedule_id)
if (unassigned.length === 0) return null
return (
<div className='bg-yellow-50 border border-yellow-200 rounded-xl p-4'>
<p className='text-sm font-medium text-yellow-800 mb-2'>Mitarbeiter ohne Arbeitsplan ({unassigned.length})</p>
<div className='flex flex-wrap gap-2'>
{unassigned.map(u => (
<span key={u.id} className='text-xs bg-white border border-yellow-200 rounded-full px-3 py-1 text-yellow-700'>
{u.first_name} {u.last_name}
</span>
))}
</div>
</div>
)
})()}
</div>
{/* Create/Edit Modal */}
{showForm && (
<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-lg'>
<div className='px-6 py-4 border-b border-gray-200'>
<h2 className='text-lg font-semibold text-gray-800'>{editId ? 'Arbeitsplan bearbeiten' : 'Neuer Arbeitsplan'}</h2>
</div>
<div className='px-6 py-4 space-y-4'>
{error && <p className='text-sm text-red-600'>{error}</p>}
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Name *</label>
<input type='text' value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder='z. B. Vollzeit (40h)' className={`w-full ${inputCls}`} />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-2'>Stunden pro Tag</label>
<div className='grid grid-cols-7 gap-2'>
{DAYS.map(d => (
<div key={d.key} className='text-center'>
<p className='text-xs text-gray-500 mb-1'>{d.label}</p>
<input
type='number' min={0} max={24} step={0.5}
value={form[d.key]}
onChange={e => setForm(f => ({ ...f, [d.key]: Number(e.target.value) }))}
className='w-full border border-gray-300 rounded text-sm text-center py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
))}
</div>
<p className='text-xs text-gray-400 mt-2 text-right'>Gesamt: {totalH(form)}h/Woche</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Gültig ab</label>
<input type='date' value={form.valid_from} onChange={e => setForm(f => ({ ...f, valid_from: e.target.value }))}
className={`w-full ${inputCls}`} />
</div>
</div>
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
<button onClick={() => setShowForm(false)} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
Abbrechen
</button>
<button onClick={save} disabled={saving || !form.name}
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Assign Modal */}
{assignModal && (
<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 justify-between'>
<h2 className='text-lg font-semibold text-gray-800'>Mitarbeiter zuweisen</h2>
<button onClick={() => setAssignModal(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>&times;</button>
</div>
<div className='px-6 py-4 max-h-80 overflow-y-auto divide-y divide-gray-50'>
{users.map(u => {
const userWithSchedule = u as UserOut & { work_schedule_id?: string }
const isAssigned = userWithSchedule.work_schedule_id === assignModal
return (
<div key={u.id} className='flex items-center justify-between py-2.5'>
<div>
<p className='text-sm font-medium text-gray-800'>{u.first_name} {u.last_name}</p>
<p className='text-xs text-gray-400'>{u.email}</p>
</div>
<button
onClick={() => assignSchedule(u.id, isAssigned ? null : assignModal)}
disabled={assigning}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${
isAssigned
? 'bg-green-100 text-green-700 hover:bg-red-100 hover:text-red-700'
: 'bg-gray-100 text-gray-600 hover:bg-blue-100 hover:text-blue-700'
}`}
>
{isAssigned ? 'Zugewiesen ✓' : 'Zuweisen'}
</button>
</div>
)
})}
</div>
<div className='px-6 py-4 border-t border-gray-200 flex justify-end'>
<button onClick={() => setAssignModal(null)} className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700'>
Fertig
</button>
</div>
</div>
</div>
)}
</Layout>
)
}
+106
View File
@@ -0,0 +1,106 @@
export interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
company_id: string
}
export type AbsenceCategory =
| 'vacation'
| 'sick'
| 'overtime_comp'
| 'training'
| 'business_trip'
| 'other'
export interface AbsenceTypeOut {
id: string
name: string
color: string
category: AbsenceCategory
requires_approval: boolean
deducts_vacation: boolean
affects_overtime_balance: boolean
requires_certificate: boolean
certificate_after_days: number
is_paid: boolean
max_days_per_year: number | null
is_active: boolean
}
export interface OvertimeBalanceOut {
total_hours: number
taken_hours: number
available_hours: number
}
export interface AbsenceOut {
id: string
user_id: string
type_id: string
start_date: string
end_date: string
half_day_start: boolean
half_day_end: boolean
working_days: number
status: string
approved_by: string | null
substitute_id: string | null
note: string | null
correction_note: string | null
rejection_reason: string | null
certificate_required_by: string | null
certificate_received_at: string | null
created_at: string
}
export interface SickStatsRow {
user_id: string
user_name: string
personnel_number: string | null
episodes: number
total_days: number
bradford_factor: number
certificates_overdue: number
}
export interface AbsenceListResponse {
total: number
items: AbsenceOut[]
}
export interface UserListItem {
id: string
full_name: string
email: string
}
export interface VacationBalanceOut {
id: string
user_id: string
year: number
entitled_days: number
special_days: number
carried_over: number
used_days: number
total_days: number
remaining_days: number
pending_days: number
carried_over_expires_at: string | null
carried_over_expired: boolean
}
export interface AbsenceSpan {
id: string
userId: string
userName: string
typeName: string
color: string
start: Date
end: Date
status: string
isHalfStart: boolean
isHalfEnd: boolean
}
+72
View File
@@ -0,0 +1,72 @@
import type { AbsenceSpan } from '../types/absence'
export const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
export const MONTHS_SHORT = [
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
]
export const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
export const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
cancelled: 'Storniert',
}
export const STATUS_COLORS: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-500',
}
export const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
export function isoWeekday(d: Date): number {
return (d.getDay() + 6) % 7
}
export function buildCalendarWeeks(year: number, month: number): Date[][] {
const first = new Date(year, month, 1)
const last = new Date(year, month + 1, 0)
const weeks: Date[][] = []
let week: Date[] = []
// Pad from Monday to the first day of the month
const startWd = isoWeekday(first)
for (let i = 0; i < startWd; i++) {
const d = new Date(year, month, 1 - (startWd - i))
week.push(d)
}
for (let day = 1; day <= last.getDate(); day++) {
week.push(new Date(year, month, day))
if (week.length === 7) {
weeks.push(week)
week = []
}
}
// Pad remaining days to complete last week
if (week.length > 0) {
let next = 1
while (week.length < 7) {
week.push(new Date(year, month + 1, next++))
}
weeks.push(week)
}
return weeks
}
export function getWeekSpans(week: Date[], spans: AbsenceSpan[]): AbsenceSpan[] {
const weekStart = week[0]
const weekEnd = week[week.length - 1]
return spans.filter(s => s.start <= weekEnd && s.end >= weekStart)
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": false
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://192.168.1.137:8000',
changeOrigin: true,
},
},
},
})