Files
IT-Nexus/frontend/src/pages/OnOffboardingPage.jsx

1118 lines
80 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import onboardingService from '../services/onboardingService';
import offboardingService from '../services/offboardingService';
import userService from '../services/userService';
import assetService from '../services/assetService';
import onboardingProcessService from '../services/onboardingProcessService';
import ProcessManagementPage from './ProcessManagementPage';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
// ── Inline Prozess-Checkliste ─────────────────────────────────────────────────
const TAG_COLORS = { critical: '#ef4444', important: '#f59e0b', normal: '#64748b' };
const TAG_LABELS = { critical: 'Kritisch', important: 'Wichtig', normal: 'Standard' };
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 ProcessChecklist = ({ processes, checkedItems, onChange, disabled = false, userTeam = null }) => {
if (!processes || processes.length === 0) {
return <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Keine Prozesse konfiguriert. Bitte unter Prozesse verwalten.</p>;
}
// Group by responsible_team
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 totalItems = processes.length;
const totalChecked = processes.filter(p => !!checkedItems[`proc_${p.id}`]).length;
const totalPct = totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0;
return (
<div>
{/* Progress bar */}
<div style={{ marginBottom: 14, padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 5 }}>
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Gesamtfortschritt</span>
<span style={{ fontSize: '12px', fontFamily: 'monospace', color: totalPct === 100 ? '#10b981' : 'var(--text-muted)' }}>
{totalChecked} / {totalItems} · {totalPct}%
</span>
</div>
<div style={{ height: 4, background: 'var(--border-color)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${totalPct}%`, background: totalPct === 100 ? '#10b981' : 'var(--cereda-primary)', borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
{/* Teams */}
{teams.map(team => {
const items = teamMap[team];
if (items.length === 0) return null;
const tc = TEAM_COLORS[team];
const isMyTeam = !userTeam || userTeam === team;
const grpChecked = items.filter(p => !!checkedItems[`proc_${p.id}`]).length;
const grpPct = Math.round((grpChecked / items.length) * 100);
return (
<div key={team} style={{ marginBottom: 10, border: `1px solid var(--border-color)`, borderLeft: `3px solid ${tc}`, borderRadius: 8, overflow: 'hidden', opacity: isMyTeam ? 1 : 0.5 }}>
{/* Team header */}
<div style={{ padding: '8px 14px', background: 'var(--bg-tertiary)', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{TEAM_ICONS[team]}</span>
<span style={{ fontWeight: 700, fontSize: '0.85rem', flex: 1, color: tc }}>{TEAM_LABELS[team]}</span>
<span style={{ fontSize: '11px', fontFamily: 'monospace', color: grpPct === 100 ? '#10b981' : 'var(--text-muted)' }}>
{grpChecked}/{items.length}
</span>
<div style={{ width: 50, height: 3, background: 'var(--border-color)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${grpPct}%`, background: grpPct === 100 ? '#10b981' : tc, transition: 'width 0.3s' }} />
</div>
</div>
{/* Processes */}
<div style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 5 }}>
{items.map(proc => {
const key = `proc_${proc.id}`;
const checked = !!checkedItems[key];
const tagColor = TAG_COLORS[proc.tag] || '#64748b';
const editable = !disabled && isMyTeam;
return (
<div
key={key}
onClick={() => editable && onChange({ ...checkedItems, [key]: !checked })}
style={{
padding: '7px 10px', borderRadius: 6, cursor: editable ? 'pointer' : 'default',
background: checked ? `${tagColor}0d` : 'var(--bg-secondary)',
border: `1px solid ${checked ? `${tagColor}40` : 'var(--border-color)'}`,
transition: 'all 0.15s',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{
width: 15, height: 15, flexShrink: 0, marginTop: 1,
border: `1.5px solid ${checked ? tagColor : 'var(--border-color)'}`,
borderRadius: 3, background: checked ? tagColor : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 9, color: '#fff',
}}>
{checked ? '✓' : ''}
</div>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '12px', fontWeight: 600, lineHeight: 1.4,
color: checked ? 'var(--text-muted)' : 'var(--text-primary)',
textDecoration: checked ? 'line-through' : 'none',
}}>
{proc.title}
</div>
{proc.description && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.35 }}>
{proc.description}
</div>
)}
{proc.tag !== 'normal' && (
<span style={{ display: 'inline-block', marginTop: 3, fontSize: '10px', fontFamily: 'monospace', padding: '1px 5px', borderRadius: 3, background: `${tagColor}20`, color: tagColor }}>
{proc.tag === 'critical' ? '🔴 ' : '⚡ '}{TAG_LABELS[proc.tag]}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
};
/* eslint-disable no-unused-vars */
// Legacy hardcoded checklist kept for reference replaced by configurable processes from API
const ON_CHECKLIST_LEGACY = [
// ── Phase 1: Sofort nach Vertragsunterzeichnung ────────────────────────
{ key: 'p1_hr_personalakte', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'HR', label: 'Personalakte anlegen', detail: 'Digitale Akte · Vertragskopie · Eintrittsdatum', tag: 'critical' },
{ key: 'p1_hr_abt_info', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'HR', label: 'Alle Abteilungen informieren', detail: 'IT, Vorgesetzten & Buchhaltung: Name, Position, Startdatum, Abteilung', tag: 'critical' },
{ key: 'p1_hr_bg_anmeldung', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'HR', label: 'Berufsgenossenschaft vorbereiten', detail: 'BG-Anmeldung vorbereiten', tag: 'normal' },
{ key: 'p1_it_ticket', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'IT', label: 'IT-Onboarding-Ticket anlegen', detail: 'Ticket im Helpdesk erstellen · startet alle IT-Aufgaben', tag: 'critical' },
{ key: 'p1_it_hardware_check', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'IT', label: 'Hardware prüfen & ggf. bestellen', detail: 'Verfügbaren Laptop prüfen · ggf. Neubestellung (Lieferzeit beachten!)', tag: 'critical' },
{ key: 'p1_vg_buddy', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'VG', label: 'Buddy / Paten benennen & informieren', detail: 'Erfahrenen Kollegen als Begleitung für die ersten Wochen auswählen', tag: 'critical' },
{ key: 'p1_vg_einarbeitungsplan',phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung',dept: 'VG', label: '30-Tage-Einarbeitungsplan erstellen', detail: 'Aufgaben, Ziele, erste Projekte, Schulungsbedarf', tag: 'important'},
{ key: 'p1_bk_datev', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'BK', label: 'Mitarbeiter in DATEV anlegen', detail: 'Stammdaten · Eintrittsdatum · Kostenstelle · Abteilung', tag: 'critical' },
{ key: 'p1_bk_bankdaten', phase: 'Phase 1 — Sofort nach Vertragsunterzeichnung', dept: 'BK', label: 'Bankdaten & Steuerklasse anfordern', detail: 'IBAN-Formular senden · Steuerklasse · SV-Nummer anfordern', tag: 'important'},
// ── Phase 2: 2 Wochen vor Start ────────────────────────────────────────
{ key: 'p2_hr_welcome_mail', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'HR', label: 'Willkommens-E-Mail senden', detail: 'Startzeit, Ansprechpartner, Parkplatz, Dresscode, Ausweis mitbringen', tag: 'important'},
{ key: 'p2_hr_formulare', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'HR', label: 'Formulare vorbereiten', detail: 'Datenschutz · IT-Nutzungsordnung · Betriebsordnung · Schweigepflicht', tag: 'normal' },
{ key: 'p2_hr_ausweis', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'HR', label: 'Mitarbeiterausweis beantragen', detail: 'Foto anfordern oder Fototermin Tag 1 planen', tag: 'normal' },
{ key: 'p2_it_ad_account', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'IT', label: 'AD-Account erstellen', detail: 'Benutzername nach Konvention · Temp-Passwort · Gruppe/Abteilung', tag: 'critical' },
{ key: 'p2_it_email', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'IT', label: 'E-Mail-Adresse einrichten', detail: 'Postfach anlegen · Signatur vorkonfigurieren · Verteiler aufnehmen', tag: 'critical' },
{ key: 'p2_it_hardware_setup', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'IT', label: 'Hardware aufsetzen', detail: 'OS-Image · Windows Updates · Antivirus · Endpoint-Agent · Asset erfassen', tag: 'important'},
{ key: 'p2_vg_team_inform', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'VG', label: 'Team informieren', detail: 'Teammitglieder über neuen Kollegen informieren · Buddy briefen', tag: 'important'},
{ key: 'p2_vg_arbeitsplatz', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'VG', label: 'Arbeitsplatz vorbereiten', detail: 'Schreibtisch · Stuhl · Namensschild · Willkommensmappe bereitstellen', tag: 'normal' },
{ key: 'p2_bk_lohnkonto', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'BK', label: 'Lohnkonto einrichten', detail: 'Gehaltsgruppe · Steuerklasse · Krankenkasse · Urlaubsanspruch', tag: 'important'},
{ key: 'p2_bk_sv_anmeldung', phase: 'Phase 2 — 2 Wochen vor Arbeitsbeginn', dept: 'BK', label: 'Sozialversicherung anmelden', detail: 'SV-Anmeldung bei Krankenkasse einreichen', tag: 'normal' },
// ── Phase 3: 1 Woche vor Start ────────────────────────────────────────
{ key: 'p3_hr_vollstaendigkeit', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'HR', label: 'Vollständigkeits-Check', detail: 'Alle Dokumente vollständig? IBAN? SV-Nummer? Unterweisungen geplant?', tag: 'critical' },
{ key: 'p3_hr_unterweisungen', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'HR', label: 'Pflichtunterweisungen planen', detail: 'Arbeitssicherheit · Brandschutz · Datenschutz in KW 1', tag: 'important'},
{ key: 'p3_it_systemtest', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'IT', label: 'Kompletttest aller Systeme', detail: 'Login · E-Mail · VPN · Netzlaufwerke · Teams testen', tag: 'critical' },
{ key: 'p3_it_software', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'IT', label: 'Software installieren', detail: 'Office 365 · VPN-Client · Teams · ERP/CRM · abteilungsspezifische Tools', tag: 'critical'},
{ key: 'p3_it_telefon', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'IT', label: 'Telefon / Durchwahl einrichten', detail: 'IP-Telefon oder Softphone · Durchwahl zuweisen · Mailbox konfigurieren', tag: 'important'},
{ key: 'p3_it_begruessung', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'IT', label: 'IT-Begrüßungsmappe drucken', detail: 'Quick-Start-Guide: Login, Helpdesk, WLAN, Drucker, wichtige URLs', tag: 'normal' },
{ key: 'p3_vg_erste_aufgaben', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'VG', label: 'Erste Aufgaben vorbereiten', detail: '23 konkrete, überschaubare Aufgaben für Tag 1 und Woche 1 definieren', tag: 'important'},
{ key: 'p3_vg_checkins', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'VG', label: 'Check-in-Termine planen', detail: 'Tägliche 15-Min-Calls Woche 1 · Wöchentliche 30-Min-Gespräche Monat 1', tag: 'normal' },
{ key: 'p3_bk_gehaltsabrechnung',phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn',dept: 'BK', label: 'Gehaltsabrechnung vorbereiten', detail: 'Erstes Gehalt anteilig berechnen · Auszahlungstermin prüfen', tag: 'important'},
{ key: 'p3_bk_reisekosten', phase: 'Phase 3 — 1 Woche vor Arbeitsbeginn', dept: 'BK', label: 'Reisekostenformulare bereitstellen', detail: 'Spesenabrechnungsformulare und -richtlinien übergeben', tag: 'normal' },
// ── Phase 4: Erster Arbeitstag ────────────────────────────────────────
{ key: 'p4_hr_empfang', phase: 'Phase 4 — Erster Arbeitstag', dept: 'HR', label: 'Empfang & Begrüßung', detail: 'MA am Empfang abholen · Rundgang · Schlüssel/Badge · Parkausweis', tag: 'critical' },
{ key: 'p4_hr_formulare_sign', phase: 'Phase 4 — Erster Arbeitstag', dept: 'HR', label: 'Alle Formulare unterzeichnen', detail: 'Betriebsordnung · Datenschutz · IT-Nutzungsordnung · Schweigepflicht', tag: 'critical' },
{ key: 'p4_hr_willkommensmappe',phase: 'Phase 4 — Erster Arbeitstag', dept: 'HR', label: 'Willkommensmappe übergeben', detail: 'Unternehmensinfos · Organigramm · Notfallnummern · Kantineninfos', tag: 'normal' },
{ key: 'p4_it_hardware_uebergabe',phase: 'Phase 4 — Erster Arbeitstag',dept: 'IT', label: 'Hardware-Übergabe mit Protokoll', detail: 'Laptop + Zubehör übergeben · Übergabeprotokoll unterzeichnen lassen', tag: 'critical' },
{ key: 'p4_it_erster_login', phase: 'Phase 4 — Erster Arbeitstag', dept: 'IT', label: 'Ersten Login begleiten', detail: 'Passwort ändern · MFA einrichten · E-Mail · VPN · Teams testen', tag: 'critical' },
{ key: 'p4_it_it_mappe', phase: 'Phase 4 — Erster Arbeitstag', dept: 'IT', label: 'IT-Begrüßungsmappe übergeben', detail: 'Helpdesk-Kontakt · Passwortregeln · wichtige URLs · Verhaltensregeln', tag: 'important'},
{ key: 'p4_vg_begruessung', phase: 'Phase 4 — Erster Arbeitstag', dept: 'VG', label: 'Persönliche Begrüßung (nicht delegieren)', detail: 'Vorgesetzter begrüßt persönlich · Teamvorstellung · Buddy vorstellen', tag: 'critical' },
{ key: 'p4_vg_einarbeitungsplan',phase: 'Phase 4 — Erster Arbeitstag',dept: 'VG', label: 'Einarbeitungsplan besprechen', detail: 'Ziele Probezeit · erste Aufgaben · Erwartungen · Fragen beantworten', tag: 'important'},
{ key: 'p4_vg_mittagessen', phase: 'Phase 4 — Erster Arbeitstag', dept: 'VG', label: 'Gemeinsames Mittagessen mit Team', detail: 'Sozialer Anschluss ist entscheidend!', tag: 'normal' },
{ key: 'p4_bk_bankverbindung', phase: 'Phase 4 — Erster Arbeitstag', dept: 'BK', label: 'Bankverbindung bestätigen', detail: 'IBAN im System prüfen · erste Gehaltsabrechnung korrekt hinterlegt?', tag: 'important'},
{ key: 'p4_bk_zeiterfassung', phase: 'Phase 4 — Erster Arbeitstag', dept: 'BK', label: 'Zeiterfassung erklären', detail: 'System vorstellen · ersten Einstempeln begleiten · Urlaubsantragsprozess', tag: 'normal' },
];
/* eslint-enable no-unused-vars */
// Mapping: system role → responsible_team
const ROLE_TO_TEAM = {
'hr_personal': 'hr',
'buchhaltung': 'buchhaltung',
'support': 'it',
};
const STATUS_LABEL = { pending: 'Ausstehend', in_progress: 'In Bearbeitung', completed: 'Abgeschlossen' };
const STATUS_CSS = { pending: 'status-pending', in_progress: 'status-in_progress', completed: 'status-completed' };
const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE') : '—';
// ── Kleine Hilfstabelle ───────────────────────────────────────────────────────
const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onOpen, onAction, actionLabel, actionClass, onDelete, onDownload, canDelete = true }) => (
<div className="card" style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>{dateLabel}</th>
<th>Status</th>
<th>PDF</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{protocols.length === 0 ? (
<tr><td colSpan="6" style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '2rem' }}>Keine Protokolle vorhanden</td></tr>
) : protocols.map(p => (
<tr key={p.id}>
<td>
<div style={{ fontWeight: 600 }}>
{(p.emp_first_name || p.employee_sys_first_name || '')} {(p.emp_last_name || p.employee_sys_last_name || '')}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{p.emp_private_email || p.employee_email || p.department || ''}
</div>
</td>
<td>{formatDate(p[dateKey])}</td>
<td>
<span className={`status-badge ${STATUS_CSS[p.status] || ''}`}>{STATUS_LABEL[p.status] || p.status}</span>
{p.employee_confirmed_at && (
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: '0.7rem', background: '#dcfce7', color: '#16a34a', borderRadius: 4, padding: '1px 6px', fontWeight: 600 }}>
Bestätigt {formatDate(p.employee_confirmed_at)}
</span>
</div>
)}
{!p.employee_confirmed_at && p.confirm_token && (
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: '0.7rem', background: '#fef9c3', color: '#ca8a04', borderRadius: 4, padding: '1px 6px', fontWeight: 600 }}>
Ausstehend
</span>
</div>
)}
</td>
<td>
{p.pdf_file_path
? <button onClick={() => onDownload(p)} className="btn btn-success btn-small">📄 Download</button>
: <span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}></span>}
</td>
<td style={{ fontSize: '0.85rem' }}>{p.created_by_username}</td>
<td>
<div className="table-actions">
{onOpen
? <button onClick={() => onOpen(p)} className="btn btn-primary btn-small">Öffnen </button>
: <>
<button onClick={() => onEdit(p)} className="btn btn-primary btn-small">Bearbeiten</button>
{onAction && <button onClick={() => onAction(p)} className={`btn ${actionClass} btn-small`}>{actionLabel}</button>}
{canDelete && <button onClick={() => onDelete(p.id)} className="btn btn-danger btn-small">Löschen</button>}
</>
}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
// ── Hauptkomponente ───────────────────────────────────────────────────────────
const OnOffboardingPage = () => {
const navigate = useNavigate();
const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth();
const canManage = isAdmin() || isSuperAdmin();
const canCreate = canViewLifecycle(); // hr_personal + buchhaltung dürfen anlegen
const [tab, setTab] = useState('onboarding');
const [loading, setLoading] = useState(true);
// Shared data
const [users, setUsers] = useState([]);
const [assets, setAssets] = useState([]); // eslint-disable-line no-unused-vars
// Onboarding state
const [onProtocols, setOnProtocols] = useState([]);
const [showOnModal, setShowOnModal] = useState(false);
const [wizardStep, setWizardStep] = useState(1);
const [wizardForm, setWizardForm] = useState({
emp_first_name: '', emp_last_name: '', emp_private_email: '', emp_phone: '',
emp_address: '', department: '', position: '', work_location: 'Büro',
hours_model: 'Vollzeit (40h)', vacation_model: '30 Tage', start_date: '', notes: '',
dept_contacts: { VG: '' },
});
const [entraLoading, setEntraLoading] = useState(false);
const [entraSearchQuery, setEntraSearchQuery] = useState('');
const [entraSearchResults, setEntraSearchResults] = useState(null); // null=no search yet, []=no results
const [showOnDetail, setShowOnDetail] = useState(false);
const [editingOn, setEditingOn] = useState(null);
const [onForm, setOnForm] = useState({ employee_user_id: '', start_date: today(), status: 'pending', checklist_data: {}, notes: '', asset_ids: [] });
// Offboarding state
const [offProtocols, setOffProtocols] = useState([]);
const [showOffModal, setShowOffModal] = useState(false);
const [showOffDetail, setShowOffDetail] = useState(false);
const [showReturnModal, setShowReturnModal] = useState(false);
const [editingOff, setEditingOff] = useState(null);
const [offForm, setOffForm] = useState({ employee_user_id: '', exit_date: today(), status: 'pending', checklist_data: {}, notes: '' });
const [assignedAssets, setAssignedAssets] = useState([]);
const [assetReturns, setAssetReturns] = useState([]);
// Configurable departments + processes (loaded from API)
const [departments, setDepartments] = useState([]);
const [onProcesses, setOnProcesses] = useState([]);
const [offProcesses, setOffProcesses] = useState([]);
function today() { return new Date().toISOString().split('T')[0]; }
useEffect(() => { loadAll(); }, []);
const loadAll = async () => {
try {
const [on, off, u, a, depts, onProcs, offProcs] = await Promise.all([
onboardingService.getAll().catch(() => []),
offboardingService.getAll().catch(() => []),
userService.getAll().catch(() => []),
assetService.getAll().catch(() => []),
onboardingProcessService.getDepartments().catch(() => []),
onboardingProcessService.getChecklistProcesses('onboarding').catch(() => []),
onboardingProcessService.getChecklistProcesses('offboarding').catch(() => []),
]);
setOnProtocols(on);
setOffProtocols(off);
setUsers(u.filter(x => x.is_active));
setAssets(a.filter(x => x.status === 'verfuegbar'));
setDepartments(depts);
setOnProcesses(onProcs);
setOffProcesses(offProcs);
} catch { toast.error('Fehler beim Laden'); }
finally { setLoading(false); }
};
// ── Onboarding handlers ───────────────────────────────────────────────────
const openOnCreate = () => {
setEditingOn(null);
setWizardStep(1);
setWizardForm({
emp_first_name: '', emp_last_name: '', emp_private_email: '', emp_phone: '',
emp_address: '', department: '', position: '', work_location: 'Büro',
hours_model: 'Vollzeit (40h)', vacation_model: '30 Tage',
start_date: today(), notes: '',
dept_contacts: { VG: '' },
});
setEntraSearchQuery('');
setEntraSearchResults(null);
setShowOnModal(true);
};
const openOnEdit = (p) => {
setEditingOn(p);
setOnForm({ employee_user_id: p.employee_user_id, start_date: p.start_date, status: p.status, checklist_data: p.checklist_data ? JSON.parse(p.checklist_data) : {}, notes: p.notes || '', asset_ids: [] });
setShowOnDetail(true);
};
// Helper: get display name for a protocol
const getOnName = (p) => {
const first = p.emp_first_name || p.employee_sys_first_name || '';
const last = p.emp_last_name || p.employee_sys_last_name || '';
return `${first} ${last}`.trim() || p.employee_username || '—';
};
// Returns the user's responsible_team (for ProcessChecklist visual highlight + edit control)
const getUserTeam = () => ROLE_TO_TEAM[user?.role_name] || null;
// Whether current user may edit the checklist
const canEditChecklist = (protocol) => {
if (isAdmin() || isSuperAdmin()) return true;
if (ROLE_TO_TEAM[user?.role_name]) return true; // role-based team user
try {
const dc = protocol?.dept_contacts ? JSON.parse(protocol.dept_contacts) : {};
return String(dc.VG) === String(user?.id); // VG from Entra lookup
} catch { return false; }
};
const submitWizardStep1 = (e) => {
e.preventDefault();
setWizardStep(2);
};
const searchEntraByName = async () => {
if (!entraSearchQuery || entraSearchQuery.trim().length < 2) return;
setEntraLoading(true);
setEntraSearchResults(null);
try {
const res = await import('../services/api').then(m => m.default.get(`/onboarding/entra-search?name=${encodeURIComponent(entraSearchQuery.trim())}`));
setEntraSearchResults(res.data.data || []);
} catch { setEntraSearchResults([]); }
finally { setEntraLoading(false); }
};
const submitWizard = async (e) => {
e.preventDefault();
try {
const payload = {
...wizardForm,
status: 'pending',
checklist_data: {},
};
const np = await onboardingService.create(payload);
toast.success('Onboarding erstellt');
setShowOnModal(false);
await loadAll();
const fresh = await onboardingService.getById(np.id);
openOnEdit(fresh);
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const submitOn = async (e) => {
e.preventDefault();
try {
await onboardingService.update(editingOn.id, { status: onForm.status, checklist_data: onForm.checklist_data, notes: onForm.notes });
toast.success('Gespeichert');
setShowOnDetail(false);
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const completeOn = async () => {
const relevantProcesses = onProcesses.filter(p => !editingOn?.department || p.department_name === editingOn.department);
const unchecked = relevantProcesses.filter(p => !onForm.checklist_data[`proc_${p.id}`]);
if (unchecked.length > 0) {
toast.error(`Noch ${unchecked.length} Aufgabe(n) offen — bitte zuerst alle Punkte abhaken.`);
return;
}
try {
await onboardingService.update(editingOn.id, { status: 'completed', checklist_data: onForm.checklist_data, notes: onForm.notes, completion_date: today() });
toast.success('Onboarding abgeschlossen!');
setShowOnDetail(false);
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const regenerateOnPdf = async () => {
try {
await onboardingService.regeneratePdf(editingOn.id);
toast.success('PDF wurde neu erstellt');
await loadAll();
const fresh = await onboardingService.getById(editingOn.id);
setEditingOn(fresh);
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const sendOnConfirmationEmail = async () => {
if (!editingOn.emp_private_email) {
toast.error('Keine private E-Mail-Adresse beim Mitarbeiter hinterlegt');
return;
}
try {
await onboardingService.sendConfirmationEmail(editingOn.id);
toast.success(`📧 Bestätigungsmail an ${editingOn.emp_private_email} gesendet`);
} catch (err) { toast.error(err.message || 'Fehler beim Senden'); }
};
const regenerateOffPdf = async () => {
try {
await offboardingService.regeneratePdf(editingOff.id);
toast.success('PDF wurde neu erstellt');
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const deleteOn = async (id) => {
if (!window.confirm('Protokoll wirklich löschen?')) return;
try { await onboardingService.delete(id); toast.success('Gelöscht'); loadAll(); }
catch (err) { toast.error(err.message || 'Fehler'); }
};
// ── Offboarding handlers ──────────────────────────────────────────────────
const openOffCreate = () => {
setEditingOff(null);
setOffForm({ employee_user_id: '', exit_date: today(), status: 'pending', checklist_data: {}, notes: '' });
setShowOffModal(true);
};
const openOffEdit = (p) => {
setEditingOff(p);
setOffForm({ employee_user_id: p.employee_user_id, exit_date: p.exit_date, status: p.status, checklist_data: p.checklist_data ? JSON.parse(p.checklist_data) : {}, notes: p.notes || '' });
setShowOffDetail(true);
};
const openReturnAssets = async (p) => {
setEditingOff(p);
try {
const all = await assetService.getAll();
const ua = all.filter(a => a.assigned_to_user_id === p.employee_user_id && a.status === 'zugewiesen');
setAssignedAssets(ua);
setAssetReturns(ua.map(a => ({ asset_id: a.id, condition: 'gut' })));
setShowReturnModal(true);
} catch { toast.error('Fehler beim Laden der Assets'); }
};
const submitOff = async (e) => {
e.preventDefault();
try {
if (editingOff) {
await offboardingService.update(editingOff.id, { status: offForm.status, checklist_data: offForm.checklist_data, notes: offForm.notes });
toast.success('Gespeichert');
setShowOffDetail(false);
} else {
const np = await offboardingService.create(offForm);
toast.success('Offboarding erstellt');
setShowOffModal(false);
await loadAll();
const fresh = await offboardingService.getById(np.id);
openReturnAssets(fresh);
}
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const submitReturn = async (e) => {
e.preventDefault();
try {
await offboardingService.returnAssets(editingOff.id, assetReturns);
toast.success('Assets zurückgegeben & PDF erstellt');
setShowReturnModal(false);
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const completeOff = async () => {
const relevantProcesses = offProcesses.filter((p, i, arr) =>
arr.findIndex(x => x.responsible_team === p.responsible_team && x.title === p.title) === i
);
const unchecked = relevantProcesses.filter(p => !offForm.checklist_data[`proc_${p.id}`]);
if (unchecked.length > 0) {
toast.error(`Noch ${unchecked.length} Aufgabe(n) offen — bitte zuerst alle Punkte abhaken.`);
return;
}
try {
await offboardingService.update(editingOff.id, { status: 'completed', checklist_data: offForm.checklist_data, notes: offForm.notes, completion_date: today() });
toast.success('Offboarding abgeschlossen!');
setShowOffDetail(false);
loadAll();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const deleteOff = async (id) => {
if (!window.confirm('Protokoll wirklich löschen?')) return;
try { await offboardingService.delete(id); toast.success('Gelöscht'); loadAll(); }
catch (err) { toast.error(err.message || 'Fehler'); }
};
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
// Dept-contacts only see onboardings where they are assigned; hr_personal sees all
const visibleOnProtocols = canCreate
? onProtocols
: onProtocols.filter(p => {
try {
const dc = p.dept_contacts ? JSON.parse(p.dept_contacts) : {};
return Object.values(dc).some(uid => String(uid) === String(user?.id));
} catch { return false; }
});
const onPending = visibleOnProtocols.filter(p => p.status !== 'completed').length;
const offPending = offProtocols.filter(p => p.status !== 'completed').length;
return (
<div className="main-content">
<div className="container">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>👥 Mitarbeiter-Lifecycle</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
On- und Offboarding-Protokolle verwalten
</p>
</div>
{canCreate && tab !== 'prozesse' && (
<button
className="btn btn-primary"
onClick={tab === 'onboarding' ? openOnCreate : openOffCreate}
>
+ {tab === 'onboarding' ? 'Neues Onboarding' : 'Neues Offboarding'}
</button>
)}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border-color)', paddingBottom: 0 }}>
{[
{ key: 'onboarding', label: '🟢 Onboarding', count: onPending },
{ key: 'offboarding', label: '🔴 Offboarding', count: offPending },
{ key: 'prozesse', label: '⚙️ Prozesse', count: 0 },
].map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
style={{
padding: '10px 20px', border: 'none', background: 'none', cursor: 'pointer',
fontWeight: 600, fontSize: '0.9rem',
color: tab === t.key ? 'var(--primary)' : 'var(--text-muted)',
borderBottom: tab === t.key ? '2px solid var(--primary)' : '2px solid transparent',
marginBottom: -1, transition: 'all 0.15s',
}}
>
{t.label}
{t.count > 0 && (
<span style={{ marginLeft: 6, background: tab === t.key ? 'var(--primary)' : 'var(--text-muted)', color: '#fff', borderRadius: 10, padding: '1px 7px', fontSize: '0.72rem' }}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* Content */}
{tab === 'onboarding' && (
<ProtocolTable
protocols={visibleOnProtocols}
dateKey="start_date" dateLabel="Startdatum"
onEdit={openOnEdit}
onDelete={deleteOn}
canDelete={canManage}
onDownload={p => p.pdf_file_path && window.open(onboardingService.downloadPdf(p.pdf_file_path), '_blank')}
/>
)}
{tab === 'offboarding' && (
<ProtocolTable
protocols={offProtocols}
dateKey="exit_date" dateLabel="Austrittsdatum"
onOpen={p => navigate(`/lifecycle/offboarding/${p.id}`)}
onDownload={p => p.pdf_file_path && window.open(offboardingService.downloadPdf(p.pdf_file_path), '_blank')}
/>
)}
{tab === 'prozesse' && <ProcessManagementPage embedded />}
</div>
{/* ── Onboarding: Wizard Modal ──────────────────────────────────────── */}
{showOnModal && (
<div className="modal-overlay" onClick={() => setShowOnModal(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()} style={{ maxWidth: 680 }}>
{/* Wizard header */}
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ marginBottom: 2 }}>Neues Onboarding</h2>
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
Schritt {wizardStep} von 2 {wizardStep === 1 ? 'Mitarbeiterdaten' : 'Abteilungen & Verantwortliche'}
</div>
</div>
<button className="modal-close" onClick={() => setShowOnModal(false)}>×</button>
</div>
{/* Step indicator */}
<div style={{ padding: '12px 24px 0', display: 'flex', gap: 16, alignItems: 'center' }}>
{[{ n: 1, label: 'Persönliche Daten' }, { n: 2, label: 'Abteilungen' }].map((s, i) => (
<React.Fragment key={s.n}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '11px', fontWeight: 700,
background: wizardStep >= s.n ? 'var(--cereda-primary)' : 'var(--bg-tertiary)',
color: wizardStep >= s.n ? '#fff' : 'var(--text-muted)',
border: `2px solid ${wizardStep >= s.n ? 'var(--cereda-primary)' : 'var(--border-color)'}`,
}}>{wizardStep > s.n ? '✓' : s.n}</div>
<span style={{ fontSize: '12px', fontWeight: 600, color: wizardStep >= s.n ? 'var(--text-primary)' : 'var(--text-muted)' }}>{s.label}</span>
</div>
{i < 1 && <div style={{ flex: 1, height: 2, background: wizardStep > s.n ? 'var(--cereda-primary)' : 'var(--border-color)', borderRadius: 2 }} />}
</React.Fragment>
))}
</div>
{/* Step 1: Personal data */}
{wizardStep === 1 && (
<form onSubmit={submitWizardStep1}>
<div style={{ padding: '20px 24px' }}>
{/* Name row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Vorname *</label>
<input className="form-input" type="text" placeholder="Max" required
value={wizardForm.emp_first_name}
onChange={e => setWizardForm({ ...wizardForm, emp_first_name: e.target.value })} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Nachname *</label>
<input className="form-input" type="text" placeholder="Mustermann" required
value={wizardForm.emp_last_name}
onChange={e => setWizardForm({ ...wizardForm, emp_last_name: e.target.value })} />
</div>
</div>
{/* Contact row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Private E-Mail *</label>
<input className="form-input" type="email" placeholder="max@privat.de" required
value={wizardForm.emp_private_email}
onChange={e => setWizardForm({ ...wizardForm, emp_private_email: e.target.value })} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Telefonnummer</label>
<input className="form-input" type="tel" placeholder="+49 151 1234567"
value={wizardForm.emp_phone}
onChange={e => setWizardForm({ ...wizardForm, emp_phone: e.target.value })} />
</div>
</div>
{/* Address */}
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="form-label">Adresse</label>
<input className="form-input" type="text" placeholder="Musterstraße 1, 12345 Musterstadt"
value={wizardForm.emp_address}
onChange={e => setWizardForm({ ...wizardForm, emp_address: e.target.value })} />
</div>
{/* Job info row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Abteilung *</label>
<select className="form-select" required
value={wizardForm.department}
onChange={e => setWizardForm({ ...wizardForm, department: e.target.value })}>
<option value=""> Bitte wählen </option>
{departments.map(d => <option key={d.id} value={d.name}>{d.icon} {d.name}</option>)}
</select>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Position *</label>
<input className="form-input" type="text" placeholder="z.B. Softwareentwickler" required
value={wizardForm.position}
onChange={e => setWizardForm({ ...wizardForm, position: e.target.value })} />
</div>
</div>
{/* Start date + work location */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Startdatum *</label>
<input className="form-input" type="date" required
value={wizardForm.start_date}
onChange={e => setWizardForm({ ...wizardForm, start_date: e.target.value })} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Arbeitsort</label>
<select className="form-select" value={wizardForm.work_location} onChange={e => setWizardForm({ ...wizardForm, work_location: e.target.value })}>
<option>Büro</option>
<option>Homeoffice</option>
<option>Hybrid</option>
</select>
</div>
</div>
{/* Hours + vacation model */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Stundenmodell</label>
<select className="form-select" value={wizardForm.hours_model} onChange={e => setWizardForm({ ...wizardForm, hours_model: e.target.value })}>
<option>Vollzeit (40h)</option>
<option>Vollzeit (38h)</option>
<option>Teilzeit (30h)</option>
<option>Teilzeit (20h)</option>
<option>Minijob</option>
</select>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Urlaubsmodell</label>
<select className="form-select" value={wizardForm.vacation_model} onChange={e => setWizardForm({ ...wizardForm, vacation_model: e.target.value })}>
<option>25 Tage</option>
<option>27 Tage</option>
<option>28 Tage</option>
<option>30 Tage</option>
<option>Gesetzlich (20 Tage)</option>
</select>
</div>
</div>
{/* Notes */}
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">Interne Notizen</label>
<textarea className="form-textarea" rows="2" placeholder="Besonderheiten, Hinweise für IT / HR…"
value={wizardForm.notes}
onChange={e => setWizardForm({ ...wizardForm, notes: e.target.value })} />
</div>
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowOnModal(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary">Weiter </button>
</div>
</form>
)}
{/* Step 2: Rollen-Info + Vorgesetzter via Entra */}
{wizardStep === 2 && (
<form onSubmit={submitWizard}>
<div style={{ padding: '20px 24px' }}>
{/* Role-based info */}
<div style={{ marginBottom: 20, padding: '14px 16px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '12px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 }}>
Rollenbasierte Sichtbarkeit
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[
{ color: '#3b82f6', label: '🧑‍💼 HR / Personal', role: 'hr_personal', desc: 'Personalakte, Formulare, Empfang' },
{ color: '#10b981', label: '💻 IT', role: 'support', desc: 'Accounts, Hardware, Software' },
{ color: '#a78bfa', label: '💶 Buchhaltung', role: 'buchhaltung', desc: 'DATEV, Lohnkonto, SV-Anmeldung' },
].map(d => (
<div key={d.role} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '8px 10px', borderRadius: 6, borderLeft: `3px solid ${d.color}`, background: d.color + '0a' }}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: d.color }}>{d.label}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
Rolle: <code style={{ background: 'var(--bg-tertiary)', padding: '1px 5px', borderRadius: 3 }}>{d.role}</code>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: 2 }}>{d.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* VG via Entra Namenssuche */}
<div style={{ padding: '14px 16px', border: `1px solid ${wizardForm.dept_contacts.VG ? '#f59e0b60' : 'var(--border-color)'}`, borderLeft: '4px solid #f59e0b', borderRadius: 8, background: wizardForm.dept_contacts.VG ? '#f59e0b0a' : 'var(--bg-secondary)' }}>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: '13px', fontWeight: 700, color: '#f59e0b', marginBottom: 2 }}>👔 Vorgesetzter</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Einarbeitungsplan, Buddy, Team-Integration</div>
</div>
{/* Entra name search */}
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 6 }}>In Entra nach Name suchen:</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input className="form-input" style={{ fontSize: '13px', flex: 1 }}
type="text" placeholder="Name eingeben (mind. 2 Zeichen)…"
value={entraSearchQuery}
onChange={e => setEntraSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), searchEntraByName())} />
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' }}
onClick={searchEntraByName}
disabled={entraLoading || entraSearchQuery.trim().length < 2}>
{entraLoading ? '⏳' : '🔍 Suchen'}
</button>
</div>
{/* Search results */}
{entraSearchResults !== null && entraSearchResults.length === 0 && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', fontStyle: 'italic', marginBottom: 10 }}>
Keine Ergebnisse für {entraSearchQuery}".
</div>
)}
{entraSearchResults && entraSearchResults.length > 0 && (
<div style={{ marginBottom: 10, borderRadius: 6, border: '1px solid var(--border-color)', overflow: 'hidden' }}>
{entraSearchResults.map(u => (
<div key={u.entraId} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)', gap: 8 }}>
<div>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{u.displayName}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
{[u.jobTitle, u.department, u.mail].filter(Boolean).join(' · ')}
</div>
</div>
<button type="button" className="btn btn-primary" style={{ fontSize: '11px', padding: '4px 10px', whiteSpace: 'nowrap' }}
onClick={() => {
const match = users.find(x => x.email?.toLowerCase() === u.mail?.toLowerCase());
setWizardForm({ ...wizardForm, dept_contacts: { ...wizardForm.dept_contacts, VG: match ? String(match.id) : u.mail } });
setEntraSearchResults(null);
setEntraSearchQuery('');
}}>
✓ Wählen
</button>
</div>
))}
</div>
)}
{/* Manual fallback */}
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 6 }}>Oder direkt aus Systembenutzer wählen:</div>
<select className="form-select" style={{ fontSize: '13px' }}
value={wizardForm.dept_contacts.VG}
onChange={e => setWizardForm({ ...wizardForm, dept_contacts: { ...wizardForm.dept_contacts, VG: e.target.value } })}>
<option value="">— Kein Vorgesetzter zugewiesen —</option>
{users.map(u => <option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>)}
</select>
</div>
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setWizardStep(1)}>← Zurück</button>
<button type="submit" className="btn btn-primary">Onboarding starten ✓</button>
</div>
</form>
)}
</div>
</div>
)}
{/* ── Onboarding: Edit/Detail Modal ──────────────────────────────────── */}
{showOnDetail && editingOn && (
<div className="modal-overlay" onClick={() => setShowOnDetail(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Onboarding: {getOnName(editingOn)}</h2>
<button className="modal-close" onClick={() => setShowOnDetail(false)}>×</button>
</div>
<form onSubmit={submitOn}>
{/* Employee info summary */}
{editingOn && (editingOn.emp_first_name || editingOn.department) && (() => {
const dc = editingOn.dept_contacts ? JSON.parse(editingOn.dept_contacts) : {};
const deptLabels = { HR: '🧑‍💼 HR', IT: '💻 IT', VG: '👔 VG', BK: '💶 BK' };
const deptColors = { HR: '#3b82f6', IT: '#10b981', VG: '#f59e0b', BK: '#a78bfa' };
const getUserName = (uid) => {
const u = users.find(x => String(x.id) === String(uid));
return u ? `${u.first_name} ${u.last_name}` : null;
};
return (
<div style={{ margin: '0 0 0', background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)' }}>
{/* Personal info row */}
<div style={{ padding: '10px 24px', display: 'flex', flexWrap: 'wrap', gap: '16px', borderBottom: '1px solid var(--border-color)' }}>
{editingOn.emp_private_email && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>E-Mail: </span><span style={{ fontWeight: 600 }}>{editingOn.emp_private_email}</span></div>}
{editingOn.department && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Abteilung: </span><span style={{ fontWeight: 600 }}>{editingOn.department}</span></div>}
{editingOn.position && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Position: </span><span style={{ fontWeight: 600 }}>{editingOn.position}</span></div>}
{editingOn.start_date && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Start: </span><span style={{ fontWeight: 600 }}>{formatDate(editingOn.start_date)}</span></div>}
{editingOn.hours_model && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Stunden: </span><span style={{ fontWeight: 600 }}>{editingOn.hours_model}</span></div>}
{editingOn.vacation_model && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Urlaub: </span><span style={{ fontWeight: 600 }}>{editingOn.vacation_model}</span></div>}
{editingOn.work_location && <div style={{ fontSize: '12px' }}><span style={{ color: 'var(--text-muted)' }}>Ort: </span><span style={{ fontWeight: 600 }}>{editingOn.work_location}</span></div>}
{editingOn.employee_confirmed_at
? <div style={{ fontSize: '12px' }}><span style={{ background: '#dcfce7', color: '#16a34a', borderRadius: 4, padding: '2px 8px', fontWeight: 600 }}>✅ Bestätigt am {formatDate(editingOn.employee_confirmed_at)}</span></div>
: editingOn.confirm_token
? <div style={{ fontSize: '12px' }}><span style={{ background: '#fef9c3', color: '#ca8a04', borderRadius: 4, padding: '2px 8px', fontWeight: 600 }}>⏳ Bestätigung ausstehend</span></div>
: null
}
</div>
{/* Dept contacts row */}
{Object.keys(dc).some(k => dc[k]) && (
<div style={{ padding: '8px 24px', display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
{Object.entries(dc).filter(([, v]) => v).map(([k, v]) => {
const name = getUserName(v);
return name ? (
<div key={k} style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: deptColors[k], fontWeight: 700 }}>{deptLabels[k]}:</span>
<span style={{ color: 'var(--text-primary)' }}>{name}</span>
</div>
) : null;
})}
</div>
)}
</div>
);
})()}
{canManage && (
<div className="form-group">
<label className="form-label">Status *</label>
<select className="form-select" value={onForm.status} onChange={e => setOnForm({ ...onForm, status: e.target.value })} required>
<option value="pending">Ausstehend</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
)}
<div className="form-group">
<label className="form-label">
Checkliste
{editingOn && !canManage && (() => {
const dc = editingOn.dept_contacts ? JSON.parse(editingOn.dept_contacts) : {};
const myDept = Object.entries(dc).find(([, uid]) => String(uid) === String(user?.id));
return myDept ? (
<span style={{ marginLeft: 8, fontSize: '11px', padding: '2px 7px', borderRadius: 4, background: 'rgba(16,185,129,0.12)', color: '#10b981', fontWeight: 600 }}>
Deine Aufgaben ({myDept[0]})
</span>
) : null;
})()}
</label>
<ProcessChecklist
processes={onProcesses.filter(p => !editingOn?.department || p.department_name === editingOn.department)}
checkedItems={onForm.checklist_data}
onChange={ci => setOnForm({ ...onForm, checklist_data: ci })}
disabled={editingOn ? !canEditChecklist(editingOn) : false}
userTeam={(!canManage && !isSuperAdmin()) ? getUserTeam() : null}
/>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea className="form-textarea" rows="4" value={onForm.notes} onChange={e => setOnForm({ ...onForm, notes: e.target.value })}
disabled={editingOn ? !canEditChecklist(editingOn) : false} />
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowOnDetail(false)}>Schließen</button>
{canEditChecklist(editingOn) && <button type="submit" className="btn btn-primary">Speichern</button>}
{canManage && <button type="button" className="btn btn-secondary" onClick={regenerateOnPdf}>📄 PDF neu erstellen</button>}
{editingOn?.emp_private_email && <button type="button" className="btn btn-secondary" onClick={sendOnConfirmationEmail} title={`Bestätigungsmail an ${editingOn.emp_private_email} senden`}>📧 Protokoll zusenden</button>}
{canManage && onForm.status !== 'completed' && <button type="button" className="btn btn-success" onClick={completeOn}>✓ Abschließen</button>}
</div>
</form>
</div>
</div>
)}
{/* ── Offboarding: Create Modal ─────────────────────────────────────── */}
{showOffModal && (
<div className="modal-overlay" onClick={() => setShowOffModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Neues Offboarding</h2>
<button className="modal-close" onClick={() => setShowOffModal(false)}>×</button>
</div>
<form onSubmit={submitOff}>
<div className="form-group">
<label className="form-label">Mitarbeiter *</label>
<select className="form-select" value={offForm.employee_user_id} onChange={e => setOffForm({ ...offForm, employee_user_id: e.target.value })} required>
<option value="">-- Bitte wählen --</option>
{users.map(u => <option key={u.id} value={u.id}>{u.first_name} {u.last_name} ({u.username})</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Austrittsdatum *</label>
<input type="date" className="form-input" value={offForm.exit_date} onChange={e => setOffForm({ ...offForm, exit_date: e.target.value })} required />
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea className="form-textarea" rows="3" value={offForm.notes} onChange={e => setOffForm({ ...offForm, notes: e.target.value })} />
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowOffModal(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary">Erstellen</button>
</div>
</form>
</div>
</div>
)}
{/* ── Offboarding: Edit/Detail Modal ─────────────────────────────────── */}
{showOffDetail && editingOff && (
<div className="modal-overlay" onClick={() => setShowOffDetail(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Offboarding: {editingOff.employee_sys_first_name || editingOff.employee_first_name} {editingOff.employee_sys_last_name || editingOff.employee_last_name}</h2>
<button className="modal-close" onClick={() => setShowOffDetail(false)}>×</button>
</div>
<form onSubmit={submitOff}>
<div className="form-group">
<label className="form-label">Status *</label>
<select className="form-select" value={offForm.status} onChange={e => setOffForm({ ...offForm, status: e.target.value })} required>
<option value="pending">Ausstehend</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Checkliste</label>
<ProcessChecklist
processes={offProcesses.filter((p, i, arr) =>
arr.findIndex(x => x.responsible_team === p.responsible_team && x.title === p.title) === i
)}
checkedItems={offForm.checklist_data}
onChange={ci => setOffForm({ ...offForm, checklist_data: ci })}
userTeam={(!canManage && !isSuperAdmin()) ? getUserTeam() : null}
/>
</div>
<div className="form-group">
<label className="form-label">Notizen</label>
<textarea className="form-textarea" rows="4" value={offForm.notes} onChange={e => setOffForm({ ...offForm, notes: e.target.value })} />
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowOffDetail(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary">Speichern</button>
{canManage && <button type="button" className="btn btn-secondary" onClick={regenerateOffPdf}>📄 PDF neu erstellen</button>}
{offForm.status !== 'completed' && <button type="button" className="btn btn-success" onClick={completeOff}>✓ Abschließen</button>}
</div>
</form>
</div>
</div>
)}
{/* ── Offboarding: Asset-Rückgabe Modal ─────────────────────────────── */}
{showReturnModal && (
<div className="modal-overlay" onClick={() => setShowReturnModal(false)}>
<div className="modal-content modal-large" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Assets zurückgeben</h2>
<button className="modal-close" onClick={() => setShowReturnModal(false)}>×</button>
</div>
<form onSubmit={submitReturn}>
<div className="form-group">
<label className="form-label">Zugewiesene Assets</label>
{assignedAssets.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>Keine Assets zugewiesen</p>
) : (
<table className="table">
<thead><tr><th>Typ</th><th>Name</th><th>Seriennummer</th><th>Zustand</th></tr></thead>
<tbody>
{assignedAssets.map(a => (
<tr key={a.id}>
<td>{a.type}</td>
<td>{a.name}</td>
<td style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{a.serial_number}</td>
<td>
<select className="form-select" value={assetReturns.find(r => r.asset_id === a.id)?.condition || 'gut'} onChange={e => setAssetReturns(assetReturns.map(r => r.asset_id === a.id ? { ...r, condition: e.target.value } : r))}>
<option value="gut">Gut</option>
<option value="beschaedigt">Beschädigt</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowReturnModal(false)}>Abbrechen</button>
<button type="submit" className="btn btn-primary">Assets zurückgeben & PDF erstellen</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default OnOffboardingPage;