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

1118 lines
80 KiB
React
Raw Normal View History

2026-06-01 20:49:07 +02:00
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
2026-06-01 20:49:07 +02:00
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 }) => (
2026-06-01 20:49:07 +02:00
<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>}
</>
}
2026-06-01 20:49:07 +02:00
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
// ── Hauptkomponente ───────────────────────────────────────────────────────────
const OnOffboardingPage = () => {
const navigate = useNavigate();
2026-06-01 20:49:07 +02:00
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;
}
2026-06-01 20:49:07 +02:00
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;
}
2026-06-01 20:49:07 +02:00
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}`)}
2026-06-01 20:49:07 +02:00
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;