import React, { useEffect, useMemo, useRef, useState } from 'react'; import { leadFullName, userColor, type AppUser, type Lead, } from '../../types'; /** * Mentions werden als Token im Text gespeichert: `@[Anzeigename](lead:12)` * bzw. `@[Anzeigename](user:3)`. Der Name steht mit im Token, damit der Text * auch ohne geladene Listen lesbar bleibt; beim Rendern gewinnt aber der * aktuelle Name aus den Daten, falls die Person noch existiert. */ const MENTION_TOKEN = /@\[([^\]\n]+)\]\((lead|user):(\d+)\)/g; export type MentionKind = 'lead' | 'user'; export interface MentionCandidate { kind: MentionKind; id: number; label: string; hint: string; color: string; /** Foto des Kontakts bzw. Benutzers, falls hinterlegt. */ avatar: string | null; } type Segment = | { type: 'text'; value: string } | { type: 'mention'; kind: MentionKind; id: number; label: string }; export function parseMentions(text: string): Segment[] { const segments: Segment[] = []; let lastIndex = 0; // Der Regex ist global, also vor jedem Durchlauf zuruecksetzen. MENTION_TOKEN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = MENTION_TOKEN.exec(text)) !== null) { if (match.index > lastIndex) { segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }); } segments.push({ type: 'mention', label: match[1], kind: match[2] as MentionKind, id: Number(match[3]), }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) segments.push({ type: 'text', value: text.slice(lastIndex) }); return segments; } /** Text ohne Token-Syntax - fuer Vorschauen, Tooltips und Suche. */ export function mentionPlainText(text: string): string { return parseMentions(text) .map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`)) .join(''); } export function buildCandidates(leads: Lead[], users: AppUser[]): MentionCandidate[] { const fromUsers: MentionCandidate[] = users.map((user) => ({ kind: 'user', id: user.id, label: user.name, hint: `Benutzer · @${user.username}`, color: userColor(user), avatar: user.avatar ?? null, })); const fromLeads: MentionCandidate[] = leads.map((lead) => ({ kind: 'lead', id: lead.id, label: leadFullName(lead), hint: [lead.company, lead.position].filter(Boolean).join(' · ') || 'Kontakt', color: '#6e6e6e', avatar: lead.avatar, })); // Benutzer zuerst: im Teamkalender werden meist Kollegen markiert. return [...fromUsers, ...fromLeads]; } export function mentionToken(candidate: MentionCandidate): string { // Eckige Klammern im Namen wuerden das Token zerlegen. const label = candidate.label.replace(/[\[\]()]/g, '').trim() || 'Unbenannt'; return `@[${label}](${candidate.kind}:${candidate.id})`; } // ---------- Anzeige ---------- interface MentionTextProps { text: string; leads: Lead[]; users: AppUser[]; onOpenLead?: (leadId: number) => void; onOpenUser?: (userId: number) => void; className?: string; } /** Rendert Text mit klickbaren Mentions. Zeilenumbrueche bleiben erhalten. */ export const MentionText: React.FC = ({ text, leads, users, onOpenLead, onOpenUser, className = '', }) => { const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]); const userById = useMemo(() => new Map(users.map((user) => [user.id, user])), [users]); return ( {parseMentions(text).map((segment, index) => { if (segment.type === 'text') return {segment.value}; const lead = segment.kind === 'lead' ? leadById.get(segment.id) : undefined; const user = segment.kind === 'user' ? userById.get(segment.id) : undefined; const exists = Boolean(lead || user); const label = lead ? leadFullName(lead) : user ? user.name : segment.label; if (!exists) { // Person gibt es nicht mehr (oder ist deaktiviert): nur noch Text. return ( @{segment.label} ); } const tint = user ? userColor(user) : '#6ba04a'; return ( ); })} ); }; // ---------- Eingabe ---------- interface MentionTextareaProps { value: string; onChange: (value: string) => void; candidates: MentionCandidate[]; placeholder?: string; rows?: number; className?: string; id?: string; } const MAX_SUGGESTIONS = 8; /** Was steht direkt vor dem Cursor gerade nach einem @? */ function activeQuery(text: string, caret: number): { start: number; query: string } | null { const before = text.slice(0, caret); // @ am Wortanfang, danach keine Leerzeichen und kein weiteres @ const match = /(^|[\s(,;])@([^\s@\[\]()]{0,40})$/.exec(before); if (!match) return null; return { start: caret - match[2].length - 1, query: match[2] }; } export const MentionTextarea: React.FC = ({ value, onChange, candidates, placeholder, rows = 4, className = '', id, }) => { const textareaRef = useRef(null); const [query, setQuery] = useState<{ start: number; query: string } | null>(null); const [highlighted, setHighlighted] = useState(0); const suggestions = useMemo(() => { if (!query) return []; const needle = query.query.toLowerCase(); const matches = needle ? candidates.filter( (candidate) => candidate.label.toLowerCase().includes(needle) || candidate.hint.toLowerCase().includes(needle) ) : candidates; return matches.slice(0, MAX_SUGGESTIONS); }, [candidates, query]); useEffect(() => { setHighlighted(0); }, [query?.query]); const isOpen = query !== null && suggestions.length > 0; const syncQuery = (text: string, caret: number) => setQuery(activeQuery(text, caret)); const insert = (candidate: MentionCandidate) => { if (!query) return; const token = `${mentionToken(candidate)} `; const next = value.slice(0, query.start) + token + value.slice(query.start + 1 + query.query.length); onChange(next); setQuery(null); // Cursor hinter das eingefuegte Token setzen. const caret = query.start + token.length; requestAnimationFrame(() => { const element = textareaRef.current; if (!element) return; element.focus(); element.setSelectionRange(caret, caret); }); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (!isOpen) return; if (event.key === 'ArrowDown') { event.preventDefault(); setHighlighted((index) => (index + 1) % suggestions.length); } else if (event.key === 'ArrowUp') { event.preventDefault(); setHighlighted((index) => (index - 1 + suggestions.length) % suggestions.length); } else if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); insert(suggestions[highlighted]); } else if (event.key === 'Escape') { // Nicht das Modal schliessen, nur die Vorschlagsliste. event.preventDefault(); event.stopPropagation(); setQuery(null); } }; return (