diff --git a/frontend/src/pages/OffboardingDetailPage.jsx b/frontend/src/pages/OffboardingDetailPage.jsx new file mode 100644 index 0000000..6080a30 --- /dev/null +++ b/frontend/src/pages/OffboardingDetailPage.jsx @@ -0,0 +1,558 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import offboardingService from '../services/offboardingService'; +import assetService from '../services/assetService'; +import onboardingProcessService from '../services/onboardingProcessService'; +import LoadingSpinner from '../components/common/LoadingSpinner'; +import { toast } from 'react-toastify'; + +const TEAM_COLORS = { it: '#3b82f6', hr: '#10b981', buchhaltung: '#8b5cf6' }; +const TEAM_ICONS = { it: '💻', hr: '🧑‍💼', buchhaltung: '💶' }; +const TEAM_LABELS = { it: 'IT', hr: 'HR / Personal', buchhaltung: 'Buchhaltung' }; +const TAG_COLORS = { critical: '#ef4444', important: '#f59e0b', normal: '#64748b' }; + +const STATUS_LABEL = { pending: 'Ausstehend', in_progress: 'In Bearbeitung', completed: 'Abgeschlossen' }; +const STATUS_COLOR = { pending: '#f59e0b', in_progress: '#3b82f6', completed: '#34d399' }; + +function StatusBadge({ status }) { + const color = STATUS_COLOR[status] || '#6b7280'; + return ( + + + {STATUS_LABEL[status] || status} + + ); +} + +function ProcessChecklist({ processes, checkedItems, onChange, disabled }) { + if (!processes?.length) return ( +

Keine Prozesse konfiguriert.

+ ); + + const teams = ['it', 'hr', 'buchhaltung']; + const teamMap = {}; + teams.forEach(t => { teamMap[t] = []; }); + processes.forEach(p => { if (teamMap[p.responsible_team]) teamMap[p.responsible_team].push(p); }); + + const total = processes.length; + const checked = processes.filter(p => !!checkedItems[`proc_${p.id}`]).length; + const pct = total > 0 ? Math.round(checked / total * 100) : 0; + + return ( +
+
+
+ Gesamtfortschritt + {checked} / {total} · {pct}% +
+
+
+
+
+ + {teams.map(team => { + const items = teamMap[team]; + if (!items.length) return null; + const tc = TEAM_COLORS[team]; + const grpChecked = items.filter(p => !!checkedItems[`proc_${p.id}`]).length; + return ( +
+
+ {TEAM_ICONS[team]} + {TEAM_LABELS[team]} + + {grpChecked}/{items.length} + +
+
+ {items.map(proc => { + const key = `proc_${proc.id}`; + const isChecked = !!checkedItems[key]; + const tagColor = TAG_COLORS[proc.tag] || '#64748b'; + return ( +
!disabled && onChange({ ...checkedItems, [key]: !isChecked })} + style={{ + padding: '8px 10px', borderRadius: 6, + cursor: disabled ? 'default' : 'pointer', + background: isChecked ? `${tagColor}0d` : 'var(--bg-secondary)', + border: `1px solid ${isChecked ? `${tagColor}40` : 'var(--border-color)'}`, + transition: 'all .15s', + }}> +
+
{isChecked ? '✓' : ''}
+
+
+ {proc.title} +
+ {proc.description && ( +
{proc.description}
+ )} +
+ + {TEAM_LABELS[team]} + +
+
+ ); + })} +
+
+ ); + })} +
+ ); +} + +export default function OffboardingDetailPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth(); + + const [protocol, setProtocol] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); + const [processes, setProcesses] = useState([]); + const [checklist, setChecklist] = useState({}); + const [notes, setNotes] = useState(''); + const [assets, setAssets] = useState([]); + + // Asset-Rückgabe Modal + const [showReturnModal, setShowReturnModal] = useState(false); + const [assetReturns, setAssetReturns] = useState([]); + + const canManage = isAdmin() || isSuperAdmin(); + const canAct = canViewLifecycle(); // hr + support + admin dürfen handeln + + useEffect(() => { load(); }, [id]); + + const load = async () => { + try { + setLoading(true); + const [p, procs] = await Promise.all([ + offboardingService.getById(id), + onboardingProcessService.getChecklistProcesses('offboarding').catch(() => []), + ]); + setProtocol(p); + setChecklist(p.checklist_data ? JSON.parse(p.checklist_data) : {}); + setNotes(p.notes || ''); + setProcesses(procs || []); + + // Assets laden + try { + const all = await assetService.getAll(); + setAssets(all.filter(a => a.assigned_to_user_id === p.employee_user_id)); + } catch { setAssets([]); } + } catch (err) { + toast.error('Protokoll nicht gefunden'); + navigate('/lifecycle'); + } finally { setLoading(false); } + }; + + const save = async () => { + setSaving(true); + try { + await offboardingService.update(id, { checklist_data: checklist, notes, status: protocol.status }); + toast.success('Gespeichert'); + load(); + } catch (err) { toast.error(err.message || 'Fehler'); } + finally { setSaving(false); } + }; + + const complete = async () => { + const relevantProcesses = processes.filter((p, i, arr) => + arr.findIndex(x => x.responsible_team === p.responsible_team && x.title === p.title) === i + ); + const unchecked = relevantProcesses.filter(p => !checklist[`proc_${p.id}`]); + if (unchecked.length > 0) { + toast.error(`Noch ${unchecked.length} Aufgabe(n) offen — erst alle Punkte abhaken.`); + return; + } + if (!window.confirm('Offboarding wirklich abschließen?')) return; + try { + await offboardingService.update(id, { status: 'completed', checklist_data: checklist, notes, completion_date: new Date().toISOString().slice(0, 10) }); + toast.success('Offboarding abgeschlossen!'); + load(); + } catch (err) { toast.error(err.message || 'Fehler'); } + }; + + const deleteProtocol = async () => { + if (!window.confirm('Protokoll wirklich löschen?')) return; + try { + await offboardingService.delete(id); + toast.success('Gelöscht'); + navigate('/lifecycle'); + } catch (err) { toast.error(err.message || 'Fehler'); } + }; + + const regeneratePdf = async () => { + try { + await offboardingService.regeneratePdf(id); + toast.success('PDF neu erstellt'); + load(); + } catch (err) { toast.error(err.message || 'Fehler'); } + }; + + const openReturnAssets = async () => { + try { + const all = await assetService.getAll(); + const ua = all.filter(a => a.assigned_to_user_id === protocol.employee_user_id && a.status === 'zugewiesen'); + setAssets(ua); + setAssetReturns(ua.map(a => ({ asset_id: a.id, condition: 'gut' }))); + setShowReturnModal(true); + } catch { toast.error('Fehler beim Laden der Assets'); } + }; + + const submitReturn = async (e) => { + e.preventDefault(); + try { + await offboardingService.returnAssets(id, assetReturns); + toast.success('Assets zurückgegeben & PDF erstellt'); + setShowReturnModal(false); + load(); + } catch (err) { toast.error(err.message || 'Fehler'); } + }; + + if (loading) return
; + if (!protocol) return null; + + const total = processes.length; + const checked = processes.filter(p => !!checklist[`proc_${p.id}`]).length; + const pct = total > 0 ? Math.round(checked / total * 100) : 0; + const statusColor = STATUS_COLOR[protocol.status] || '#6b7280'; + const initials = (protocol.employee_name || protocol.employee_email || '??').slice(0, 2).toUpperCase(); + const daysLeft = protocol.exit_date + ? Math.ceil((new Date(protocol.exit_date) - new Date()) / 86400000) + : null; + + const TABS = [ + { key: 'overview', label: 'Übersicht' }, + { key: 'checklist', label: `Checkliste${total ? ` (${checked}/${total})` : ''}` }, + { key: 'assets', label: `Assets${assets.length ? ` (${assets.length})` : ''}` }, + { key: 'notes', label: 'Notizen & PDF' }, + ]; + + return ( +
+ {/* ── Breadcrumb ── */} +
+ + / + Offboarding + / + {protocol.employee_name || protocol.employee_email} +
+ + {/* ── Header Card ── */} +
+ {/* farbige Top-Linie */} +
+
+ {/* Avatar */} +
{initials}
+ + {/* Name + Meta */} +
+
+ {protocol.employee_name || protocol.employee_email} +
+
+ {protocol.employee_email && ✉️ {protocol.employee_email}} + 📅 Austritt: {protocol.exit_date ? new Date(protocol.exit_date).toLocaleDateString('de-DE') : '—'} + {daysLeft !== null && ( + + {daysLeft > 0 ? `⏳ noch ${daysLeft} Tage` : daysLeft === 0 ? '⚠️ heute' : `✅ vor ${Math.abs(daysLeft)} Tagen`} + + )} + +
+
+ + {/* Aktionen */} +
+ {canAct && protocol.status !== 'completed' && ( + + )} + {canAct && ( + + )} + {canAct && protocol.status !== 'completed' && ( + + )} + {canManage && ( + + )} +
+
+ + {/* Fortschrittsbalken */} + {total > 0 && ( +
+ Checkliste +
+
+
+ + {pct}% · {checked}/{total} + +
+ )} +
+ + {/* ── Tabs ── */} +
+ {TABS.map(t => ( + + ))} +
+ + {/* ── Tab: Übersicht ── */} + {activeTab === 'overview' && ( +
+ {/* Stat-Kacheln */} +
+ {[ + { label: 'Offene Aufgaben', value: total - checked, color: total - checked > 0 ? '#f59e0b' : '#34d399' }, + { label: 'Erledigt', value: checked, color: '#34d399' }, + { label: 'Assets', value: assets.length, color: '#3b82f6' }, + { label: 'Tage bis Austritt', value: daysLeft !== null ? Math.max(0, daysLeft) : '—', color: daysLeft < 7 ? '#ef4444' : '#6b7280' }, + ].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* Allgemeine Infos */} +
+
📋 Allgemeine Infos
+ {[ + ['Mitarbeiter', protocol.employee_name || '—'], + ['E-Mail', protocol.employee_email || '—'], + ['Austrittsdatum', protocol.exit_date ? new Date(protocol.exit_date).toLocaleDateString('de-DE') : '—'], + ['Status', ], + ['Erstellt von', protocol.created_by_username || '—'], + ['Erstellt am', protocol.created_at ? new Date(protocol.created_at).toLocaleDateString('de-DE') : '—'], + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ + {/* Assets Vorschau */} +
+
📦 Zugewiesene Assets
+ {assets.length === 0 ? ( +

Keine Assets zugewiesen

+ ) : assets.slice(0, 5).map(a => ( +
+ {a.type === 'notebook' ? '💻' : a.type === 'smartphone' ? '📱' : '📦'} +
+
{a.name || a.model}
+
{a.serial_number}
+
+ ausstehend +
+ ))} + {assets.length > 5 &&
+{assets.length - 5} weitere
} +
+
+ )} + + {/* ── Tab: Checkliste ── */} + {activeTab === 'checklist' && ( +
+ + {protocol.status !== 'completed' && canAct && ( +
+ +
+ )} +
+ )} + + {/* ── Tab: Assets ── */} + {activeTab === 'assets' && ( +
+
+ {assets.length} Asset(s) zugewiesen + {canAct && protocol.status !== 'completed' && ( + + )} +
+ {assets.length === 0 ? ( +
+
📦
+
Keine Assets zugewiesen
+
+ ) : ( +
+ {assets.map(a => ( +
+
+ {a.type === 'notebook' ? '💻' : a.type === 'smartphone' ? '📱' : '📦'} +
+
+
{a.name || a.model}
+
SN: {a.serial_number} · {a.type}
+
+ + Ausstehend + +
+ ))} +
+ )} +
+ )} + + {/* ── Tab: Notizen & PDF ── */} + {activeTab === 'notes' && ( +
+
+
Interne Notizen
+