Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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.
|
||||
|
||||
---
|
||||
@@ -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>
|
||||
Generated
+3139
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const MAIN_NAV: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/time', label: 'Zeiterfassung' },
|
||||
{ path: '/absences', label: 'Abwesenheiten' },
|
||||
{ path: '/calendar', label: 'Kalender' },
|
||||
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
|
||||
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||
]
|
||||
|
||||
const SETTINGS_NAV: NavItem[] = [
|
||||
{ path: '/work-schedules', label: 'Arbeitspläne', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/absence-types', label: 'Abwesenheitstypen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/company', label: 'Firma', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/kiosk', label: 'Kiosk-Geräte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/users/import', label: 'Mitarbeiter-Import', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/import', label: 'Daten importieren', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||
{ path: '/settings/caldav', label: 'CalDAV' },
|
||||
{ path: '/settings/ldap', label: 'LDAP', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/smtp', label: 'SMTP', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
{ path: '/settings/audit-log', label: 'Audit-Log', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] },
|
||||
]
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
COMPANY_ADMIN: 'Administrator',
|
||||
HR: 'HR',
|
||||
MANAGER: 'Manager',
|
||||
EMPLOYEE: 'Mitarbeiter',
|
||||
}
|
||||
|
||||
export function Layout({ children, userRole, userName }: {
|
||||
children: React.ReactNode
|
||||
userRole: string
|
||||
userName: string
|
||||
}) {
|
||||
const { logout } = useAuth()
|
||||
const { pathname } = useLocation()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const settingsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const visibleMain = MAIN_NAV.filter(n => !n.roles || n.roles.includes(userRole))
|
||||
const visibleSettings = SETTINGS_NAV.filter(n => !n.roles || n.roles.includes(userRole))
|
||||
const isSettingsActive = visibleSettings.some(n => pathname === n.path)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (settingsRef.current && !settingsRef.current.contains(e.target as Node))
|
||||
setSettingsOpen(false)
|
||||
}
|
||||
if (settingsOpen) document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [settingsOpen])
|
||||
|
||||
useEffect(() => { setSettingsOpen(false) }, [pathname])
|
||||
|
||||
const initials = userName
|
||||
? userName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
: '?'
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<header className='bg-white border-b border-gray-200 sticky top-0 z-30'>
|
||||
<div className='max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-2'>
|
||||
|
||||
{/* Logo */}
|
||||
<Link to='/dashboard' className='flex items-center gap-2 flex-shrink-0'>
|
||||
<div className='w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center'>
|
||||
<span className='text-white font-bold text-sm'>TM</span>
|
||||
</div>
|
||||
<span className='font-bold text-gray-800 hidden lg:block text-sm'>TimeMaster</span>
|
||||
</Link>
|
||||
|
||||
{/* Hauptnavigation */}
|
||||
<nav className='flex items-center gap-0.5 flex-1 overflow-x-auto'>
|
||||
{visibleMain.map(n => (
|
||||
<Link
|
||||
key={n.path}
|
||||
to={n.path}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
pathname === n.path
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Rechte Seite: Einstellungen + User + Abmelden */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
|
||||
{/* Zahnrad-Dropdown */}
|
||||
{visibleSettings.length > 0 && (
|
||||
<div ref={settingsRef} className='relative'>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(o => !o)}
|
||||
title='Einstellungen'
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isSettingsActive || settingsOpen
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75}
|
||||
d='M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' />
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75} d='M15 12a3 3 0 11-6 0 3 3 0 016 0z' />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className='absolute right-0 top-full mt-1.5 w-52 bg-white rounded-xl shadow-lg border border-gray-200 py-1.5 z-50'>
|
||||
<p className='px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wide'>Einstellungen</p>
|
||||
{visibleSettings.map((n, i) => {
|
||||
const divider = i > 0 && n.path === '/settings/caldav'
|
||||
return (
|
||||
<div key={n.path}>
|
||||
{divider && <div className='my-1 border-t border-gray-100' />}
|
||||
<Link
|
||||
to={n.path}
|
||||
className={`flex items-center px-4 py-2 text-sm transition-colors ${
|
||||
pathname === n.path
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Avatar → Profil */}
|
||||
<Link
|
||||
to='/profile'
|
||||
title={`${userName} · ${ROLE_LABELS[userRole] ?? userRole}`}
|
||||
className='flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-100 transition-colors'
|
||||
>
|
||||
<div className='w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0'>
|
||||
<span className='text-blue-700 font-bold text-xs'>{initials}</span>
|
||||
</div>
|
||||
<div className='hidden md:block text-left'>
|
||||
<p className='text-sm font-medium text-gray-800 leading-tight'>{userName}</p>
|
||||
<p className='text-xs text-gray-400 leading-tight'>{ROLE_LABELS[userRole] ?? userRole}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Abmelden */}
|
||||
<button
|
||||
onClick={logout}
|
||||
title='Abmelden'
|
||||
className='p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors'
|
||||
>
|
||||
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1.75}
|
||||
d='M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className='max-w-7xl mx-auto px-4 sm:px-6 py-8'>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export function Modal({ title, children, onClose, size = 'md' }: ModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4' onClick={onClose}>
|
||||
<div
|
||||
className={`bg-white rounded-2xl shadow-xl w-full ${SIZE_CLASS[size]} p-6 max-h-[90vh] overflow-auto`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-base font-semibold text-gray-800'>{title}</h2>
|
||||
<button onClick={onClose} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { token } = useAuth()
|
||||
return token ? <Outlet /> : <Navigate to='/login' replace />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function Spinner() {
|
||||
return (
|
||||
<div className='flex justify-center items-center h-32'>
|
||||
<div className='animate-spin rounded-full h-10 w-10 border-4 border-blue-500 border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import type {
|
||||
AbsenceTypeOut,
|
||||
AbsenceOut,
|
||||
UserListItem,
|
||||
VacationBalanceOut,
|
||||
OvertimeBalanceOut,
|
||||
} from '../../types/absence'
|
||||
|
||||
// ── BalanceEditModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BalanceEditModalProps {
|
||||
year: number
|
||||
balance: VacationBalanceOut
|
||||
balanceForm: { entitled_days: number; special_days: number; carried_over: number }
|
||||
setBalanceForm: React.Dispatch<React.SetStateAction<{ entitled_days: number; special_days: number; carried_over: number }>>
|
||||
balanceSaving: boolean
|
||||
onSave: () => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BalanceEditModal({
|
||||
year,
|
||||
balanceForm,
|
||||
setBalanceForm,
|
||||
balanceSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: BalanceEditModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Urlaubskonto {year} bearbeiten</h2>
|
||||
<div className='space-y-3'>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Grundurlaub (Tage)</span>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.entitled_days}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, entitled_days: Number(e.target.value) }))}
|
||||
className='mt-1 w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Sondertage (Tage)</span>
|
||||
<p className='text-xs text-gray-400 mb-1'>z.B. Betriebsurlaub, Jubiläum, individuelle Zusatztage</p>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.special_days}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, special_days: Number(e.target.value) }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-medium text-gray-700'>Resturlaub aus {year - 1} (Tage)</span>
|
||||
<p className='text-xs text-gray-400 mb-1'>Wird automatisch aus dem Vorjahr berechnet</p>
|
||||
<input
|
||||
type='number' min={0} max={365} value={balanceForm.carried_over}
|
||||
onChange={e => setBalanceForm(f => ({ ...f, carried_over: Number(e.target.value) }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</label>
|
||||
<div className='bg-blue-50 rounded-lg p-3 text-sm text-blue-700'>
|
||||
Gesamt: <strong>{balanceForm.entitled_days + balanceForm.special_days + balanceForm.carried_over} Tage</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 pt-1'>
|
||||
<button
|
||||
disabled={balanceSaving}
|
||||
onClick={onSave}
|
||||
className='flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{balanceSaving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EditAbsenceModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface EditAbsenceModalProps {
|
||||
editAbsence: AbsenceOut
|
||||
editForm: {
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
correction_note: string
|
||||
}
|
||||
setEditForm: React.Dispatch<React.SetStateAction<{
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
correction_note: string
|
||||
}>>
|
||||
types: AbsenceTypeOut[]
|
||||
isManager: boolean
|
||||
submitting: boolean
|
||||
error: string
|
||||
onSave: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EditAbsenceModal({
|
||||
editAbsence,
|
||||
editForm,
|
||||
setEditForm,
|
||||
types,
|
||||
isManager,
|
||||
submitting,
|
||||
error,
|
||||
onSave,
|
||||
onClose,
|
||||
}: EditAbsenceModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
|
||||
<div className='px-6 py-4 border-b border-gray-200'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Antrag bearbeiten</h2>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
{editAbsence.status === 'approved' && !isManager && (
|
||||
<div className='bg-orange-50 border border-orange-200 rounded-lg p-3 text-sm text-orange-700'>
|
||||
Der Antrag wurde bereits genehmigt. Nach der Änderung wird er zur erneuten Genehmigung eingereicht.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Abwesenheitsart *</label>
|
||||
<select
|
||||
value={editForm.type_id}
|
||||
onChange={e => setEditForm(f => ({ ...f, type_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>Bitte wählen…</option>
|
||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={editForm.start_date}
|
||||
onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={editForm.end_date} min={editForm.start_date}
|
||||
onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={editForm.half_day_start}
|
||||
onChange={e => setEditForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={editForm.half_day_end}
|
||||
onChange={e => setEditForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
|
||||
<textarea
|
||||
value={editForm.note}
|
||||
onChange={e => setEditForm(f => ({ ...f, note: e.target.value }))}
|
||||
rows={2}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'
|
||||
/>
|
||||
</div>
|
||||
{editAbsence.status === 'approved' && !isManager && (
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-red-600 mb-1'>Änderungsgrund *</label>
|
||||
<input
|
||||
type='text' value={editForm.correction_note}
|
||||
onChange={e => setEditForm(f => ({ ...f, correction_note: e.target.value }))}
|
||||
placeholder='z. B. Urlaub wurde verkürzt'
|
||||
className='w-full border border-red-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400'
|
||||
/>
|
||||
<p className='text-xs text-gray-400 mt-1'>Wird im Änderungsprotokoll gespeichert.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={submitting}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting
|
||||
? 'Wird gespeichert…'
|
||||
: (editAbsence.status === 'approved' && !isManager ? 'Änderung einreichen' : 'Speichern')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CreateAbsenceModal ────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateAbsenceModalProps {
|
||||
form: {
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
}
|
||||
setForm: React.Dispatch<React.SetStateAction<{
|
||||
type_id: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
half_day_start: boolean
|
||||
half_day_end: boolean
|
||||
note: string
|
||||
for_user_id: string
|
||||
}>>
|
||||
types: AbsenceTypeOut[]
|
||||
colleagues: UserListItem[]
|
||||
isManager: boolean
|
||||
submitting: boolean
|
||||
error: string
|
||||
overtimeBalance: OvertimeBalanceOut | null
|
||||
onCreate: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CreateAbsenceModal({
|
||||
form,
|
||||
setForm,
|
||||
types,
|
||||
colleagues,
|
||||
isManager,
|
||||
submitting,
|
||||
error,
|
||||
overtimeBalance,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: CreateAbsenceModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
|
||||
<div className='px-6 py-4 border-b border-gray-200'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Abwesenheitsantrag</h2>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
{isManager && (
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Mitarbeiter</label>
|
||||
<select
|
||||
value={form.for_user_id}
|
||||
onChange={e => setForm(f => ({ ...f, for_user_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>— Für mich selbst —</option>
|
||||
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Abwesenheitsart *</label>
|
||||
<select
|
||||
value={form.type_id}
|
||||
onChange={e => setForm(f => ({ ...f, type_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
>
|
||||
<option value=''>Bitte wählen…</option>
|
||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{form.type_id && types.find(t => t.id === form.type_id)?.affects_overtime_balance && overtimeBalance !== null && (
|
||||
<p className='text-xs mt-1 text-purple-600'>
|
||||
Überstunden-Konto: <strong>{overtimeBalance.available_hours.toFixed(1)} h verfügbar</strong> (≈ {(overtimeBalance.available_hours / 8).toFixed(1)} Tage)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_start}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_end}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={e => setForm(f => ({ ...f, note: e.target.value }))}
|
||||
rows={2}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button onClick={onClose} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
disabled={submitting}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting ? 'Wird gesendet…' : 'Antrag stellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── QuickSickModal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuickSickModalProps {
|
||||
form: { start_date: string; end_date: string }
|
||||
setForm: React.Dispatch<React.SetStateAction<{ start_date: string; end_date: string }>>
|
||||
submitting: boolean
|
||||
error: string
|
||||
onSubmit: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuickSickModal({ form, setForm, submitting, error, onSubmit, onClose }: QuickSickModalProps) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-sm'>
|
||||
<div className='px-6 py-4 border-b border-gray-200 flex items-center gap-2'>
|
||||
<span className='text-2xl'>🤒</span>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Krank melden</h2>
|
||||
<p className='text-xs text-gray-500'>Wird sofort genehmigt — Attest-Frist wird automatisch berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 space-y-4'>
|
||||
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 border-t border-gray-200 flex justify-end gap-2'>
|
||||
<button onClick={onClose} className='px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitting || !form.start_date || !form.end_date}
|
||||
className='px-4 py-2 bg-orange-600 text-white rounded-lg text-sm font-medium hover:bg-orange-700 disabled:opacity-50'
|
||||
>
|
||||
{submitting ? 'Wird gesendet…' : 'Krank melden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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:00–24:00 Uhr (Mo–Sa)</span>
|
||||
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>00:00–04:00 Uhr (Mo–Sa)</span>
|
||||
<span className="font-medium text-blue-700">40 % Zuschlag (§3b EStG)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>04:00–06:00 Uhr (Mo–Sa)</span>
|
||||
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span>Sonntagsarbeit (00:00–24: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"><TOKEN></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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} ·
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Spinner } from '../components/Spinner'
|
||||
import { Layout } from '../components/Layout'
|
||||
|
||||
interface UserOut {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
role: string
|
||||
company_id: string
|
||||
can_manual_time_entry: boolean
|
||||
}
|
||||
|
||||
interface TimeEntryOut {
|
||||
id: string
|
||||
user_id: string
|
||||
date: string // "YYYY-MM-DD"
|
||||
start_time: string // "HH:MM:SS"
|
||||
end_time: string | null // "HH:MM:SS" oder null wenn offen
|
||||
break_minutes: number
|
||||
worked_hours: number | null
|
||||
note: string | null
|
||||
correction_note: string | null
|
||||
status: string
|
||||
source: string
|
||||
}
|
||||
|
||||
|
||||
interface TimeEntryWithWarnings {
|
||||
entry: TimeEntryOut
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
interface TimeEntryListResponse {
|
||||
total: number
|
||||
items: TimeEntryOut[]
|
||||
}
|
||||
|
||||
interface BalanceResponse {
|
||||
user_id: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
total_hours_worked: number
|
||||
expected_hours: number
|
||||
overtime_hours: number
|
||||
approved_entries: number
|
||||
pending_entries: number
|
||||
}
|
||||
|
||||
interface TodayStatus {
|
||||
today_open: boolean
|
||||
today_start: string | null
|
||||
today_hours_so_far: number | null
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
pending: 'Zur Prüfung',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
auto: 'Auto',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
pending: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
auto: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return '–'
|
||||
// Backend kann reines time-Objekt liefern ("HH:MM:SS") oder ISO-Datetime
|
||||
if (/^\d{2}:\d{2}(:\d{2})?$/.test(iso)) return iso.slice(0, 5)
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 5)
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtH(h: number | null): string {
|
||||
if (h === null || h === undefined) return '–'
|
||||
const hrs = Math.floor(h)
|
||||
const min = Math.round((h - hrs) * 60)
|
||||
return `${hrs}h ${min}m`
|
||||
}
|
||||
|
||||
|
||||
export function TimeTrackingPage() {
|
||||
const [user, setUser] = useState<UserOut | null>(null)
|
||||
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
|
||||
const [balance, setBalance] = useState<BalanceResponse | null>(null)
|
||||
const [entries, setEntries] = useState<TimeEntryOut[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stamping, setStamping] = useState(false)
|
||||
const [note, setNote] = useState('')
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Edit modal
|
||||
const [editEntry, setEditEntry] = useState<TimeEntryOut | null>(null)
|
||||
const [editStart, setEditStart] = useState('')
|
||||
const [editEnd, setEditEnd] = useState('')
|
||||
const [editBreak, setEditBreak] = useState(0)
|
||||
const [editNote, setEditNote] = useState('')
|
||||
const [editCorrectionNote, setEditCorrectionNote] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
|
||||
// Duplicate modal
|
||||
const [dupEntry, setDupEntry] = useState<TimeEntryOut | null>(null)
|
||||
const [dupDate, setDupDate] = useState('')
|
||||
const [dupStart, setDupStart] = useState('')
|
||||
const [dupEnd, setDupEnd] = useState('')
|
||||
const [dupBreak, setDupBreak] = useState(0)
|
||||
const [dupNote, setDupNote] = useState('')
|
||||
const [dupSaving, setDupSaving] = useState(false)
|
||||
const [dupError, setDupError] = useState('')
|
||||
|
||||
// New manual entry modal
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [newDate, setNewDate] = useState('')
|
||||
const [newStart, setNewStart] = useState('')
|
||||
const [newEnd, setNewEnd] = useState('')
|
||||
const [newBreak, setNewBreak] = useState(0)
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [newSaving, setNewSaving] = useState(false)
|
||||
const [newError, setNewError] = useState('')
|
||||
|
||||
const [liveSeconds, setLiveSeconds] = useState(0)
|
||||
const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [me, dash, bal, list] = await Promise.all([
|
||||
api.get<UserOut>('/auth/me'),
|
||||
api.get<TodayStatus>('/dashboard/me'),
|
||||
api.get<BalanceResponse>('/time/balance/me'),
|
||||
api.get<TimeEntryListResponse>('/time/entries?limit=20'),
|
||||
])
|
||||
setUser(me)
|
||||
setDashboard(dash)
|
||||
setBalance(bal)
|
||||
setEntries(list.items)
|
||||
setTotal(list.total)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Live-Ticker: läuft nur wenn eingestempelt
|
||||
useEffect(() => {
|
||||
if (tickerRef.current) clearInterval(tickerRef.current)
|
||||
if (dashboard?.today_open && dashboard.today_start) {
|
||||
// Startzeit aus "HH:MM:SS" in heutigen Timestamp umrechnen
|
||||
const startStr = dashboard.today_start
|
||||
const today = new Date()
|
||||
const [h, m, s] = startStr.split(':').map(Number)
|
||||
const startMs = new Date(today.getFullYear(), today.getMonth(), today.getDate(), h, m, s || 0).getTime()
|
||||
const update = () => setLiveSeconds(Math.max(0, Math.floor((Date.now() - startMs) / 1000)))
|
||||
update()
|
||||
tickerRef.current = setInterval(update, 1000)
|
||||
} else {
|
||||
setLiveSeconds(0)
|
||||
}
|
||||
return () => { if (tickerRef.current) clearInterval(tickerRef.current) }
|
||||
}, [dashboard?.today_open, dashboard?.today_start])
|
||||
|
||||
const stampIn = async () => {
|
||||
setStamping(true); setError(''); setWarnings([])
|
||||
try {
|
||||
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-in', { note: note || null })
|
||||
if (res.warnings.length) setWarnings(res.warnings)
|
||||
setNote('')
|
||||
await load()
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setStamping(false) }
|
||||
}
|
||||
|
||||
const stampOut = async () => {
|
||||
setStamping(true); setError(''); setWarnings([])
|
||||
try {
|
||||
const res = await api.post<TimeEntryWithWarnings>('/time/stamp-out', { note: note || null })
|
||||
if (res.warnings.length) setWarnings(res.warnings)
|
||||
setNote('')
|
||||
await load()
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setStamping(false) }
|
||||
}
|
||||
|
||||
const breakStart = async () => {
|
||||
setStamping(true); setError('')
|
||||
try { await api.post('/time/break-start', {}); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setStamping(false) }
|
||||
}
|
||||
|
||||
const breakEnd = async () => {
|
||||
setStamping(true); setError('')
|
||||
try { await api.post('/time/break-end', {}); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setStamping(false) }
|
||||
}
|
||||
|
||||
const isManager = ['MANAGER', 'HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user?.role ?? '')
|
||||
const canManual = isManager || (user?.can_manual_time_entry ?? false)
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
if (!confirm('Eintrag wirklich löschen?')) return
|
||||
setError('')
|
||||
try {
|
||||
await api.del(`/time/entries/${id}`)
|
||||
await load()
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Löschen') }
|
||||
}
|
||||
|
||||
const openDuplicate = (entry: TimeEntryOut) => {
|
||||
setDupEntry(entry)
|
||||
setDupDate(new Date().toISOString().slice(0, 10)) // heute als Standard
|
||||
setDupStart(fmt(entry.start_time))
|
||||
setDupEnd(entry.end_time ? fmt(entry.end_time) : '')
|
||||
setDupBreak(entry.break_minutes)
|
||||
setDupNote(entry.note ?? '')
|
||||
setDupError('')
|
||||
}
|
||||
|
||||
const saveDuplicate = async () => {
|
||||
if (!dupDate || !dupStart) { setDupError('Datum und Startzeit sind Pflicht.'); return }
|
||||
setDupSaving(true); setDupError('')
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
date: dupDate,
|
||||
start_time: dupStart,
|
||||
break_minutes: dupBreak,
|
||||
note: dupNote || null,
|
||||
}
|
||||
if (dupEnd) payload.end_time = dupEnd
|
||||
await api.post('/time/entries', payload)
|
||||
setDupEntry(null)
|
||||
await load()
|
||||
} catch (e: unknown) { setDupError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setDupSaving(false) }
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setNewDate(new Date().toISOString().slice(0, 10))
|
||||
setNewStart(''); setNewEnd(''); setNewBreak(0); setNewNote(''); setNewError('')
|
||||
setShowNew(true)
|
||||
}
|
||||
|
||||
const saveNew = async () => {
|
||||
if (!newDate || !newStart) { setNewError('Datum und Startzeit sind Pflicht.'); return }
|
||||
setNewSaving(true); setNewError('')
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
date: newDate, start_time: newStart, break_minutes: newBreak, note: newNote || null,
|
||||
}
|
||||
if (newEnd) payload.end_time = newEnd
|
||||
await api.post('/time/entries', payload)
|
||||
setShowNew(false)
|
||||
await load()
|
||||
} catch (e: unknown) { setNewError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setNewSaving(false) }
|
||||
}
|
||||
|
||||
const openEdit = (entry: TimeEntryOut) => {
|
||||
setEditEntry(entry)
|
||||
setEditStart(fmt(entry.start_time))
|
||||
setEditEnd(entry.end_time ? fmt(entry.end_time) : '')
|
||||
setEditBreak(entry.break_minutes)
|
||||
setEditNote(entry.note ?? '')
|
||||
setEditCorrectionNote('')
|
||||
setEditError('')
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editEntry) return
|
||||
if (!editStart) { setEditError('Startzeit fehlt.'); return }
|
||||
if (editEntry.status === 'approved' && !editCorrectionNote.trim()) {
|
||||
setEditError('Änderungsgrund ist bei genehmigten Einträgen Pflicht.')
|
||||
return
|
||||
}
|
||||
setEditSaving(true); setEditError('')
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
start_time: editStart,
|
||||
break_minutes: editBreak,
|
||||
note: editNote || null,
|
||||
}
|
||||
if (editEnd) payload.end_time = editEnd
|
||||
if (editEntry.status === 'approved') payload.correction_note = editCorrectionNote.trim()
|
||||
await api.patch(`/time/entries/${editEntry.id}`, payload)
|
||||
setEditEntry(null)
|
||||
await load()
|
||||
} catch (e: unknown) { setEditError(e instanceof Error ? e.message : 'Fehler') }
|
||||
finally { setEditSaving(false) }
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<Layout userRole='' userName=''>
|
||||
<div className='flex justify-center py-20'><Spinner /></div>
|
||||
</Layout>
|
||||
)
|
||||
if (!user) return (
|
||||
<Layout userRole='' userName=''>
|
||||
{error && <div className='m-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700'>{error}</div>}
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const isOpen = dashboard?.today_open ?? false
|
||||
const inp = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
|
||||
return (
|
||||
<Layout userRole={user.role} userName={`${user.first_name} ${user.last_name}`}>
|
||||
<div className='space-y-6'>
|
||||
<h1 className='text-2xl font-bold text-gray-900'>Zeiterfassung</h1>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-4'>
|
||||
<p className='font-medium text-yellow-800 mb-1'>Hinweise:</p>
|
||||
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
|
||||
{warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700'>{error}</div>
|
||||
)}
|
||||
|
||||
{/* Stempeluhr */}
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
||||
<h2 className='text-lg font-semibold text-gray-800 mb-4'>Stempeluhr</h2>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4'>
|
||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
||||
<p className='text-xs text-gray-500 mb-1'>Status</p>
|
||||
<p className={`text-lg font-bold ${isOpen ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{isOpen ? 'Eingestempelt' : 'Ausgestempelt'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
||||
<p className='text-xs text-gray-500 mb-1'>Beginn</p>
|
||||
<p className='text-lg font-bold text-gray-800'>{fmt(dashboard?.today_start ?? null)}</p>
|
||||
</div>
|
||||
<div className='text-center p-4 bg-gray-50 rounded-lg'>
|
||||
<p className='text-xs text-gray-500 mb-1'>Bisher heute</p>
|
||||
<p className='text-lg font-bold text-gray-800'>
|
||||
{dashboard?.today_open && liveSeconds > 0
|
||||
? `${Math.floor(liveSeconds / 3600)}h ${Math.floor((liveSeconds % 3600) / 60)}m ${liveSeconds % 60}s`
|
||||
: fmtH(dashboard?.today_hours_so_far ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 mb-3'>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Notiz (optional)'
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
className='flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{!isOpen ? (
|
||||
<button onClick={stampIn} disabled={stamping}
|
||||
className='px-6 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors'>
|
||||
{stamping ? '...' : 'Einstempeln'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={stampOut} disabled={stamping}
|
||||
className='px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors'>
|
||||
{stamping ? '...' : 'Ausstempeln'}
|
||||
</button>
|
||||
<button onClick={breakStart} disabled={stamping}
|
||||
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
|
||||
Pause starten
|
||||
</button>
|
||||
<button onClick={breakEnd} disabled={stamping}
|
||||
className='px-4 py-2.5 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 disabled:opacity-50 transition-colors'>
|
||||
Pause beenden
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
{balance && (
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
|
||||
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3'>
|
||||
Monat ({new Date(balance.period_start).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })})
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-4 gap-4'>
|
||||
{[
|
||||
{ label: 'Gearbeitet', value: fmtH(balance.total_hours_worked) },
|
||||
{ label: 'Soll', value: fmtH(balance.expected_hours) },
|
||||
{ label: 'Überstunden', value: fmtH(balance.overtime_hours), ot: balance.overtime_hours },
|
||||
{ label: 'Genehmigte Einträge', value: String(balance.approved_entries) },
|
||||
].map(({ label, value, ot }) => (
|
||||
<div key={label}>
|
||||
<p className='text-xs text-gray-500'>{label}</p>
|
||||
<p className={`text-xl font-bold mt-1 ${
|
||||
ot !== undefined ? (ot > 0 ? 'text-green-600' : ot < 0 ? 'text-red-600' : 'text-gray-800') : 'text-gray-800'
|
||||
}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent entries */}
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
|
||||
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold text-gray-800'>Letzte Einträge</h2>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm text-gray-400'>{total} gesamt</span>
|
||||
{canManual && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className='flex items-center gap-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg transition-colors'
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-3.5 h-3.5' viewBox='0 0 20 20' fill='currentColor'>
|
||||
<path fillRule='evenodd' d='M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z' clipRule='evenodd' />
|
||||
</svg>
|
||||
Neuer Eintrag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='divide-y divide-gray-50'>
|
||||
{entries.length === 0 ? (
|
||||
<p className='text-center text-gray-400 py-8 text-sm'>Noch keine Einträge</p>
|
||||
) : (
|
||||
entries.map(entry => (
|
||||
<div key={entry.id} className='px-6 py-3 flex items-center gap-4'>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium text-gray-800'>
|
||||
{new Date(entry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</p>
|
||||
<p className='text-xs text-gray-500'>
|
||||
{fmt(entry.start_time)} – {fmt(entry.end_time)} · Pause: {entry.break_minutes} min
|
||||
</p>
|
||||
{entry.note && <p className='text-xs text-gray-400 mt-0.5'>{entry.note}</p>}
|
||||
{entry.correction_note && <p className='text-xs text-orange-500 mt-0.5'>✏️ {entry.correction_note}</p>}
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='text-right'>
|
||||
<p className='text-sm font-bold text-gray-800'>{fmtH(entry.worked_hours)}</p>
|
||||
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{STATUS_LABELS[entry.status] ?? entry.status}
|
||||
</span>
|
||||
</div>
|
||||
{(entry.status !== 'approved' || isManager) && (
|
||||
<button
|
||||
onClick={() => openEdit(entry)}
|
||||
title={entry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Bearbeiten'}
|
||||
className={`transition-colors p-1 ${entry.status === 'approved' ? 'text-orange-400 hover:text-orange-600' : 'text-gray-400 hover:text-blue-600'}`}
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
|
||||
<path d='M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z' />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openDuplicate(entry)}
|
||||
title='Eintrag duplizieren'
|
||||
className='transition-colors p-1 text-gray-400 hover:text-purple-600'
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
|
||||
<path d='M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z' />
|
||||
<path d='M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z' />
|
||||
</svg>
|
||||
</button>
|
||||
{(entry.status !== 'approved' || isManager) && (
|
||||
<button
|
||||
onClick={() => deleteEntry(entry.id)}
|
||||
title='Eintrag löschen'
|
||||
className='transition-colors p-1 text-gray-400 hover:text-red-600'
|
||||
>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-4 h-4' viewBox='0 0 20 20' fill='currentColor'>
|
||||
<path fillRule='evenodd' d='M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z' clipRule='evenodd' />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate Modal */}
|
||||
{dupEntry && (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-base font-semibold text-gray-800'>Eintrag duplizieren</h2>
|
||||
<button onClick={() => setDupEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 mb-4'>
|
||||
Kopie von: {new Date(dupEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</p>
|
||||
<div className='space-y-3'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Datum *</span>
|
||||
<input type='date' value={dupDate} onChange={e => setDupDate(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
|
||||
<input type='time' value={dupStart} onChange={e => setDupStart(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Ende</span>
|
||||
<input type='time' value={dupEnd} onChange={e => setDupEnd(e.target.value)} className={inp} />
|
||||
</label>
|
||||
</div>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
|
||||
<input type='number' min={0} max={480} value={dupBreak}
|
||||
onChange={e => setDupBreak(Number(e.target.value))} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||
<input type='text' value={dupNote} onChange={e => setDupNote(e.target.value)}
|
||||
placeholder='Optional' className={inp} />
|
||||
</label>
|
||||
{dupError && (
|
||||
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{dupError}</p>
|
||||
)}
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<button onClick={() => setDupEntry(null)}
|
||||
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={saveDuplicate} disabled={dupSaving}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
|
||||
{dupSaving ? 'Wird erstellt…' : 'Duplizieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editEntry && (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-base font-semibold text-gray-800'>
|
||||
{editEntry.status === 'approved' ? 'Genehmigten Eintrag korrigieren' : 'Eintrag bearbeiten'}
|
||||
</h2>
|
||||
<button onClick={() => setEditEntry(null)} className='text-gray-400 hover:text-gray-600 text-xl leading-none'>×</button>
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 mb-4'>
|
||||
{new Date(editEntry.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</p>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
|
||||
<input type='time' value={editStart} onChange={e => setEditStart(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Ende</span>
|
||||
<input type='time' value={editEnd} onChange={e => setEditEnd(e.target.value)} className={inp} />
|
||||
</label>
|
||||
</div>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
|
||||
<input type='number' min={0} max={480} value={editBreak}
|
||||
onChange={e => setEditBreak(Number(e.target.value))} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||
<input type='text' value={editNote} onChange={e => setEditNote(e.target.value)}
|
||||
placeholder='Optional' className={inp} />
|
||||
</label>
|
||||
{editEntry.status === 'approved' && (
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-red-600'>Änderungsgrund *</span>
|
||||
<input type='text' value={editCorrectionNote}
|
||||
onChange={e => setEditCorrectionNote(e.target.value)}
|
||||
placeholder='z. B. Tippfehler bei Stempelzeit korrigiert'
|
||||
className={`${inp} border-red-300 focus:ring-red-400`} />
|
||||
<p className='text-xs text-gray-400 mt-1'>Wird im Änderungsprotokoll gespeichert.</p>
|
||||
</label>
|
||||
)}
|
||||
{editError && (
|
||||
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>
|
||||
)}
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<button onClick={() => setEditEntry(null)}
|
||||
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={saveEdit} disabled={editSaving}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
|
||||
{editSaving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neuer manueller Eintrag Modal */}
|
||||
{showNew && (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white rounded-2xl shadow-xl w-full max-w-sm p-6'>
|
||||
<h2 className='text-base font-semibold text-gray-800 mb-4'>Neuer Eintrag</h2>
|
||||
<div className='space-y-3'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Datum *</span>
|
||||
<input type='date' value={newDate} onChange={e => setNewDate(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Beginn *</span>
|
||||
<input type='time' value={newStart} onChange={e => setNewStart(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Ende</span>
|
||||
<input type='time' value={newEnd} onChange={e => setNewEnd(e.target.value)} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Pause (Minuten)</span>
|
||||
<input type='number' min={0} max={480} value={newBreak}
|
||||
onChange={e => setNewBreak(Number(e.target.value))} className={inp} />
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
||||
<input type='text' value={newNote} onChange={e => setNewNote(e.target.value)}
|
||||
placeholder='Optional' className={inp} />
|
||||
</label>
|
||||
{newError && (
|
||||
<p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{newError}</p>
|
||||
)}
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<button onClick={() => setShowNew(false)}
|
||||
className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={saveNew} disabled={newSaving}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
|
||||
{newSaving ? 'Speichere…' : 'Eintrag anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'>×</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user