1099 lines
79 KiB
React
1099 lines
79 KiB
React
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
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: '2–3 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, 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">
|
|||
|
|
<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 { 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 () => {
|
|||
|
|
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 () => {
|
|||
|
|
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"
|
|||
|
|
onEdit={openOffEdit}
|
|||
|
|
onAction={openReturnAssets} actionLabel="Assets zurück" actionClass="btn-warning"
|
|||
|
|
onDelete={deleteOff}
|
|||
|
|
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;
|