mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +02:00
- 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>
537 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|