Files
perinco-CRM/src/components/calendar/mentions.tsx
T
Sascha WolfandClaude Opus 5 8b7ce448a6 fix(ui): echte Umlaute in allen sichtbaren Texten
Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").

Unangetastet bleibt bewusst:

- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
  dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
  'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
  Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
  geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
  Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
  sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar

Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:37:24 +02:00

334 lines
11 KiB
TypeScript

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<MentionTextProps> = ({
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 (
<span className={`whitespace-pre-wrap break-words ${className}`}>
{parseMentions(text).map((segment, index) => {
if (segment.type === 'text') return <React.Fragment key={index}>{segment.value}</React.Fragment>;
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 (
<span
key={index}
title="Diese Person ist nicht mehr verfügbar."
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-400"
>
@{segment.label}
</span>
);
}
const tint = user ? userColor(user) : '#6ba04a';
return (
<button
key={index}
type="button"
onClick={(event) => {
event.stopPropagation();
if (segment.kind === 'lead') onOpenLead?.(segment.id);
else onOpenUser?.(segment.id);
}}
title={segment.kind === 'lead' ? 'Kontakt öffnen' : 'Benutzer anzeigen'}
className="mx-0.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 align-baseline text-xs font-bold transition hover:brightness-95"
style={{ backgroundColor: `${tint}1f`, color: tint }}
>
<span
className="inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: tint }}
/>
@{label}
</button>
);
})}
</span>
);
};
// ---------- 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<MentionTextareaProps> = ({
value,
onChange,
candidates,
placeholder,
rows = 4,
className = '',
id,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="relative">
<textarea
id={id}
ref={textareaRef}
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => {
onChange(event.target.value);
syncQuery(event.target.value, event.target.selectionStart ?? 0);
}}
onClick={(event) =>
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0)
}
onKeyUp={(event) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0);
}
}}
onKeyDown={handleKeyDown}
onBlur={() => window.setTimeout(() => setQuery(null), 120)}
className={className}
/>
{isOpen && (
<ul
role="listbox"
className="scrollbar-slim absolute left-0 right-0 z-20 mt-1 max-h-64 overflow-y-auto rounded-xl border border-ink-200 bg-surface py-1 shadow-lift"
>
{suggestions.map((candidate, index) => (
<li key={`${candidate.kind}-${candidate.id}`}>
<button
type="button"
role="option"
aria-selected={index === highlighted}
// MouseDown statt Click: das Blur der Textarea darf die Liste
// nicht vorher schliessen.
onMouseDown={(event) => {
event.preventDefault();
insert(candidate);
}}
onMouseEnter={() => setHighlighted(index)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition ${
index === highlighted ? 'bg-brand-50' : 'hover:bg-ink-50'
}`}
>
{candidate.avatar ? (
<img
src={candidate.avatar}
alt=""
className="h-7 w-7 shrink-0 rounded-lg object-cover"
/>
) : (
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[10px] font-extrabold text-white"
style={{ backgroundColor: candidate.color }}
>
{candidate.kind === 'user' ? 'U' : 'K'}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-900">
{candidate.label}
</span>
<span className="block truncate text-xs text-ink-400">{candidate.hint}</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
);
};