Add: Offboarding Detail-Seite (/lifecycle/offboarding/:id) + Öffnen-Button in Liste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:02:39 +02:00
parent b3be7ee06b
commit 58d7df64e9
3 changed files with 572 additions and 7 deletions

View File

@@ -0,0 +1,558 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import offboardingService from '../services/offboardingService';
import assetService from '../services/assetService';
import onboardingProcessService from '../services/onboardingProcessService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const TEAM_COLORS = { it: '#3b82f6', hr: '#10b981', buchhaltung: '#8b5cf6' };
const TEAM_ICONS = { it: '💻', hr: '🧑‍💼', buchhaltung: '💶' };
const TEAM_LABELS = { it: 'IT', hr: 'HR / Personal', buchhaltung: 'Buchhaltung' };
const TAG_COLORS = { critical: '#ef4444', important: '#f59e0b', normal: '#64748b' };
const STATUS_LABEL = { pending: 'Ausstehend', in_progress: 'In Bearbeitung', completed: 'Abgeschlossen' };
const STATUS_COLOR = { pending: '#f59e0b', in_progress: '#3b82f6', completed: '#34d399' };
function StatusBadge({ status }) {
const color = STATUS_COLOR[status] || '#6b7280';
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 20,
color, background: `${color}18`, border: `1px solid ${color}40`,
}}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: color, display: 'inline-block' }} />
{STATUS_LABEL[status] || status}
</span>
);
}
function ProcessChecklist({ processes, checkedItems, onChange, disabled }) {
if (!processes?.length) return (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Keine Prozesse konfiguriert.</p>
);
const teams = ['it', 'hr', 'buchhaltung'];
const teamMap = {};
teams.forEach(t => { teamMap[t] = []; });
processes.forEach(p => { if (teamMap[p.responsible_team]) teamMap[p.responsible_team].push(p); });
const total = processes.length;
const checked = processes.filter(p => !!checkedItems[`proc_${p.id}`]).length;
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
return (
<div>
<div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.05em' }}>Gesamtfortschritt</span>
<span style={{ fontSize: 12, fontFamily: 'monospace', color: pct === 100 ? '#10b981' : 'var(--text-muted)' }}>{checked} / {total} · {pct}%</span>
</div>
<div style={{ height: 6, background: 'var(--border-color)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: pct === 100 ? '#10b981' : 'var(--cereda-primary)', borderRadius: 3, transition: 'width .3s' }} />
</div>
</div>
{teams.map(team => {
const items = teamMap[team];
if (!items.length) return null;
const tc = TEAM_COLORS[team];
const grpChecked = items.filter(p => !!checkedItems[`proc_${p.id}`]).length;
return (
<div key={team} style={{ marginBottom: 10, border: '1px solid var(--border-color)', borderLeft: `3px solid ${tc}`, borderRadius: 8, overflow: 'hidden' }}>
<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: 13, flex: 1, color: tc }}>{TEAM_LABELS[team]}</span>
<span style={{ fontSize: 11, fontFamily: 'monospace', color: grpChecked === items.length ? '#10b981' : 'var(--text-muted)' }}>
{grpChecked}/{items.length}
</span>
</div>
<div style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 5 }}>
{items.map(proc => {
const key = `proc_${proc.id}`;
const isChecked = !!checkedItems[key];
const tagColor = TAG_COLORS[proc.tag] || '#64748b';
return (
<div key={key}
onClick={() => !disabled && onChange({ ...checkedItems, [key]: !isChecked })}
style={{
padding: '8px 10px', borderRadius: 6,
cursor: disabled ? 'default' : 'pointer',
background: isChecked ? `${tagColor}0d` : 'var(--bg-secondary)',
border: `1px solid ${isChecked ? `${tagColor}40` : 'var(--border-color)'}`,
transition: 'all .15s',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{
width: 15, height: 15, flexShrink: 0, marginTop: 2,
border: `1.5px solid ${isChecked ? tagColor : 'var(--border-color)'}`,
borderRadius: 3, background: isChecked ? tagColor : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 9, color: '#fff',
}}>{isChecked ? '✓' : ''}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: isChecked ? 'var(--text-muted)' : 'var(--text-primary)', textDecoration: isChecked ? 'line-through' : 'none' }}>
{proc.title}
</div>
{proc.description && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{proc.description}</div>
)}
</div>
<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 4, background: `${tc}18`, color: tc, flexShrink: 0 }}>
{TEAM_LABELS[team]}
</span>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
export default function OffboardingDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth();
const [protocol, setProtocol] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const [processes, setProcesses] = useState([]);
const [checklist, setChecklist] = useState({});
const [notes, setNotes] = useState('');
const [assets, setAssets] = useState([]);
// Asset-Rückgabe Modal
const [showReturnModal, setShowReturnModal] = useState(false);
const [assetReturns, setAssetReturns] = useState([]);
const canManage = isAdmin() || isSuperAdmin();
const canAct = canViewLifecycle(); // hr + support + admin dürfen handeln
useEffect(() => { load(); }, [id]);
const load = async () => {
try {
setLoading(true);
const [p, procs] = await Promise.all([
offboardingService.getById(id),
onboardingProcessService.getChecklistProcesses('offboarding').catch(() => []),
]);
setProtocol(p);
setChecklist(p.checklist_data ? JSON.parse(p.checklist_data) : {});
setNotes(p.notes || '');
setProcesses(procs || []);
// Assets laden
try {
const all = await assetService.getAll();
setAssets(all.filter(a => a.assigned_to_user_id === p.employee_user_id));
} catch { setAssets([]); }
} catch (err) {
toast.error('Protokoll nicht gefunden');
navigate('/lifecycle');
} finally { setLoading(false); }
};
const save = async () => {
setSaving(true);
try {
await offboardingService.update(id, { checklist_data: checklist, notes, status: protocol.status });
toast.success('Gespeichert');
load();
} catch (err) { toast.error(err.message || 'Fehler'); }
finally { setSaving(false); }
};
const complete = async () => {
const relevantProcesses = processes.filter((p, i, arr) =>
arr.findIndex(x => x.responsible_team === p.responsible_team && x.title === p.title) === i
);
const unchecked = relevantProcesses.filter(p => !checklist[`proc_${p.id}`]);
if (unchecked.length > 0) {
toast.error(`Noch ${unchecked.length} Aufgabe(n) offen — erst alle Punkte abhaken.`);
return;
}
if (!window.confirm('Offboarding wirklich abschließen?')) return;
try {
await offboardingService.update(id, { status: 'completed', checklist_data: checklist, notes, completion_date: new Date().toISOString().slice(0, 10) });
toast.success('Offboarding abgeschlossen!');
load();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const deleteProtocol = async () => {
if (!window.confirm('Protokoll wirklich löschen?')) return;
try {
await offboardingService.delete(id);
toast.success('Gelöscht');
navigate('/lifecycle');
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const regeneratePdf = async () => {
try {
await offboardingService.regeneratePdf(id);
toast.success('PDF neu erstellt');
load();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
const openReturnAssets = async () => {
try {
const all = await assetService.getAll();
const ua = all.filter(a => a.assigned_to_user_id === protocol.employee_user_id && a.status === 'zugewiesen');
setAssets(ua);
setAssetReturns(ua.map(a => ({ asset_id: a.id, condition: 'gut' })));
setShowReturnModal(true);
} catch { toast.error('Fehler beim Laden der Assets'); }
};
const submitReturn = async (e) => {
e.preventDefault();
try {
await offboardingService.returnAssets(id, assetReturns);
toast.success('Assets zurückgegeben & PDF erstellt');
setShowReturnModal(false);
load();
} catch (err) { toast.error(err.message || 'Fehler'); }
};
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
if (!protocol) return null;
const total = processes.length;
const checked = processes.filter(p => !!checklist[`proc_${p.id}`]).length;
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
const statusColor = STATUS_COLOR[protocol.status] || '#6b7280';
const initials = (protocol.employee_name || protocol.employee_email || '??').slice(0, 2).toUpperCase();
const daysLeft = protocol.exit_date
? Math.ceil((new Date(protocol.exit_date) - new Date()) / 86400000)
: null;
const TABS = [
{ key: 'overview', label: 'Übersicht' },
{ key: 'checklist', label: `Checkliste${total ? ` (${checked}/${total})` : ''}` },
{ key: 'assets', label: `Assets${assets.length ? ` (${assets.length})` : ''}` },
{ key: 'notes', label: 'Notizen & PDF' },
];
return (
<div className="main-content" style={{ paddingBottom: 40 }}>
{/* ── Breadcrumb ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20, fontSize: 13, color: 'var(--text-muted)' }}>
<button onClick={() => navigate('/lifecycle')}
style={{ background: 'none', border: 'none', color: 'var(--cereda-primary)', cursor: 'pointer', padding: 0, fontSize: 13 }}>
Lifecycle
</button>
<span>/</span>
<span>Offboarding</span>
<span>/</span>
<span style={{ color: 'var(--text-primary)' }}>{protocol.employee_name || protocol.employee_email}</span>
</div>
{/* ── Header Card ── */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 16, marginBottom: 20, overflow: 'hidden' }}>
{/* farbige Top-Linie */}
<div style={{ height: 4, background: statusColor }} />
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
{/* Avatar */}
<div style={{
width: 56, height: 56, borderRadius: 14, flexShrink: 0,
background: `linear-gradient(135deg, ${statusColor}cc, ${statusColor}88)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 700, color: '#fff',
}}>{initials}</div>
{/* Name + Meta */}
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 5 }}>
{protocol.employee_name || protocol.employee_email}
</div>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13, color: 'var(--text-muted)', alignItems: 'center' }}>
{protocol.employee_email && <span> {protocol.employee_email}</span>}
<span>📅 Austritt: {protocol.exit_date ? new Date(protocol.exit_date).toLocaleDateString('de-DE') : '—'}</span>
{daysLeft !== null && (
<span style={{ color: daysLeft < 7 ? '#ef4444' : daysLeft < 14 ? '#f59e0b' : 'var(--text-muted)' }}>
{daysLeft > 0 ? `⏳ noch ${daysLeft} Tage` : daysLeft === 0 ? '⚠️ heute' : `✅ vor ${Math.abs(daysLeft)} Tagen`}
</span>
)}
<StatusBadge status={protocol.status} />
</div>
</div>
{/* Aktionen */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{canAct && protocol.status !== 'completed' && (
<button onClick={openReturnAssets}
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', padding: '7px 14px', fontSize: 13, cursor: 'pointer', fontWeight: 500 }}>
📦 Assets zurück
</button>
)}
{canAct && (
<button onClick={regeneratePdf}
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', padding: '7px 14px', fontSize: 13, cursor: 'pointer' }}>
📄 PDF
</button>
)}
{canAct && protocol.status !== 'completed' && (
<button onClick={complete}
style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '7px 16px', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
Abschließen
</button>
)}
{canManage && (
<button onClick={deleteProtocol}
style={{ background: 'transparent', border: '1px solid #da363340', borderRadius: 8, color: '#ef4444', padding: '7px 14px', fontSize: 13, cursor: 'pointer' }}>
🗑
</button>
)}
</div>
</div>
{/* Fortschrittsbalken */}
{total > 0 && (
<div style={{ padding: '10px 24px 14px', display: 'flex', alignItems: 'center', gap: 14, borderTop: '1px solid var(--border-color)' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Checkliste</span>
<div style={{ flex: 1, height: 6, background: 'var(--border-color)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: pct === 100 ? '#10b981' : 'var(--cereda-primary)', borderRadius: 3, transition: 'width .4s' }} />
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: pct === 100 ? '#10b981' : 'var(--cereda-primary)', whiteSpace: 'nowrap' }}>
{pct}% · {checked}/{total}
</span>
</div>
)}
</div>
{/* ── Tabs ── */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border-color)', marginBottom: 20 }}>
{TABS.map(t => (
<button key={t.key} onClick={() => setActiveTab(t.key)}
style={{
background: 'none', border: 'none', padding: '9px 16px', fontSize: 13,
cursor: 'pointer', fontWeight: 500,
color: activeTab === t.key ? 'var(--cereda-primary)' : 'var(--text-muted)',
borderBottom: `2px solid ${activeTab === t.key ? 'var(--cereda-primary)' : 'transparent'}`,
marginBottom: -1, transition: 'all .15s',
}}>
{t.label}
</button>
))}
</div>
{/* ── Tab: Übersicht ── */}
{activeTab === 'overview' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
{/* Stat-Kacheln */}
<div style={{ gridColumn: '1 / -1', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
{[
{ label: 'Offene Aufgaben', value: total - checked, color: total - checked > 0 ? '#f59e0b' : '#34d399' },
{ label: 'Erledigt', value: checked, color: '#34d399' },
{ label: 'Assets', value: assets.length, color: '#3b82f6' },
{ label: 'Tage bis Austritt', value: daysLeft !== null ? Math.max(0, daysLeft) : '—', color: daysLeft < 7 ? '#ef4444' : '#6b7280' },
].map((s, i) => (
<div key={i} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderLeft: `3px solid ${s.color}`, borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 26, fontWeight: 700, color: '#fff' }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
{/* Allgemeine Infos */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 12 }}>📋 Allgemeine Infos</div>
{[
['Mitarbeiter', protocol.employee_name || '—'],
['E-Mail', protocol.employee_email || '—'],
['Austrittsdatum', protocol.exit_date ? new Date(protocol.exit_date).toLocaleDateString('de-DE') : '—'],
['Status', <StatusBadge status={protocol.status} />],
['Erstellt von', protocol.created_by_username || '—'],
['Erstellt am', protocol.created_at ? new Date(protocol.created_at).toLocaleDateString('de-DE') : '—'],
].map(([label, value]) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 0', borderBottom: '1px solid rgba(255,255,255,.04)', fontSize: 13, gap: 12 }}>
<span style={{ color: 'var(--text-muted)', flexShrink: 0 }}>{label}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>{value}</span>
</div>
))}
</div>
{/* Assets Vorschau */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 12 }}>📦 Zugewiesene Assets</div>
{assets.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: '20px 0' }}>Keine Assets zugewiesen</p>
) : assets.slice(0, 5).map(a => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,.04)', fontSize: 13 }}>
<span style={{ fontSize: 18 }}>{a.type === 'notebook' ? '💻' : a.type === 'smartphone' ? '📱' : '📦'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{a.name || a.model}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{a.serial_number}</div>
</div>
<span style={{ fontSize: 11, color: '#f59e0b', background: 'rgba(245,158,11,.1)', padding: '2px 8px', borderRadius: 4 }}>ausstehend</span>
</div>
))}
{assets.length > 5 && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>+{assets.length - 5} weitere</div>}
</div>
</div>
)}
{/* ── Tab: Checkliste ── */}
{activeTab === 'checklist' && (
<div>
<ProcessChecklist
processes={processes}
checkedItems={checklist}
onChange={setChecklist}
disabled={protocol.status === 'completed'}
/>
{protocol.status !== 'completed' && canAct && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={save} disabled={saving}
style={{ background: 'var(--cereda-primary)', border: 'none', borderRadius: 8, color: '#fff', padding: '8px 20px', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? .7 : 1 }}>
{saving ? 'Speichert…' : '💾 Fortschritt speichern'}
</button>
</div>
)}
</div>
)}
{/* ── Tab: Assets ── */}
{activeTab === 'assets' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{assets.length} Asset(s) zugewiesen</span>
{canAct && protocol.status !== 'completed' && (
<button onClick={openReturnAssets}
style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '7px 16px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
📦 Assets zurückgeben
</button>
)}
</div>
{assets.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>📦</div>
<div>Keine Assets zugewiesen</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{assets.map(a => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10 }}>
<div style={{ width: 38, height: 38, borderRadius: 8, background: 'rgba(63,163,163,.1)', border: '1px solid rgba(63,163,163,.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18 }}>
{a.type === 'notebook' ? '💻' : a.type === 'smartphone' ? '📱' : '📦'}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{a.name || a.model}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>SN: {a.serial_number} · {a.type}</div>
</div>
<span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', background: 'rgba(245,158,11,.12)', border: '1px solid rgba(245,158,11,.3)', padding: '3px 10px', borderRadius: 20 }}>
Ausstehend
</span>
</div>
))}
</div>
)}
</div>
)}
{/* ── Tab: Notizen & PDF ── */}
{activeTab === 'notes' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '.5px' }}>Interne Notizen</div>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
disabled={protocol.status === 'completed' || !canAct}
placeholder="Notizen zum Offboarding..."
style={{ width: '100%', minHeight: 160, background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', fontSize: 13, padding: '10px 12px', fontFamily: 'inherit', resize: 'vertical' }}
/>
{canAct && protocol.status !== 'completed' && (
<button onClick={save} disabled={saving}
style={{ marginTop: 10, background: 'var(--cereda-primary)', border: 'none', borderRadius: 8, color: '#fff', padding: '7px 16px', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? .7 : 1 }}>
{saving ? 'Speichert…' : '💾 Speichern'}
</button>
)}
</div>
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 16, textTransform: 'uppercase', letterSpacing: '.5px' }}>📄 PDF-Dokument</div>
{protocol.pdf_path ? (
<div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', marginBottom: 14 }}>PDF vorhanden</div>
<div style={{ display: 'flex', gap: 8 }}>
<a href={offboardingService.downloadPdf(protocol.pdf_path)} target="_blank" rel="noreferrer"
style={{ background: 'var(--cereda-primary)', border: 'none', borderRadius: 8, color: '#fff', padding: '7px 16px', fontSize: 13, textDecoration: 'none', fontWeight: 600 }}>
📥 Download
</a>
{canAct && (
<button onClick={regeneratePdf}
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', padding: '7px 14px', fontSize: 13, cursor: 'pointer' }}>
🔄 Neu erstellen
</button>
)}
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 36, marginBottom: 10 }}>📄</div>
<div style={{ fontSize: 13, marginBottom: 14 }}>Noch kein PDF generiert</div>
{canAct && (
<button onClick={regeneratePdf}
style={{ background: 'transparent', border: '1px solid var(--cereda-primary)', borderRadius: 8, color: 'var(--cereda-primary)', padding: '7px 16px', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
📄 PDF erstellen
</button>
)}
</div>
)}
</div>
</div>
)}
{/* ── Asset-Rückgabe Modal ── */}
{showReturnModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: 16, width: '100%', maxWidth: 540, overflow: 'hidden' }}>
<div style={{ padding: '18px 24px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 700, fontSize: 15 }}>📦 Assets zurückgeben</span>
<button onClick={() => setShowReturnModal(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 18 }}></button>
</div>
<form onSubmit={submitReturn} style={{ padding: 24 }}>
{assets.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px 0' }}>Keine Assets zum Zurückgeben</p>
) : assets.map((a, i) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid var(--border-color)' }}>
<span style={{ fontSize: 18 }}>{a.type === 'notebook' ? '💻' : a.type === 'smartphone' ? '📱' : '📦'}</span>
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{a.name || a.model}</span>
<select
value={assetReturns[i]?.condition || 'gut'}
onChange={e => setAssetReturns(prev => prev.map((r, j) => j === i ? { ...r, condition: e.target.value } : r))}
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 6, color: 'var(--text-primary)', padding: '4px 8px', fontSize: 12 }}>
<option value="gut">Gut</option>
<option value="beschaedigt">Beschädigt</option>
<option value="verloren">Verloren</option>
</select>
</div>
))}
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button type="button" onClick={() => setShowReturnModal(false)}
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', padding: '8px 16px', fontSize: 13, cursor: 'pointer' }}>
Abbrechen
</button>
<button type="submit"
style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '8px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Bestätigen & PDF erstellen
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import onboardingService from '../services/onboardingService'; import onboardingService from '../services/onboardingService';
import offboardingService from '../services/offboardingService'; import offboardingService from '../services/offboardingService';
@@ -196,7 +197,7 @@ const STATUS_CSS = { pending: 'status-pending', in_progress: 'status-in_progre
const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE') : '—'; const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE') : '—';
// ── Kleine Hilfstabelle ─────────────────────────────────────────────────────── // ── Kleine Hilfstabelle ───────────────────────────────────────────────────────
const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onAction, actionLabel, actionClass, onDelete, onDownload, canDelete = true }) => ( const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onOpen, onAction, actionLabel, actionClass, onDelete, onDownload, canDelete = true }) => (
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
<table className="table"> <table className="table">
<thead> <thead>
@@ -248,9 +249,14 @@ const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onAction, action
<td style={{ fontSize: '0.85rem' }}>{p.created_by_username}</td> <td style={{ fontSize: '0.85rem' }}>{p.created_by_username}</td>
<td> <td>
<div className="table-actions"> <div className="table-actions">
<button onClick={() => onEdit(p)} className="btn btn-primary btn-small">Bearbeiten</button> {onOpen
{onAction && <button onClick={() => onAction(p)} className={`btn ${actionClass} btn-small`}>{actionLabel}</button>} ? <button onClick={() => onOpen(p)} className="btn btn-primary btn-small">Öffnen </button>
{canDelete && <button onClick={() => onDelete(p.id)} className="btn btn-danger btn-small">Löschen</button>} : <>
<button onClick={() => onEdit(p)} className="btn btn-primary btn-small">Bearbeiten</button>
{onAction && <button onClick={() => onAction(p)} className={`btn ${actionClass} btn-small`}>{actionLabel}</button>}
{canDelete && <button onClick={() => onDelete(p.id)} className="btn btn-danger btn-small">Löschen</button>}
</>
}
</div> </div>
</td> </td>
</tr> </tr>
@@ -262,6 +268,7 @@ const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onAction, action
// ── Hauptkomponente ─────────────────────────────────────────────────────────── // ── Hauptkomponente ───────────────────────────────────────────────────────────
const OnOffboardingPage = () => { const OnOffboardingPage = () => {
const navigate = useNavigate();
const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth(); const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth();
const canManage = isAdmin() || isSuperAdmin(); const canManage = isAdmin() || isSuperAdmin();
const canCreate = canViewLifecycle(); // hr_personal + buchhaltung dürfen anlegen const canCreate = canViewLifecycle(); // hr_personal + buchhaltung dürfen anlegen
@@ -620,9 +627,7 @@ const OnOffboardingPage = () => {
<ProtocolTable <ProtocolTable
protocols={offProtocols} protocols={offProtocols}
dateKey="exit_date" dateLabel="Austrittsdatum" dateKey="exit_date" dateLabel="Austrittsdatum"
onEdit={openOffEdit} onOpen={p => navigate(`/lifecycle/offboarding/${p.id}`)}
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')} onDownload={p => p.pdf_file_path && window.open(offboardingService.downloadPdf(p.pdf_file_path), '_blank')}
/> />
)} )}

View File

@@ -40,6 +40,7 @@ import DefenderPage from './pages/DefenderPage';
import WarehousePage from './pages/WarehousePage'; import WarehousePage from './pages/WarehousePage';
import PortalPage from './pages/PortalPage'; import PortalPage from './pages/PortalPage';
import PatchManagementPage from './pages/PatchManagementPage'; import PatchManagementPage from './pages/PatchManagementPage';
import OffboardingDetailPage from './pages/OffboardingDetailPage';
import ProxmoxPage from './pages/ProxmoxPage'; import ProxmoxPage from './pages/ProxmoxPage';
import DockerPage from './pages/DockerPage'; import DockerPage from './pages/DockerPage';
import KnowledgeAiPage from './pages/KnowledgeAiPage'; import KnowledgeAiPage from './pages/KnowledgeAiPage';
@@ -106,6 +107,7 @@ const AppRoutes = () => {
<Route path="/onboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} /> <Route path="/onboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/offboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} /> <Route path="/offboarding" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/lifecycle" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} /> <Route path="/lifecycle" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OnOffboardingPage /></L>} />
<Route path="/lifecycle/offboarding/:id" element={<L roles={['super_admin', 'admin', 'hr_personal', 'buchhaltung', 'support']}><OffboardingDetailPage /></L>} />
<Route path="/users" element={<L roles={['super_admin', 'admin']}><UsersPage /></L>} /> <Route path="/users" element={<L roles={['super_admin', 'admin']}><UsersPage /></L>} />
<Route path="/benutzerverwaltung" element={<L roles={['super_admin', 'admin']}><UserManagementPage /></L>} /> <Route path="/benutzerverwaltung" element={<L roles={['super_admin', 'admin']}><UserManagementPage /></L>} />