feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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 verfuegbar."
|
||||
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 oeffnen' : '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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user