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:
558
frontend/src/pages/OffboardingDetailPage.jsx
Normal file
558
frontend/src/pages/OffboardingDetailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import onboardingService from '../services/onboardingService';
|
||||
import offboardingService from '../services/offboardingService';
|
||||
@@ -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') : '—';
|
||||
|
||||
// ── 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' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
@@ -248,9 +249,14 @@ const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onAction, action
|
||||
<td style={{ fontSize: '0.85rem' }}>{p.created_by_username}</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
{onOpen
|
||||
? <button onClick={() => onOpen(p)} className="btn btn-primary btn-small">Öffnen →</button>
|
||||
: <>
|
||||
<button onClick={() => onEdit(p)} className="btn btn-primary btn-small">Bearbeiten</button>
|
||||
{onAction && <button onClick={() => onAction(p)} className={`btn ${actionClass} btn-small`}>{actionLabel}</button>}
|
||||
{canDelete && <button onClick={() => onDelete(p.id)} className="btn btn-danger btn-small">Löschen</button>}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -262,6 +268,7 @@ const ProtocolTable = ({ protocols, dateKey, dateLabel, onEdit, onAction, action
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
const OnOffboardingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAdmin, isSuperAdmin, canViewLifecycle } = useAuth();
|
||||
const canManage = isAdmin() || isSuperAdmin();
|
||||
const canCreate = canViewLifecycle(); // hr_personal + buchhaltung dürfen anlegen
|
||||
@@ -620,9 +627,7 @@ const OnOffboardingPage = () => {
|
||||
<ProtocolTable
|
||||
protocols={offProtocols}
|
||||
dateKey="exit_date" dateLabel="Austrittsdatum"
|
||||
onEdit={openOffEdit}
|
||||
onAction={openReturnAssets} actionLabel="Assets zurück" actionClass="btn-warning"
|
||||
onDelete={deleteOff}
|
||||
onOpen={p => navigate(`/lifecycle/offboarding/${p.id}`)}
|
||||
onDownload={p => p.pdf_file_path && window.open(offboardingService.downloadPdf(p.pdf_file_path), '_blank')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ import DefenderPage from './pages/DefenderPage';
|
||||
import WarehousePage from './pages/WarehousePage';
|
||||
import PortalPage from './pages/PortalPage';
|
||||
import PatchManagementPage from './pages/PatchManagementPage';
|
||||
import OffboardingDetailPage from './pages/OffboardingDetailPage';
|
||||
import ProxmoxPage from './pages/ProxmoxPage';
|
||||
import DockerPage from './pages/DockerPage';
|
||||
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="/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/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="/benutzerverwaltung" element={<L roles={['super_admin', 'admin']}><UserManagementPage /></L>} />
|
||||
|
||||
Reference in New Issue
Block a user