INSIGHT-MVP/packages/frontend/src/profile/sections/ProjectModal.tsx
Thomas Reitz c8b25321e7 feat(core+frontend): Profilzugriff-Gruppen für Admin mit delegierten Berechtigungen
- Neue Prisma-Modelle ProfileAccessGroup + ProfileAccessGroupMember mit canView/canExport/canEdit
- Manuelle Migration 20260314_profile_access_groups
- ProfileAccessModule: CRUD-Endpoints für Gruppen und Mitglieder (nur PLATFORM_ADMIN)
- Neue Admin-Endpoints in ExpertProfileService/-Controller für alle Profil-Mutationen
- verifyOwnership mit skipCheck-Parameter für Admin-Bypass
- ExpertProfileTab + alle Section-Komponenten erhalten apiBase-Prop für Wiederverwendung
- AdminProfileAccessPage: Gruppen-Tab (CRUD) + Profile-Tab (alle User mit Aktionen)
- AdminProfileDetailPage: Profil eines beliebigen Users im Admin-Kontext bearbeiten
- Route /admin/profile-access + /admin/profiles/:userId + Nav-Tab Profilzugriff

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:47:36 +01:00

537 lines
18 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, type FormEvent } from 'react';
import { Modal } from '../../components/Modal';
import api from '../../api/client';
import type { ExpertProject } from '../ExpertProfileTab';
import styles from '../ExpertProfileTab.module.css';
interface ProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => Promise<void>;
project: ExpertProject | null;
apiBase?: string;
}
const MONTHS = [
{ value: 1, label: 'Januar' }, { value: 2, label: 'Februar' },
{ value: 3, label: 'März' }, { value: 4, label: 'April' },
{ value: 5, label: 'Mai' }, { value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' }, { value: 8, label: 'August' },
{ value: 9, label: 'September' }, { value: 10, label: 'Oktober' },
{ value: 11, label: 'November' }, { value: 12, label: 'Dezember' },
];
const COMPANY_SIZES = ['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+'];
const INDUSTRIES = [
'IT-Dienstleistung', 'Software-Entwicklung', 'Cloud & Hosting', 'Telekommunikation',
'Finanzdienstleistung', 'Versicherung', 'Gesundheitswesen', 'Pharma & Medizintechnik',
'Automobilindustrie', 'Maschinenbau', 'Energiewirtschaft', 'Logistik & Transport',
'Handel & E-Commerce', 'Medien & Unterhaltung', 'Bildung & Forschung',
'Öffentlicher Sektor', 'Beratung & Consulting', 'Luft- und Raumfahrt',
'Chemie & Werkstoffe', 'Sonstige',
];
const currentYear = new Date().getFullYear();
const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i);
// ─── BulletEditor helpers (module-level, pure) ───────────────────────────────
/**
* Wrap or unwrap the current selection with an inline formatting marker.
* - With selection: wraps/unwraps (toggle)
* - Without selection: inserts marker pair with cursor inside
*/
function blWrapFormat(
ta: HTMLTextAreaElement,
v: string,
marker: string,
onChange: (val: string) => void,
): void {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const mLen = marker.length;
const selected = v.substring(start, end);
if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length > mLen * 2) {
// Already wrapped → unwrap
const inner = selected.substring(mLen, selected.length - mLen);
onChange(v.substring(0, start) + inner + v.substring(end));
requestAnimationFrame(() => {
ta.selectionStart = start;
ta.selectionEnd = start + inner.length;
ta.focus();
});
} else if (selected.length > 0) {
// Wrap selection
onChange(v.substring(0, start) + marker + selected + marker + v.substring(end));
requestAnimationFrame(() => {
ta.selectionStart = start + mLen;
ta.selectionEnd = end + mLen;
ta.focus();
});
} else {
// No selection: insert empty marker pair, cursor inside
onChange(v.substring(0, start) + marker + marker + v.substring(start));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + mLen;
ta.focus();
});
}
}
/** Parse the line at cursor position into its structural components */
function blLineAt(val: string, pos: number) {
const s = val.lastIndexOf('\n', pos - 1) + 1;
const eIdx = val.indexOf('\n', pos);
const e = eIdx === -1 ? val.length : eIdx;
const text = val.substring(s, e);
const bm = text.match(/^( *)(• )([\s\S]*)$/);
const nm = text.match(/^( *)(\d+)\. ([\s\S]*)$/);
return {
s,
e,
text,
indent: bm?.[1] ?? nm?.[1] ?? (text.match(/^ */)?.[0] ?? ''),
isBullet: !!bm,
isNum: !!nm,
num: nm ? parseInt(nm[2]) : 0,
body: bm ? bm[3] : nm ? nm[3] : text.replace(/^ */, ''),
};
}
// ─── BulletEditor component ───────────────────────────────────────────────────
/**
* Lightweight rich-text-like editor for task lists.
* Supports bullet lists (• ), numbered lists (1. 2. 3.),
* Tab/Shift+Tab indentation, and auto-continuation on Enter.
* No external dependencies — plain textarea with smart keyboard handling.
*/
function BulletEditor({
value,
onChange,
placeholder,
maxLength,
rows = 6,
}: {
value: string;
onChange: (val: string) => void;
placeholder?: string;
maxLength?: number;
rows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
/** Replace the current line and reposition cursor */
function applyLineMutation(
ta: HTMLTextAreaElement,
v: string,
s: number,
e: number,
newLine: string,
newPos: number,
) {
onChange(v.substring(0, s) + newLine + v.substring(e));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = newPos;
ta.focus();
});
}
// ── Toggle bullet list ──────────────────────────────────────────────────────
const toggleBullet = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const pos = ta.selectionStart;
const v = ta.value;
const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos);
if (isBullet) {
// Remove bullet
applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - 2));
} else if (isNum) {
// Convert numbered → bullet
const oldNumLen = String(num).length + 2; // "N. "
applyLineMutation(ta, v, s, e, indent + '• ' + body, Math.max(s + indent.length + 2, pos - oldNumLen + 2));
} else {
// Add bullet
applyLineMutation(ta, v, s, e, indent + '• ' + body, pos + 2);
}
}, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Inline formatting: Bold / Italic / Underline ───────────────────────────
const wrapBold = useCallback(() => {
const ta = textareaRef.current;
if (ta) blWrapFormat(ta, ta.value, '**', onChange);
}, [onChange]);
const wrapItalic = useCallback(() => {
const ta = textareaRef.current;
if (ta) blWrapFormat(ta, ta.value, '*', onChange);
}, [onChange]);
const wrapUnderline = useCallback(() => {
const ta = textareaRef.current;
if (ta) blWrapFormat(ta, ta.value, '__', onChange);
}, [onChange]);
// ── Toggle numbered list ────────────────────────────────────────────────────
const toggleNumbered = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const pos = ta.selectionStart;
const v = ta.value;
const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos);
if (isNum) {
// Remove numbered
const oldNumLen = String(num).length + 2;
applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - oldNumLen));
} else if (isBullet) {
// Convert bullet → numbered
applyLineMutation(ta, v, s, e, indent + '1. ' + body, s + indent.length + 3);
} else {
// Add numbered
applyLineMutation(ta, v, s, e, indent + '1. ' + body, pos + 3);
}
}, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard handler: Tab/Shift+Tab + Enter ─────────────────────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const ta = textareaRef.current;
if (!ta) return;
const pos = ta.selectionStart;
const v = ta.value;
const info = blLineAt(v, pos);
// Ctrl+B / Ctrl+I / Ctrl+U → inline formatting
if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key)) {
e.preventDefault();
if (e.key === 'b') blWrapFormat(ta, v, '**', onChange);
else if (e.key === 'i') blWrapFormat(ta, v, '*', onChange);
else if (e.key === 'u') blWrapFormat(ta, v, '__', onChange);
return;
}
// Tab → indent (+2 spaces at line start)
// Shift+Tab → outdent (-2 spaces from line start)
if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) {
if (info.text.startsWith(' ')) {
onChange(v.substring(0, info.s) + info.text.substring(2) + v.substring(info.e));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = Math.max(info.s, pos - 2);
});
}
} else {
onChange(v.substring(0, info.s) + ' ' + v.substring(info.s));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos + 2;
});
}
return;
}
if (e.key !== 'Enter') return;
const isEmptyItem = (info.isBullet || info.isNum) && info.body.trim() === '';
if (isEmptyItem) {
// Empty list item → exit list, keep indentation only
e.preventDefault();
onChange(v.substring(0, info.s) + info.indent + v.substring(info.e));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = info.s + info.indent.length;
});
} else if (info.isBullet) {
// Continue bullet on next line (preserving indentation)
e.preventDefault();
const ins = '\n' + info.indent + '• ';
onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos + ins.length;
});
} else if (info.isNum) {
// Continue numbered list with incremented number
e.preventDefault();
const ins = '\n' + info.indent + String(info.num + 1) + '. ';
onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd));
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos + ins.length;
});
}
},
[onChange],
);
return (
<div className={styles.bulletEditor}>
<div className={styles.bulletToolbar}>
<button
type="button"
className={styles.bulletBtn}
onClick={toggleBullet}
title="Aufzählungsliste ein/aus (• Punkt)"
>
Liste
</button>
<button
type="button"
className={styles.bulletBtn}
onClick={toggleNumbered}
title="Nummerierte Liste ein/aus (1. 2. 3.)"
>
1. Liste
</button>
<span className={styles.bulletToolbarSep} />
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnBold}`}
onClick={wrapBold}
title="Fett (Strg+B) — Text markieren, dann B drücken"
>
B
</button>
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnItalic}`}
onClick={wrapItalic}
title="Kursiv (Strg+I) — Text markieren, dann I drücken"
>
I
</button>
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnUnderline}`}
onClick={wrapUnderline}
title="Unterstrichen (Strg+U) — Text markieren, dann U drücken"
>
U
</button>
<span className={styles.bulletToolbarSep} />
<span className={styles.bulletToolbarHint}>Tab = einrücken · Tab = ausrücken</span>
</div>
<textarea
ref={textareaRef}
className={styles.bulletEditorTextarea}
value={value}
onChange={(ev) => onChange(ev.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
maxLength={maxLength}
rows={rows}
/>
</div>
);
}
export function ProjectModal({ isOpen, onClose, onSave, project, apiBase = '/expert-profile/me' }: ProjectModalProps) {
const [fromMonth, setFromMonth] = useState(1);
const [fromYear, setFromYear] = useState(currentYear);
const [toMonth, setToMonth] = useState(1);
const [toYear, setToYear] = useState(currentYear);
const [isCurrent, setIsCurrent] = useState(false);
const [role, setRole] = useState('');
const [tasks, setTasks] = useState('');
const [company, setCompany] = useState('');
const [companySize, setCompanySize] = useState('');
const [industry, setIndustry] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
if (project) {
setFromMonth(project.fromMonth);
setFromYear(project.fromYear);
setToMonth(project.toMonth ?? 1);
setToYear(project.toYear ?? currentYear);
setIsCurrent(project.isCurrent);
setRole(project.role);
setTasks(project.tasks ?? '');
setCompany(project.company ?? '');
setCompanySize(project.companySize ?? '');
setIndustry(project.industry ?? '');
} else {
setFromMonth(1);
setFromYear(currentYear);
setToMonth(1);
setToYear(currentYear);
setIsCurrent(false);
setRole('');
setTasks('');
setCompany('');
setCompanySize('');
setIndustry('');
}
setError('');
}
}, [isOpen, project]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
const payload = {
fromMonth,
fromYear,
...(!isCurrent && { toMonth, toYear }),
isCurrent,
role: role.trim(),
...(tasks.trim() && { tasks: tasks.trim() }),
...(company.trim() && { company: company.trim() }),
...(companySize && { companySize }),
...(industry && { industry }),
};
try {
if (project) {
await api.patch(`${apiBase}/projects/${project.id}`, payload);
} else {
await api.post(`${apiBase}/projects`, payload);
}
await onSave();
} catch (err: unknown) {
const apiErr = err as { response?: { data?: { message?: string } } };
setError(apiErr.response?.data?.message ?? 'Fehler beim Speichern');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={project ? 'Projekt bearbeiten' : 'Neues Projekt hinzufügen'}
maxWidth="650px"
>
<form onSubmit={handleSubmit} className={styles.modalForm}>
{error && <div className={styles.error}>{error}</div>}
{/* Zeitraum von */}
<div className={styles.modalFieldRow}>
<div className={styles.modalField}>
<label>Zeitraum von *</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<select value={fromMonth} onChange={(e) => setFromMonth(Number(e.target.value))} required>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select value={fromYear} onChange={(e) => setFromYear(Number(e.target.value))} required>
{YEARS.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
<div className={styles.modalField}>
<label>Zeitraum bis *</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<select
value={toMonth}
onChange={(e) => setToMonth(Number(e.target.value))}
disabled={isCurrent}
>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
value={toYear}
onChange={(e) => setToYear(Number(e.target.value))}
disabled={isCurrent}
>
{YEARS.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</div>
<div className={styles.checkboxRow}>
<input
type="checkbox"
id="isCurrent"
checked={isCurrent}
onChange={(e) => setIsCurrent(e.target.checked)}
/>
<label htmlFor="isCurrent">bis heute</label>
</div>
<div className={styles.modalField}>
<label>Tätigkeit *</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="z.B. Senior DevOps Engineer"
maxLength={200}
required
/>
</div>
<div className={styles.modalField}>
<label>Aufgaben</label>
<BulletEditor
value={tasks}
onChange={setTasks}
placeholder="Beschreiben Sie Ihre Aufgaben... (☰ Liste für Aufzählungspunkte)"
maxLength={1500}
rows={6}
/>
<div className={`${styles.charCount} ${tasks.length > 1400 ? styles.charCountWarn : ''}`}>
{tasks.length}/1.500
</div>
</div>
<div className={styles.modalField}>
<label>Firma</label>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Firmenname"
maxLength={200}
/>
</div>
<div className={styles.modalFieldRow}>
<div className={styles.modalField}>
<label>Unternehmensgröße</label>
<select value={companySize} onChange={(e) => setCompanySize(e.target.value)}>
<option value="">Anzahl der Mitarbeitenden</option>
{COMPANY_SIZES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className={styles.modalField}>
<label>Branche</label>
<select value={industry} onChange={(e) => setIndustry(e.target.value)}>
<option value="">Bitte wählen</option>
{INDUSTRIES.map((ind) => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
</div>
</div>
<div className={styles.btnRow}>
<button type="button" className={styles.btnSecondary} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={styles.btnPrimary} disabled={loading || !role.trim()}>
{loading ? 'Speichern...' : project ? 'Projekt speichern' : 'Projekt hinzufügen'}
</button>
</div>
</form>
</Modal>
);
}