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:
Sascha Wolf
2026-08-06 15:08:59 +02:00
co-authored by Claude Opus 5
commit 82d1b7eab4
106 changed files with 24133 additions and 0 deletions
+333
View File
@@ -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>
);
};