Quellcode des ICON CRM (6)

This commit is contained in:
2026-09-14 16:55:40 +02:00
parent 4fcb34f324
commit bd810fa608
24 changed files with 3855 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
/** sm für Bestätigungen, lg für Formulare, xl für Karten mit Reitern. */
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const SIZES = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
};
/*
* Dialogfenster.
*
* Zwei Dinge aus der Featureliste stecken hier drin:
* * Ein Klick auf den Hintergrund schliesst das vorderste Fenster.
* * Esc tut dasselbe - und zwar nur beim obersten Fenster, sonst wuerde ein
* Kontakt, der aus einem Unternehmen heraus geoeffnet wurde, beide
* Ebenen auf einmal schliessen.
*/
// Zaehlt die offenen Fenster. Die zuletzt geoeffnete Ebene ist die oberste.
let openCount = 0;
export const Modal: React.FC<ModalProps> = ({
open,
title,
subtitle,
onClose,
children,
footer,
size = 'md',
}) => {
const depthRef = useRef(0);
useEffect(() => {
if (!open) return;
openCount += 1;
depthRef.current = openCount;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (depthRef.current !== openCount) return;
event.stopPropagation();
onClose();
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onKeyDown);
return () => {
openCount -= 1;
document.removeEventListener('keydown', onKeyDown);
if (openCount === 0) document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-950/60 p-4 backdrop-blur-sm sm:p-8"
// Nur ein Klick auf die Flaeche selbst schliesst - ein Klick im Fenster
// steigt zwar hierher auf, hat dann aber ein anderes Ziel.
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
className={`my-auto w-full ${SIZES[size]} overflow-hidden rounded-2xl bg-surface shadow-lift`}
>
<header className="flex items-start gap-4 border-b border-ink-200 px-6 py-4">
<div className="min-w-0 flex-1">
<h2 className="truncate text-lg font-bold text-ink-900">{title}</h2>
{subtitle && <p className="mt-0.5 truncate text-sm text-ink-400">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="-mr-2 -mt-1 rounded-lg p-2 text-ink-400 hover:bg-ink-100 hover:text-ink-700"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<path d="M6.3 5A.9.9 0 0 0 5 6.3L8.7 10 5 13.7A.9.9 0 1 0 6.3 15L10 11.3 13.7 15a.9.9 0 0 0 1.3-1.3L11.3 10 15 6.3A.9.9 0 0 0 13.7 5L10 8.7Z" />
</svg>
</button>
</header>
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto px-6 py-5 scrollbar-slim">{children}</div>
{footer && (
<footer className="flex items-center justify-end gap-2 border-t border-ink-200 bg-ink-50 px-6 py-3">
{footer}
</footer>
)}
</div>
</div>
);
};
interface ConfirmProps {
open: boolean;
title: string;
message: React.ReactNode;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
danger?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmProps> = ({
open,
title,
message,
confirmLabel = 'Löschen',
onConfirm,
onCancel,
danger = true,
}) => (
<Modal
open={open}
title={title}
onClose={onCancel}
size="sm"
footer={
<>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Abbrechen
</button>
<button
type="button"
onClick={onConfirm}
className={`rounded-lg px-4 py-2 text-sm font-semibold text-white ${
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-brand-600 hover:bg-brand-700'
}`}
>
{confirmLabel}
</button>
</>
}
>
<p className="text-sm text-ink-700">{message}</p>
</Modal>
);
+114
View File
@@ -0,0 +1,114 @@
import React, { useEffect, useRef } from 'react';
/*
* Kleiner WYSIWYG-Editor.
*
* Bewusst ohne Fremdpaket: Quill & Co. bringen ihr eigenes Farbschema mit, das
* im Dunkelmodus gegen die Variablen dieser Anwendung arbeitet, und kosten
* mehr als 200 kB. Gebraucht werden hier sechs Formate - das ist mit
* `contentEditable` und `document.execCommand` in wenigen Zeilen erledigt.
*
* `execCommand` gilt als veraltet, ist aber in allen aktuellen Browsern
* implementiert und der einzige Weg, der ohne eigenes Auswahl- und
* Bereichsmodell auskommt. Ein Ersatz waere ein eigenes Dokumentmodell - das
* waere fuer ein Beschreibungsfeld unverhaeltnismaessig.
*/
interface RichTextEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
minHeight?: number;
/** Zusatzknöpfe rechts in der Leiste, z. B. für die KI. */
actions?: React.ReactNode;
}
interface ToolButton {
command: string;
argument?: string;
label: React.ReactNode;
title: string;
}
const BUTTONS: ToolButton[] = [
{ command: 'bold', label: <strong>F</strong>, title: 'Fett (Strg+B)' },
{ command: 'italic', label: <em>K</em>, title: 'Kursiv (Strg+I)' },
{ command: 'formatBlock', argument: 'h2', label: 'H2', title: 'Überschrift' },
{ command: 'formatBlock', argument: 'h3', label: 'H3', title: 'Zwischenüberschrift' },
{ command: 'formatBlock', argument: 'p', label: '¶', title: 'Absatz' },
{ command: 'insertUnorderedList', label: '• •', title: 'Aufzählung' },
{ command: 'insertOrderedList', label: '1.', title: 'Nummerierte Liste' },
{ command: 'removeFormat', label: '⌫', title: 'Formatierung entfernen' },
];
export const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder,
minHeight = 220,
actions,
}) => {
const ref = useRef<HTMLDivElement>(null);
/*
* Der Inhalt wird nur dann von aussen gesetzt, wenn er sich wirklich
* unterscheidet. Sonst schriebe jeder Tastendruck das DOM neu und die
* Schreibmarke spränge ans Ende.
*/
useEffect(() => {
const element = ref.current;
if (!element) return;
if (element.innerHTML !== value) element.innerHTML = value || '';
}, [value]);
const run = (button: ToolButton) => {
ref.current?.focus();
document.execCommand(button.command, false, button.argument);
onChange(ref.current?.innerHTML ?? '');
};
return (
<div className="rounded-lg border border-ink-200 bg-surface">
<div className="flex flex-wrap items-center gap-1 border-b border-ink-200 px-2 py-1.5">
{BUTTONS.map((button) => (
<button
key={`${button.command}-${button.argument ?? ''}`}
type="button"
title={button.title}
// Der Editor darf den Fokus nicht verlieren - sonst geht die
// markierte Stelle verloren, bevor der Befehl greift.
onMouseDown={(event) => event.preventDefault()}
onClick={() => run(button)}
className="min-w-[2rem] rounded px-2 py-1 text-sm text-ink-600 hover:bg-ink-100 hover:text-ink-900"
>
{button.label}
</button>
))}
{actions && <span className="ml-auto flex flex-wrap items-center gap-1">{actions}</span>}
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
style={{ minHeight }}
onInput={(event) => onChange((event.target as HTMLDivElement).innerHTML)}
onPaste={(event) => {
/*
* Aus Word oder einem Portal kommt HTML mit fremden Schriftgroessen,
* Farben und <span style>-Wolken. Eingefuegt wird deshalb der reine
* Text - die Formatierung macht der Benutzer hier.
*/
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
onChange(ref.current?.innerHTML ?? '');
}}
className="prose-editor max-w-none px-3 py-2 text-sm text-ink-900 focus:outline-none"
/>
</div>
);
};
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
interface StarRatingProps {
value: number;
/** Ohne onChange ist die Anzeige rein lesend. */
onChange?: (value: number) => void;
size?: number;
className?: string;
}
/**
* Fuenf Sterne: wie interessant ist ein Unternehmen fuer uns.
*
* Ein Klick auf den bereits gesetzten Stern nimmt die Bewertung wieder zurueck
* (0 = nicht bewertet) - sonst liesse sich eine versehentliche Bewertung nicht
* mehr entfernen.
*/
export const StarRating: React.FC<StarRatingProps> = ({ value, onChange, size = 16, className = '' }) => {
const readOnly = !onChange;
return (
<span className={`inline-flex items-center gap-0.5 ${className}`} title={`${value} von 5`}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= value;
const icon = (
<svg
viewBox="0 0 20 20"
style={{ width: size, height: size }}
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={filled ? 0 : 1.4}
className={filled ? 'text-brand-500' : 'text-ink-300'}
aria-hidden="true"
>
<path d="M10 1.8l2.5 5.1 5.6.8-4 3.9.9 5.6L10 14.6l-5 2.6.9-5.6-4-3.9 5.6-.8z" />
</svg>
);
if (readOnly) return <span key={star}>{icon}</span>;
return (
<button
key={star}
type="button"
onClick={(event) => {
event.stopPropagation();
onChange?.(value === star ? 0 : star);
}}
aria-label={`${star} von 5 Sternen`}
className="rounded p-0.5 hover:scale-110"
>
{icon}
</button>
);
})}
</span>
);
};
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
/*
* Gemeinsame Bausteine der Formulare.
*
* Die Klassen stehen an genau einer Stelle: sonst driften Eingabefelder
* zwischen Kontakt-, Unternehmens- und Projektformular auseinander, und ein
* Design muesste jede Kopie einzeln erreichen.
*/
export const inputClass =
'w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900 placeholder:text-ink-400 disabled:bg-ink-50 disabled:text-ink-400';
export const labelClass =
'field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500';
export const Field: React.FC<{
label: string;
children: React.ReactNode;
hint?: string;
className?: string;
}> = ({ label, children, hint, className = '' }) => (
<div className={className}>
<label className={labelClass}>{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-ink-400">{hint}</p>}
</div>
);
export const PrimaryButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
className = '',
...props
}) => (
<button
{...props}
className={`rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50 ${className}`}
/>
);
export const SecondaryButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
className = '',
...props
}) => (
<button
{...props}
className={`rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100 disabled:opacity-50 ${className}`}
/>
);
/** Getoente Pille - Status, Art, Kennzeichen. */
export const Pill: React.FC<{ tone?: string; children: React.ReactNode; className?: string }> = ({
tone = 'ink',
children,
className = '',
}) => (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${TONES[tone] ?? TONES.ink} ${className}`}
>
{children}
</span>
);
const TONES: Record<string, string> = {
ink: 'border-ink-200 bg-ink-50 text-ink-600',
brand: 'border-brand-200 bg-brand-50 text-brand-700',
sky: 'border-sky-200 bg-sky-50 text-sky-700',
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
amber: 'border-amber-200 bg-amber-50 text-amber-700',
violet: 'border-violet-200 bg-violet-50 text-violet-700',
red: 'border-red-200 bg-red-50 text-red-700',
};
/** Umschalter zwischen zwei oder mehr Ansichten. */
export function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: Array<{ value: T; label: React.ReactNode; title?: string }>;
onChange: (value: T) => void;
}) {
return (
<div className="inline-flex rounded-lg border border-ink-200 bg-surface p-0.5">
{options.map((option) => (
<button
key={option.value}
type="button"
title={option.title}
onClick={() => onChange(option.value)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
value === option.value ? 'bg-brand-600 text-white' : 'text-ink-600 hover:bg-ink-100'
}`}
>
{option.label}
</button>
))}
</div>
);
}
+652
View File
@@ -0,0 +1,652 @@
@import 'tailwindcss';
/*
* ICON CRM - Corporate Identity
*
* Primaerfarbe #C1862F (Gold), heller Akzent #DFA85A
* Flaechen neutrales Grau bis #0A0A0A im Dunkelmodus
* Schrift Montserrat
*
* Aufbau wie im perinco CRM: im Markup steht nirgends eine feste Farbe,
* sondern durchgaengig brand-*, ink-*, canvas und surface. Ein Moduswechsel
* oder ein Design tauscht deshalb nur Variablenwerte - die Komponenten
* brauchen keine dark:-Varianten.
*
* Die Rollen der Skalen sind ueberall gleich:
* ink-50/100 Flaechen und Hover
* ink-200 Rahmen
* ink-300-900 Text; 400 traegt die Hinweistexte
* ink-950 liegt nur unter Dialog-Overlays, bleibt immer dunkel
* brand-50/100 getoente Flaechen
* brand-200-400 Rahmen und Akzente
* brand-500 das CI-Gold - Logo, aktive Kanten, Fokusring
* brand-600 gefuellte Knoepfe; hier steht WEISSE Schrift drauf, deshalb
* dunkler als 500 (Kontrast 4.8:1 statt 2.9:1)
* brand-700-900 Text auf getoenten Flaechen
*/
@theme {
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
/* Flaechen semantisch benannt statt "weiss" - im Dunkelmodus werden nur
diese Werte getauscht. */
--color-canvas: #f6f6f7;
--color-surface: #ffffff;
--color-brand-50: #fdf8f0;
--color-brand-100: #f8ecd8;
--color-brand-200: #efd8ae;
--color-brand-300: #e3bd7c;
--color-brand-400: #d3a253;
--color-brand-500: #c1862f;
--color-brand-600: #a06c22;
--color-brand-700: #7d541b;
--color-brand-800: #5f4116;
--color-brand-900: #48310f;
--color-brand-accent: #dfa85a;
--color-ink-50: #f7f7f8;
--color-ink-100: #ededef;
--color-ink-200: #dcdce0;
--color-ink-300: #bcbcc2;
--color-ink-400: #93939b;
--color-ink-500: #6b6b73;
--color-ink-600: #55555c;
--color-ink-700: #414147;
--color-ink-800: #2b2b30;
--color-ink-900: #1a1a1a;
--color-ink-950: #0a0a0a;
--shadow-card: 0 1px 2px rgb(10 10 10 / 0.04), 0 4px 16px rgb(10 10 10 / 0.06);
--shadow-lift: 0 8px 28px rgb(10 10 10 / 0.12);
}
/*
* Dunkelmodus. Die neutrale Skala wird gedreht: 50 ist die dunkelste Flaeche,
* 900 der hellste Text.
*
* Zwei Werte bleiben in beiden Modi gleich und sind deshalb bewusst so
* gewaehlt: ink-950 liegt nur unter Dialog-Overlays und muss dunkel bleiben,
* und text-white steht immer auf farbigen Flaechen.
*/
.dark {
--color-canvas: #0a0a0a;
--color-surface: #161616;
--color-ink-50: #1c1c1e;
--color-ink-100: #26262a;
--color-ink-200: #36363c;
/* Nicht die exakte Umkehrung von #bcbcc2 - dunkler waeren die Hinweistexte
(text-ink-300) auf der Flaeche kaum noch zu lesen. */
--color-ink-300: #6b6b73;
--color-ink-400: #90909a;
--color-ink-500: #a9a9b2;
--color-ink-600: #bfbfc6;
--color-ink-700: #d4d4d9;
--color-ink-800: #e7e7ea;
--color-ink-900: #f6f6f7;
--color-ink-950: #000000;
/* Das Gold bleibt das Gold. Die hellen Stufen werden zu dunklen Toenen
(Flaechen), die dunklen zu hellen (Text). 600 traegt weiter die gefuellten
Knoepfe, darf hier aber heller sein - auf Schwarz leuchtet es sonst nicht. */
--color-brand-50: #2a1e0a;
--color-brand-100: #3a290d;
--color-brand-200: #5a3f15;
--color-brand-300: #7d541b;
--color-brand-400: #a87428;
--color-brand-600: #d0953c;
--color-brand-700: #e3bd7c;
--color-brand-800: #efd8ae;
--color-brand-900: #f8ecd8;
/* Getoente Status-Pillen: hell mit dunklem Text wird hier gedreht. Die
Stufen 500/600 bleiben unberuehrt, dort steht weisse Schrift drauf. */
--color-sky-50: #0d2537;
--color-sky-200: #1d4a6b;
--color-sky-700: #7cc4ef;
--color-emerald-50: #0b2a1e;
--color-emerald-200: #14513a;
--color-emerald-700: #6ee7b7;
--color-amber-50: #2e2008;
--color-amber-200: #5c4210;
--color-amber-700: #f0b356;
--color-violet-50: #241a3d;
--color-violet-200: #3f2f66;
--color-violet-700: #b49bf2;
--color-red-50: #2e1414;
--color-red-200: #5e2626;
--color-red-700: #f28b8b;
}
/* ===========================================================================
* Half-Life
*
* Black Mesa: Beton, Warnorange und harte Kanten. Der Akzent ist das Orange des
* HEV-Anzugs, die Flächen sind Sichtbeton mit kaltem Stich, die Ecken fast
* rechtwinklig - Laboreinrichtung hat keine Radien.
*
* Zwei Dinge sind bewusst so gelöst:
*
* * Der Akzent bleibt Orange und wird nicht zu Gelb. Auf brand-500 und
* brand-600 steht überall weiße Schrift; ein helleres Warngelb wäre dort
* nicht mehr lesbar. Das Gelb-Schwarz der Gefahrenstreifen kommt deshalb
* nicht als Fläche vor.
* * Die Überschriften laufen in Oswald und gesperrt in Versalien - eine
* schmale Grotesk ist das, was die Beschilderung in Black Mesa ausmacht.
* Der Fließtext bleibt Montserrat: die Akquise-Tabellen haben acht Spalten
* bei 12 px, und eine Schmalschrift wäre darin nicht mehr zu lesen.
*
* Der helle Block ist mit :not(.dark) abgegrenzt. Ohne das gewänne er gegen
* .dark (eine Klasse mehr in der Spezifität) und der Dunkelmodus wäre kaputt.
* =========================================================================== */
html.theme-halflife:not(.dark) {
/*
* Grafik hinter Kopfleiste und Menü.
*
* Die Variable enthält ZWEI Ebenen: einen Schleier aus der Flächenfarbe und
* darunter die Zeichnung. Das ist kein Umweg, sondern der Grund, dass
* dieselbe Grafik in hell und dunkel funktioniert - und die Bedingung dafür,
* dass die Schrift lesbar bleibt. In der Kopfleiste steht der Schleier an den
* Rändern dicht (Titel links, Knöpfe rechts) und lässt die Grafik nur in der
* Mitte durch; im Menü liegt er oben über den Einträgen und gibt nach unten
* frei.
*/
--theme-header-image: linear-gradient(
90deg,
rgb(244 242 236 / 0.96) 0%,
rgb(244 242 236 / 0.55) 28%,
rgb(244 242 236 / 0.5) 55%,
rgb(244 242 236 / 0.96) 100%
),
url('/themes/halflife-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(244 242 236 / 0.98) 0%,
rgb(244 242 236 / 0.93) 45%,
rgb(244 242 236 / 0.62) 78%,
rgb(244 242 236 / 0.72) 100%
),
url('/themes/halflife-sidebar.svg');
/* Laborflur: Sichtbeton, kalt-warm gemischt. Deutlich dunkler als die Karten,
damit die Panels wie eingesetzte Platten wirken. */
--color-canvas: #dedbd3;
--color-surface: #f4f2ec;
--color-brand-50: #fdf1e5;
--color-brand-100: #fae0c6;
--color-brand-200: #f4bf8a;
--color-brand-300: #ec9c4f;
--color-brand-400: #e5811f;
--color-brand-500: #e0720b;
/* Gefüllte Knöpfe tragen weiße Schrift - dieser Ton hält den Kontrast. */
--color-brand-600: #b85a05;
--color-brand-700: #8f4604;
--color-brand-800: #6a3403;
--color-brand-900: #4b2502;
--color-brand-accent: #ff8c1a;
/* Stahlgrau mit kaltem Stich statt Neutralgrau. */
--color-ink-50: #eceae4;
--color-ink-100: #dedbd3;
--color-ink-200: #bfbcb2;
--color-ink-300: #98958b;
/* Hinweistexte (text-ink-400) müssen auf dem Beton lesbar bleiben -
deshalb dunkler als die reine Umkehrung. */
--color-ink-400: #6b6961;
--color-ink-500: #55534c;
--color-ink-600: #44423c;
--color-ink-700: #35332e;
--color-ink-800: #26241f;
--color-ink-900: #171512;
--color-ink-950: #0b0a08;
/* Kanten wie an Laboreinrichtung: fast rechtwinklig. */
--radius-sm: 0.0625rem;
--radius-md: 0.0625rem;
--radius-lg: 0.125rem;
--radius-xl: 0.125rem;
--radius-2xl: 0.1875rem;
/* Hart und versetzt statt weich - Beton wirft keinen weichen Schatten. */
--shadow-card: 0 1px 0 0 rgb(23 21 18 / 0.12), 2px 2px 0 0 rgb(23 21 18 / 0.06);
--shadow-lift: 4px 4px 0 0 rgb(23 21 18 / 0.18);
--font-display: 'Oswald', 'Montserrat', ui-sans-serif, system-ui, sans-serif;
}
/*
* Half-Life, dunkel: Ravenholm. Die Flächen sind nicht neutral schwarz, sondern
* ins Blaugrün gezogen; das Orange leuchtet kräftiger, weil es die einzige
* warme Farbe im Bild ist.
*/
html.theme-halflife.dark {
/* Dieselbe Zeichnung, dunkler Schleier: aus dem Laborflur wird Nachtschicht. */
--theme-header-image: linear-gradient(
90deg,
rgb(22 28 31 / 0.96) 0%,
rgb(22 28 31 / 0.62) 28%,
rgb(22 28 31 / 0.55) 55%,
rgb(22 28 31 / 0.96) 100%
),
url('/themes/halflife-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(22 28 31 / 0.97) 0%,
rgb(22 28 31 / 0.9) 45%,
rgb(22 28 31 / 0.55) 78%,
rgb(22 28 31 / 0.68) 100%
),
url('/themes/halflife-sidebar.svg');
--color-canvas: #0d1113;
--color-surface: #161c1f;
--color-brand-50: #2a1608;
--color-brand-100: #3a1e0a;
--color-brand-200: #5c3010;
--color-brand-300: #7d4315;
--color-brand-400: #b25f1c;
--color-brand-500: #e0720b;
--color-brand-600: #ff8c1a;
--color-brand-700: #f4bf8a;
--color-brand-800: #fae0c6;
--color-brand-900: #fdf1e5;
--color-brand-accent: #ff8c1a;
--color-ink-50: #191f22;
--color-ink-100: #222a2e;
--color-ink-200: #33403f;
--color-ink-300: #667470;
--color-ink-400: #8b9894;
--color-ink-500: #a6b2ae;
--color-ink-600: #bfc9c5;
--color-ink-700: #d5dcd9;
--color-ink-800: #e8edeb;
--color-ink-900: #f5f8f7;
--color-ink-950: #000000;
--radius-sm: 0.0625rem;
--radius-md: 0.0625rem;
--radius-lg: 0.125rem;
--radius-xl: 0.125rem;
--radius-2xl: 0.1875rem;
--shadow-card: 0 0 0 1px rgb(224 114 11 / 0.1), 2px 2px 0 0 rgb(0 0 0 / 0.5);
--shadow-lift: 4px 4px 0 0 rgb(0 0 0 / 0.6);
--font-display: 'Oswald', 'Montserrat', ui-sans-serif, system-ui, sans-serif;
/* Die getönten Status-Pillen aus .dark ins Kalte gezogen. */
--color-amber-50: #2b1e05;
--color-amber-200: #573d0c;
--color-emerald-50: #0a2620;
--color-emerald-200: #14493c;
}
/* ===========================================================================
* Pixel Art
*
* 8-bit: keine runden Ecken, harte versetzte Schatten ohne Weichzeichnung,
* Rahmen als dunkle Outline, gesättigtes Arcade-Grün.
*
* Die Pixelschrift steckt nur in --font-display (Überschriften und
* Feldbeschriftungen). Der Fließtext bleibt Montserrat: die Tabellen haben bis
* zu acht Spalten bei 12 px, und eine Pixelschrift wäre darin nicht mehr zu
* lesen - ein Design, das die Arbeit behindert, wäre ein schlechtes Design.
* =========================================================================== */
html.theme-pixel:not(.dark) {
--theme-header-image: linear-gradient(
90deg,
rgb(246 248 241 / 0.95) 0%,
rgb(246 248 241 / 0.45) 30%,
rgb(246 248 241 / 0.4) 55%,
rgb(246 248 241 / 0.95) 100%
),
url('/themes/pixel-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(246 248 241 / 0.97) 0%,
rgb(246 248 241 / 0.92) 48%,
rgb(246 248 241 / 0.7) 80%,
rgb(246 248 241 / 0.8) 100%
),
url('/themes/pixel-sidebar.svg');
/* Deutlich dunkler als die Karten: Pixelgrafik lebt von harten Kanten, und
bei zu ähnlichen Tönen verschwimmen Karte und Grund. */
--color-canvas: #cdd8c2;
--color-surface: #f6f8f1;
--color-brand-50: #e0f2d4;
--color-brand-100: #c8e8b4;
/* Rahmen sind hier keine Andeutung, sondern Kontur - deshalb dunkel. */
--color-brand-200: #2b7a1c;
--color-brand-300: #256b18;
--color-brand-400: #35931f;
--color-brand-500: #2e8b22;
--color-brand-600: #24701a;
--color-brand-700: #1b5613;
--color-brand-800: #143f0e;
--color-brand-900: #0e2f0a;
--color-brand-accent: #2e8b22;
--color-ink-50: #eef2e8;
--color-ink-100: #dde4d4;
/* Der Kniff des Designs: ink-200 ist fast ausschließlich Rahmen und wird hier
zur schwarzen Outline - an allen Stellen zugleich, ohne eine Zeile Markup. */
--color-ink-200: #1f2a1a;
--color-ink-300: #5a6653;
--color-ink-400: #47523f;
--color-ink-500: #38412f;
--color-ink-600: #2c3425;
--color-ink-700: #22291c;
--color-ink-800: #182015;
--color-ink-900: #0e140c;
--color-ink-950: #000000;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
/* Versetzt statt weichgezeichnet - so sehen Schatten in Pixelgrafik aus. */
--shadow-card: 4px 4px 0 0 #1f2a1a;
--shadow-lift: 6px 6px 0 0 #1f2a1a;
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
}
/*
* Pixel Art, dunkel: Arcade-Automat im dunklen Raum. Flächen fast schwarz mit
* blauem Stich, Konturen in gedecktem Grün - schwarze Outlines wären auf
* schwarzem Grund unsichtbar.
*/
html.theme-pixel.dark {
--theme-header-image: linear-gradient(
90deg,
rgb(21 26 34 / 0.96) 0%,
rgb(21 26 34 / 0.7) 30%,
rgb(21 26 34 / 0.62) 55%,
rgb(21 26 34 / 0.96) 100%
),
url('/themes/pixel-header.svg');
--theme-sidebar-image: linear-gradient(
180deg,
rgb(21 26 34 / 0.97) 0%,
rgb(21 26 34 / 0.92) 48%,
rgb(21 26 34 / 0.76) 80%,
rgb(21 26 34 / 0.86) 100%
),
url('/themes/pixel-sidebar.svg');
--color-canvas: #0d0f14;
--color-surface: #151a22;
--color-brand-50: #0f2a0c;
--color-brand-100: #163c11;
--color-brand-200: #2c7a1f;
--color-brand-300: #35931f;
--color-brand-400: #3aa524;
--color-brand-500: #3aa524;
--color-brand-600: #49bf30;
--color-brand-700: #7fdc63;
--color-brand-800: #b0ec9c;
--color-brand-900: #d8f7cd;
--color-brand-accent: #49bf30;
--color-ink-50: #1a2029;
--color-ink-100: #232b36;
--color-ink-200: #33734a;
--color-ink-300: #6f7f92;
--color-ink-400: #93a3b5;
--color-ink-500: #adbccb;
--color-ink-600: #c2cfdb;
--color-ink-700: #d6e0ea;
--color-ink-800: #e9eff5;
--color-ink-900: #f6f9fc;
--color-ink-950: #000000;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--shadow-card: 4px 4px 0 0 #000000;
--shadow-lift: 6px 6px 0 0 #000000;
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
}
@layer base {
html,
body {
height: 100%;
}
body {
background-color: var(--color-canvas);
color: var(--color-ink-900);
font-family: var(--font-sans);
font-weight: 400;
-webkit-font-smoothing: antialiased;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.015em;
}
/* Feldbeschriftungen tragen dieselbe Klasse, damit ein spaeteres Design sie
mit einer Regel erreicht. */
.field-label {
font-family: var(--font-display);
}
/* Fokus-Ring durchgaengig im CI-Gold */
:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
/*
* Half-Life: Überschriften und Feldbeschriftungen laufen gesperrt in
* Versalien - so ist Black Mesa beschildert. Die negative Laufweite der
* Grundeinstellung würde Oswald ineinanderschieben.
*/
html.theme-halflife h1,
html.theme-halflife h2,
html.theme-halflife h3,
html.theme-halflife h4,
html.theme-halflife h5,
html.theme-halflife h6 {
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
html.theme-halflife .field-label {
letter-spacing: 0.12em;
}
/*
* Pixelschrift hat eine feste Zeichenbreite und trägt ihre Sperrung selbst;
* die negative Laufweite der Überschriften würde die Buchstaben ineinander
* schieben. Außerdem kein Kantenglätten - Pixel sollen Pixel bleiben.
*/
html.theme-pixel h1,
html.theme-pixel h2,
html.theme-pixel h3,
html.theme-pixel h4,
html.theme-pixel h5,
html.theme-pixel h6,
html.theme-pixel .field-label {
letter-spacing: 0;
-webkit-font-smoothing: none;
}
/*
* Hintergrundbilder der Kopfleiste und des Menüs.
*
* Ohne Design steht in den Variablen nichts, dann greift der Rückfall `none`
* und beide Flächen bleiben einfarbig - ICON sieht also aus wie immer.
* `bg-surface` an den Elementen bleibt: die Farbe liegt unter dem Bild und
* trägt, solange die Grafik lädt oder fehlt.
*/
.app-header {
background-image: var(--theme-header-image, none);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.app-sidebar {
background-image: var(--theme-sidebar-image, none);
background-repeat: no-repeat;
/* Unten verankert: dort sitzt in beiden Grafiken das Motiv. */
background-position: bottom center;
background-size: cover;
}
}
@layer utilities {
/*
* "rounded-full" lässt sich nicht über eine Variable abschalten - Tailwind
* schreibt dort einen festen Wert. Für Pixel Art muss es aber weg: runde
* Punkte und Pillen neben eckigen Karten wären ein Bruch. Der
* Attributselektor greift genau dieses eine Klassenwort und ist mit dem
* vorangestellten html spezifischer als die Utility-Klasse.
*/
html.theme-pixel [class~='rounded-full'] {
border-radius: 0;
}
}
@layer utilities {
/* Das ICON Logo ist fuer helle Hintergruende gemacht - im Dunkelmodus wird
die Helligkeit gedreht und das Gold per hue-rotate zurueckgeholt. */
.dark .logo-adaptive {
filter: invert(1) hue-rotate(180deg);
}
.scrollbar-slim::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-slim::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-slim::-webkit-scrollbar-thumb {
background: var(--color-ink-200);
border-radius: 999px;
}
.scrollbar-slim::-webkit-scrollbar-thumb:hover {
background: var(--color-ink-300);
}
/*
* Der WYSIWYG-Editor und die Leseansicht der Ausschreibungstexte.
*
* Tailwind setzt Ueberschriften und Listen im Basis-Reset zurueck - in einem
* Feld, dessen Inhalt aus <h2>, <ul> und <p> besteht, saehe sonst alles
* gleich aus. Hier bekommen diese Elemente ihre Darstellung zurueck, und nur
* hier.
*/
/*
* Der Altbestand traegt Inline-Farben aus dem frueheren Editor. Sie hier
* auszuhebeln ist kein Schoenheitseingriff: im Dunkelmodus stuende sonst
* schwarze Schrift auf weissen Kaesten mitten auf dunklem Grund.
*
* !important ist noetig, weil Inline-Stile sonst immer gewinnen. Eigene
* Farbwahl gibt es in diesem Editor nicht - es geht also nichts verloren.
*/
.prose-editor [style] {
color: inherit !important;
background-color: transparent !important;
font-family: inherit !important;
font-size: inherit !important;
}
/*
* Lange Woerter und Adressen muessen umbrechen duerfen. Der Altbestand ist
* voller geschuetzter Leerzeichen; bis ein Text einmal gespeichert und dabei
* bereinigt wurde, haelt das hier die Zeilen im Rahmen.
*/
.prose-editor {
overflow-wrap: anywhere;
word-break: break-word;
}
.prose-editor:empty::before {
content: attr(data-placeholder);
color: var(--color-ink-400);
}
.prose-editor h2 {
font-size: 1.05rem;
font-weight: 700;
margin: 0.9em 0 0.3em;
}
.prose-editor h3 {
font-size: 0.95rem;
font-weight: 600;
margin: 0.8em 0 0.25em;
}
.prose-editor p {
margin: 0.4em 0;
}
.prose-editor ul,
.prose-editor ol {
margin: 0.4em 0 0.4em 1.25em;
}
.prose-editor ul {
list-style: disc;
}
.prose-editor ol {
list-style: decimal;
}
.prose-editor li {
margin: 0.15em 0;
}
/* Ein Aufzaehlungspunkt, der nur eine weitere Liste enthaelt, bekommt kein
eigenes Zeichen - sonst stehen zwei nebeneinander. */
.prose-editor li:has(> ul):not(:has(> :not(ul))),
.prose-editor li:has(> ol):not(:has(> :not(ol))) {
list-style: none;
}
.prose-editor strong {
font-weight: 600;
}
.prose-editor > :first-child {
margin-top: 0;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
+415
View File
@@ -0,0 +1,415 @@
import type {
AppUser,
Company,
Connection,
Contact,
DirectoryUser,
Employment,
HistoryEntry,
Interview,
Lead,
LeadTodo,
PerincoContact,
Project,
Tender,
} from '../types';
const TOKEN_KEY = 'icon_crm_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler;
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`/api${path}`, { ...options, headers });
} catch {
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
}
if (response.status === 401) {
clearToken();
onUnauthorized?.();
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const message =
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return payload as T;
}
const get = <T>(path: string) => apiRequest<T>(path);
const post = <T>(path: string, body?: unknown) =>
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
const patch = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
function query(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : '';
}
export const authApi = {
login: (identifier: string, password: string) =>
post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }),
me: () => get<{ user: AppUser }>('/auth/me'),
updateMe: (patchBody: Partial<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
patch<{ user: AppUser }>('/auth/me', patchBody),
changePassword: (current_password: string, new_password: string) =>
post<{ ok: true }>('/auth/change-password', { current_password, new_password }),
};
export const usersApi = {
directory: () => get<{ users: DirectoryUser[] }>('/users/directory'),
list: () => get<{ users: AppUser[] }>('/users'),
create: (body: Record<string, unknown>) => post<{ user: AppUser }>('/users', body),
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
};
export interface CompanyDetail {
company: Company;
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
former: Array<{
employment_id: number;
id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
position: string | null;
started_on: string | null;
ended_on: string | null;
now_company: string | null;
now_company_id: number | null;
}>;
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const companiesApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ companies: Company[] }>(`/companies${query(params)}`),
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ company: Company }>(`/companies/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/companies/${id}/interviews`, body),
};
export interface ContactDetail {
contact: Contact;
employments: Employment[];
interviews: Interview[];
history: HistoryEntry[];
mentioned: HistoryEntry[];
}
export const contactsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ contact: Contact }>(`/contacts/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
addInterview: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/interviews`, body),
/*
* Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines
* Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird
* vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr
* geradeziehen.
*
* `company` (Name) oder `company_id` - ein unbekannter Name legt das
* Unternehmen an, wie im CV-Import auch.
*/
addEmployment: (id: number, body: Record<string, unknown>) =>
post<{ id: number }>(`/contacts/${id}/employments`, body),
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body),
removeEmployment: (employmentId: number) =>
del<{ ok: true }>(`/contacts/employments/${employmentId}`),
lastEmployers: () =>
get<{
entries: Array<{
contact_id: number;
first_name: string | null;
last_name: string;
avatar: string | null;
company_id: number;
company: string;
industry: string | null;
position: string | null;
ended_on: string | null;
}>;
}>('/contacts/last-employers'),
linkedInPhoto: (url: string) =>
post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }),
};
export const tendersApi = {
list: (params: Record<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
detail: (id: number) =>
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
};
export const projectsApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ projects: Project[] }>(`/projects${query(params)}`),
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
update: (id: number, body: Record<string, unknown>) =>
patch<{ project: Project }>(`/projects/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/projects/${id}`),
};
export const leadsApi = {
list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`),
detail: (id: number) =>
get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`),
create: (body: Record<string, unknown>) => post<{ lead: Lead }>('/leads', body),
update: (id: number, body: Record<string, unknown>) => patch<{ lead: Lead }>(`/leads/${id}`, body),
remove: (id: number) => del<{ ok: true }>(`/leads/${id}`),
reminders: () =>
get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'),
acknowledge: (id: number, snooze_days?: number) =>
post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }),
addTodo: (id: number, text: string, due_on?: string | null) =>
post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }),
updateTodo: (todoId: number, body: Record<string, unknown>) =>
patch<{ ok: true }>(`/leads/todos/${todoId}`, body),
removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`),
};
/** Vorschlag der KI zu einem Lebenslauf. */
export interface CvSuggestion {
first_name: string | null;
last_name: string | null;
position: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
company: string | null;
tags: string[];
bio: string | null;
rate_onsite: number | null;
rate_remote: number | null;
employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>;
confidence: number | null;
}
export interface CvImport {
id: number;
filename: string;
original_name: string;
mime: string | null;
size_bytes: number;
status: 'eingang' | 'screening' | 'auswahl' | 'verworfen';
extracted: CvSuggestion | null;
/**
* Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in
* der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im
* Browser verkleinert (siehe lib/imageCompress.ts).
*/
has_photo: boolean;
error: string | null;
contact_id: number | null;
contact_name: string | null;
uploaded_by_name: string | null;
created_at: string;
processed_at: string | null;
}
export const cvsApi = {
list: () =>
get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'),
/** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */
upload: (files: File[]) => {
const body = new FormData();
for (const file of files) body.append('files', file);
return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body });
},
analyze: (id: number) =>
post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`),
/** Das gefundene Foto, unveraendert - nur fuer das Formular. */
photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`),
accept: (id: number, overrides: Record<string, unknown> = {}) =>
post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides),
reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`),
remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`),
};
export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile';
/**
* Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt
* ("sk-proj-…a1b2") - vollständig verlässt er den Server nie.
*/
export interface AiConfig {
configured: boolean;
model: string;
defaultModel: string;
/** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */
source: 'db' | 'env' | 'none';
masked: string | null;
envAvailable: boolean;
}
export const aiApi = {
status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'),
text: (task: AiTask, current: string, prompt?: string) =>
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
config: () => get<AiConfig>('/ai/config'),
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
};
export const perincoApi = {
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
};
export const historyApi = {
list: (params: Record<string, unknown> = {}) =>
get<{ entries: HistoryEntry[] }>(`/history${query(params)}`),
add: (entity_type: string, entity_id: number | null, text: string) =>
post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }),
remove: (id: number) => del<{ ok: true }>(`/history/${id}`),
};
export interface SearchGroup {
type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history';
label: string;
items: Array<Record<string, unknown> & { id: number; title?: string }>;
}
export interface MentionCandidate {
kind: 'user' | 'contact' | 'company';
id: number;
label: string;
hint: string | null;
avatar: string | null;
color?: string | null;
}
export interface AiAnswer {
answer: string;
refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>;
model: string;
truncated: boolean;
}
export const searchApi = {
search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`),
mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`),
ask: (question: string) => post<AiAnswer>('/search/ai', { question }),
};
export interface OptionValue {
id: number;
field: string;
value: string;
position: number;
/** Wie oft der Wert im Bestand verwendet wird. */
used: number;
}
export interface OptionField {
field: string;
label: string;
values: OptionValue[];
}
export const optionsApi = {
list: () => get<{ fields: OptionField[] }>('/options'),
create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }),
rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }),
remove: (id: number) => del<{ ok: true }>(`/options/${id}`),
};
export interface NetworkData {
companies: Array<Company & { id: number }>;
contacts: Array<{
id: number;
name: string;
position: string | null;
avatar: string | null;
kind: string;
status: string;
company_id: number | null;
}>;
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
groups: Array<{ name: string; count: number }>;
}
export const networkApi = {
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
};
export const connectionsApi = {
list: () => get<{ connections: Connection[] }>('/connections'),
create: (contact_a: number, contact_b: number, kind?: string) =>
post<Connection>('/connections', { contact_a, contact_b, kind }),
update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }),
remove: (id: number) => del<{ ok: true }>(`/connections/${id}`),
};
export interface DashboardData {
counts: Record<string, number>;
contacts_by_status: Array<{ status: string; count: number }>;
companies_by_industry: Array<{ industry: string; count: number }>;
tenders_by_stage: Array<{ stage: string; count: number }>;
recent: HistoryEntry[];
}
export const dashboardApi = {
load: () => get<DashboardData>('/dashboard'),
};
+43
View File
@@ -0,0 +1,43 @@
/**
* Whisper erwartet Mono-PCM mit 16 kHz als Float32. Der MediaRecorder liefert
* aber WebM/Opus in der Aufnahmerate der Hardware - hier wird dekodiert und
* heruntergerechnet.
*/
export const WHISPER_SAMPLE_RATE = 16000;
export interface DecodedAudio {
audio: Float32Array;
/** Laenge der Aufnahme in Sekunden. */
seconds: number;
}
export async function toWhisperAudio(blob: Blob): Promise<DecodedAudio> {
const bytes = await blob.arrayBuffer();
const AudioContextClass =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) throw new Error('Dieser Browser kann kein Audio dekodieren.');
const context = new AudioContextClass();
let decoded: AudioBuffer;
try {
decoded = await context.decodeAudioData(bytes);
} finally {
// Nicht geschlossene Kontexte zaehlen gegen das Browserlimit.
await context.close().catch(() => {});
}
if (decoded.duration === 0) throw new Error('Die Aufnahme ist leer.');
// Resampling ueber einen OfflineAudioContext - das erledigt gleich das
// Zusammenfassen mehrerer Kanaele auf Mono.
const frames = Math.max(1, Math.ceil(decoded.duration * WHISPER_SAMPLE_RATE));
const offline = new OfflineAudioContext(1, frames, WHISPER_SAMPLE_RATE);
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
const resampled = await offline.startRendering();
return { audio: resampled.getChannelData(0), seconds: decoded.duration };
}
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { AppUser } from '../types';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
interface AuthContextValue {
user: AppUser | null;
isLoading: boolean;
isAdmin: boolean;
login: (identifier: string, password: string) => Promise<void>;
logout: () => void;
/** Aktualisiert den lokalen Benutzer, z. B. nach Bearbeiten des eigenen Kontos. */
updateUser: (user: AppUser) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);
// Ein 401 aus irgendeinem Aufruf wirft sauber zurueck auf den Login, statt
// eine leere Ansicht stehen zu lassen.
useEffect(() => {
setUnauthorizedHandler(() => setUser(null));
return () => setUnauthorizedHandler(null);
}, []);
// Beim Start pruefen, ob das gespeicherte Token noch gilt.
useEffect(() => {
let cancelled = false;
const restore = async () => {
if (!getToken()) {
setIsLoading(false);
return;
}
try {
const { user: current } = await authApi.me();
if (!cancelled) setUser(current);
} catch {
clearToken();
} finally {
if (!cancelled) setIsLoading(false);
}
};
void restore();
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (identifier: string, password: string) => {
const { token, user: loggedIn } = await authApi.login(identifier, password);
setToken(token);
setUser(loggedIn);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
isAdmin: user?.role === 'admin',
login,
logout,
updateUser: setUser,
}),
[user, isLoading, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
return ctx;
}
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWhisper } from './whisper';
/**
* Aufnahme fuer das Diktat. Die Erkennung selbst macht der Whisper-Worker im
* Browser (siehe whisper.tsx) - hier geht es nur um Mikrofon, Dauer und das
* Weiterreichen der Aufnahme.
*/
/** Erstes Format, das der Browser aufnehmen kann. */
function pickMimeType(): string {
const candidates = ['audio/webm', 'audio/ogg', 'audio/mp4'];
for (const type of candidates) {
if (MediaRecorder.isTypeSupported?.(type)) return type;
}
return '';
}
type RecorderState = 'idle' | 'recording' | 'transcribing';
interface UseRecorderResult {
state: RecorderState;
/** Laufende Aufnahmedauer in Sekunden. */
seconds: number;
start: () => Promise<void>;
stop: () => void;
cancel: () => void;
}
export function useRecorder(
onText: (text: string) => void,
onError: (message: string) => void
): UseRecorderResult {
const { transcribe, status } = useWhisper();
const [state, setState] = useState<RecorderState>('idle');
const [seconds, setSeconds] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const discardRef = useRef(false);
const cleanup = useCallback(() => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
// Mikrofon freigeben, sonst bleibt die Aufnahmeanzeige des Browsers stehen.
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setSeconds(0);
}, []);
useEffect(() => cleanup, [cleanup]);
const start = useCallback(async () => {
if (state !== 'idle') return;
if (status !== 'ready') {
onError('Das Spracherkennungsmodell ist noch nicht geladen.');
return;
}
discardRef.current = false;
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
const name = (err as DOMException)?.name;
onError(
name === 'NotAllowedError'
? 'Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.'
: name === 'NotFoundError'
? 'Es wurde kein Mikrofon gefunden.'
: 'Die Aufnahme konnte nicht gestartet werden.'
);
return;
}
const mimeType = pickMimeType();
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
} catch {
stream.getTracks().forEach((track) => track.stop());
onError('Dieser Browser kann kein passendes Audioformat aufnehmen.');
return;
}
streamRef.current = stream;
recorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
cleanup();
if (discardRef.current) {
setState('idle');
return;
}
if (blob.size < 1024) {
setState('idle');
onError('Die Aufnahme war zu kurz.');
return;
}
setState('transcribing');
try {
const { text } = await transcribe(blob);
if (text) onText(text);
else onError('Es war nichts Verständliches zu hören.');
} catch (err) {
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
} finally {
setState('idle');
}
};
recorder.start();
setState('recording');
setSeconds(0);
timerRef.current = window.setInterval(() => setSeconds((value) => value + 1), 1000);
}, [state, status, cleanup, onError, onText, transcribe]);
const stop = useCallback(() => {
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
}, []);
const cancel = useCallback(() => {
discardRef.current = true;
stop();
}, [stop]);
return { state, seconds, start, stop, cancel };
}
+113
View File
@@ -0,0 +1,113 @@
/*
* Bilder im Browser verkleinern, bevor sie hochgeladen werden.
*
* Fotos und Logos liegen als Data-URL in der Datenbank. Ohne diesen Schritt
* landet ein 3-MB-Foto aus dem Telefon unveraendert in einer Textspalte und
* geht bei jedem Laden der Liste erneut ueber die Leitung.
*
* 400 px ist bewusst mehr als jede Ansicht braucht (die groesste zeigt 96 px):
* die Featureliste nennt diese Groesse fuer den CV-Import, und ein spaeterer
* Ausbau soll das Bild nicht erneut skalieren muessen.
*/
const TARGET_SIZE = 400;
const QUALITY = 0.82;
/** Grenze fuer die Quelldatei - komprimiert wird sie ohnehin. */
export const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
/**
* Obergrenze fuer das Ergebnis.
*
* 400 px WebP liegt in der Praxis bei 20-60 kB, die Grenze greift also nie -
* sie ist die Zusicherung, nicht die Regel. Ein Foto aus einem Lebenslauf kann
* ein Scan in Druckaufloesung sein; ohne Grenze laege so etwas ungeprueft in
* einer Textspalte.
*/
export const MAX_RESULT_BYTES = 512 * 1024;
/** Groesse einer Data-URL in Byte - Base64 traegt 3 Byte je 4 Zeichen. */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
return Math.floor((base64.length * 3) / 4) - padding;
}
export async function compressImageFile(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('Die Datei ist kein Bild.');
}
if (file.size > MAX_SOURCE_BYTES) {
throw new Error('Das Bild ist zu groß (max. 25 MB).');
}
return compressImageSource(await readAsDataUrl(file));
}
/**
* Verkleinert eine Data- oder http-Adresse auf ein Quadrat von 400 px und
* hoechstens {@link MAX_RESULT_BYTES}.
*
* Bleibt das Ergebnis ueber der Grenze - ein sehr unruhiges Bild kann das -,
* wird die Kantenlaenge schrittweise verringert, bis es passt. Lieber ein
* kleineres Foto als eine Zeile, die jede Liste ausbremst.
*/
export async function compressImageSource(source: string): Promise<string> {
const image = await loadImage(source);
// Mittiger Ausschnitt: ein Portrait wird sonst verzerrt, und der Kopf sitzt
// bei Profilfotos praktisch immer in der Mitte.
const side = Math.min(image.naturalWidth, image.naturalHeight);
const sx = (image.naturalWidth - side) / 2;
const sy = (image.naturalHeight - side) / 2;
let size = Math.min(TARGET_SIZE, side);
let last = '';
// Hoechstens fuenf Anlaeufe: 400 -> 320 -> 256 -> 204 -> 163 px.
for (let attempt = 0; attempt < 5; attempt += 1) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) throw new Error('Das Bild konnte nicht verarbeitet werden.');
context.imageSmoothingQuality = 'high';
context.drawImage(image, sx, sy, side, side, 0, 0, size, size);
// WebP zuerst, dann JPEG, dann PNG: aeltere Browser liefern bei einem nicht
// unterstuetzten Typ stillschweigend PNG zurueck - das faellt hier auf.
let dataUrl = '';
for (const type of ['image/webp', 'image/jpeg', 'image/png']) {
const candidate = canvas.toDataURL(type, QUALITY);
if (candidate.startsWith(`data:${type}`)) {
dataUrl = candidate;
break;
}
}
if (!dataUrl) throw new Error('Das Bild konnte nicht umgewandelt werden.');
last = dataUrl;
if (dataUrlBytes(dataUrl) <= MAX_RESULT_BYTES) return dataUrl;
size = Math.round(size * 0.8);
}
return last;
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('Die Datei konnte nicht gelesen werden.'));
reader.readAsDataURL(file);
});
}
function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
image.src = source;
});
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useState, type DragEvent } from 'react';
/*
* Ziehen und Fallenlassen auf den Kanban-Brettern (Leads, Ausschreibungen).
*
* Die Gesten stecken hier und nicht in den Ansichten, weil sie dreimal an
* denselben Stellen schiefgehen:
*
* 1. **Ohne Nutzlast keine Geste.** Firefox startet eine Ziehgeste nur, wenn
* `dragstart` etwas in `dataTransfer` legt. Chrome ist da nachsichtiger -
* deshalb faellt es beim Entwickeln nicht auf und beim Benutzen sofort.
* 2. **Der Zustand allein reicht nicht.** Die Kennung wird zusaetzlich in
* `dataTransfer` mitgegeben. Sie ueberlebt damit auch einen Neuaufbau der
* Liste waehrend der Geste.
* 3. **Ohne `dropEffect` zeigt der Zeiger ein Verbotsschild**, obwohl das
* Fallenlassen erlaubt ist.
*
* Bilder in den Kacheln (Logo, Foto) muessen zusaetzlich `draggable={false}`
* tragen - sonst zieht der Browser das Bild statt der Kachel. Das erledigt die
* Avatar-Komponente.
*/
/** Eigenes Format neben `text/plain`: erkennt eigene Kacheln von fremdem Text. */
const MIME = 'application/x-icon-card';
interface KanbanDrag<K extends string> {
/** Kennung der Kachel am Zeiger - fuer die gedaempfte Darstellung. */
draggedId: number | null;
/** Spalte unter dem Zeiger - fuer die Hervorhebung des Ziels. */
hoverColumn: K | null;
/** Auf die Kachel legen. */
cardProps: (id: number) => {
draggable: true;
onDragStart: (event: DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
};
/** Auf die Spalte legen. */
columnProps: (column: K) => {
onDragOver: (event: DragEvent<HTMLElement>) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
onDrop: (event: DragEvent<HTMLElement>) => void;
};
}
export function useKanbanDrag<K extends string>(
onMove: (id: number, column: K) => void
): KanbanDrag<K> {
const [draggedId, setDraggedId] = useState<number | null>(null);
const [hoverColumn, setHoverColumn] = useState<K | null>(null);
const cardProps = useCallback(
(id: number) =>
({
draggable: true,
onDragStart: (event: DragEvent<HTMLElement>) => {
event.dataTransfer.setData(MIME, String(id));
event.dataTransfer.setData('text/plain', String(id));
event.dataTransfer.effectAllowed = 'move';
setDraggedId(id);
},
onDragEnd: () => {
setDraggedId(null);
setHoverColumn(null);
},
}) as const,
[]
);
const columnProps = useCallback(
(column: K) => ({
onDragOver: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setHoverColumn((current) => (current === column ? current : column));
},
onDragLeave: (event: DragEvent<HTMLElement>) => {
// Der Zeiger wandert staendig ueber Kindelemente - nur das Verlassen
// der Spalte selbst zaehlt.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setHoverColumn((current) => (current === column ? null : current));
},
onDrop: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
const carried = event.dataTransfer.getData(MIME) || event.dataTransfer.getData('text/plain');
const id = draggedId ?? Number(carried);
setDraggedId(null);
setHoverColumn(null);
if (Number.isInteger(id) && id > 0) onMove(id, column);
},
}),
[draggedId, onMove]
);
return { draggedId, hoverColumn, cardProps, columnProps };
}
/** Rahmen der Spalte, solange eine Kachel darueber schwebt. */
export function columnClass(isHover: boolean): string {
return isHover ? 'border-brand-400 bg-brand-50/40' : 'border-ink-200 bg-canvas';
}
/** Die Kachel am Zeiger wird gedaempft - sonst sieht man nicht, was man zieht. */
export function cardClass(isDragged: boolean): string {
return isDragged ? 'opacity-40' : '';
}
+319
View File
@@ -0,0 +1,319 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { searchApi, type MentionCandidate } from './api';
import { useNavigation } from './navigation';
import { initials } from '../types';
/*
* @-Erwaehnungen.
*
* Gespeichert wird ein Token: `@[Anzeigename](contact:12)`, ebenso fuer
* `company` und `user`. Der Name steht mit im Token, damit ein Text auch dann
* lesbar bleibt, wenn die Listen nicht geladen sind.
*
* Jede Erwaehnung ist anklickbar und oeffnet das zugehoerige Profil - und sie
* landet ueber den Index in icon_mentions in der Historie des Genannten.
*/
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g;
export type MentionKind = 'contact' | 'company' | 'user';
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;
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 und Kurzfassungen. */
export function mentionPlainText(text: string): string {
return parseMentions(text)
.map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`))
.join('');
}
/** Enthält der Text mindestens eine Erwähnung? */
export function hasMentions(text: string | null | undefined): boolean {
if (!text) return false;
MENTION_TOKEN.lastIndex = 0;
return MENTION_TOKEN.test(text);
}
export function mentionToken(candidate: MentionCandidate): string {
// Klammern im Namen wuerden das Token zerlegen.
const label = candidate.label.replace(/[[\]()]/g, '').trim() || 'Unbenannt';
return `@[${label}](${candidate.kind}:${candidate.id})`;
}
/** Rendert einen Text mit klickbaren Erwaehnungen. */
export const MentionText: React.FC<{ text: string | null | undefined; className?: string }> = ({
text,
className,
}) => {
const { open } = useNavigation();
if (!text) return null;
return (
<span className={className}>
{parseMentions(text).map((segment, index) =>
segment.type === 'text' ? (
<React.Fragment key={index}>{segment.value}</React.Fragment>
) : (
<button
key={index}
type="button"
onClick={(event) => {
event.stopPropagation();
if (segment.kind === 'user') return;
open(segment.kind, segment.id);
}}
className={
segment.kind === 'user'
? 'mx-0.5 rounded bg-ink-100 px-1 font-medium text-ink-700'
: 'mx-0.5 rounded bg-brand-100 px-1 font-medium text-brand-700 hover:bg-brand-200'
}
title={segment.kind === 'company' ? 'Unternehmen öffnen' : 'Profil öffnen'}
>
@{segment.label}
</button>
)
)}
</span>
);
};
interface MentionFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
className?: string;
id?: string;
}
/**
* Textfeld mit Vorschlagsliste: ein `@` oeffnet sie, die Auswahl setzt den
* Token an der Schreibmarke ein.
*/
export const MentionField: React.FC<MentionFieldProps> = ({
value,
onChange,
placeholder,
rows = 4,
className = '',
id,
}) => {
/*
* Zwei Zustaende.
*
* lesen der Text, wie er gemeint ist - Erwaehnungen sind Knoepfe und
* fuehren auf das Profil
* bearbeiten der Rohtext mit dem Token `@[Name](contact:12)`, denn anders
* laesst sich eine Erwaehnung nicht setzen oder loeschen
*
* Vorher stand beides untereinander: oben der Rohtext, darunter dieselbe
* Aussage noch einmal als Vorschau. Das war doppelt und niemand hat die
* Vorschau als das Klickbare erkannt.
*
* Ein leeres Feld startet gleich im Bearbeiten-Zustand - sonst muesste man
* erst einen Knopf finden, um ueberhaupt etwas schreiben zu koennen.
*/
const [editing, setEditing] = useState(() => !value.trim());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [candidates, setCandidates] = useState<MentionCandidate[]>([]);
const [queryText, setQueryText] = useState<string | null>(null);
const [active, setActive] = useState(0);
// Steht die Schreibmarke hinter einem @ samt angefangenem Wort, ist das die
// Suche. Ein Leerzeichen im Wort beendet sie - sonst stuende die Liste bei
// jedem Satz offen, der ein @ enthaelt.
const detectQuery = (text: string, caret: number): string | null => {
const upToCaret = text.slice(0, caret);
const match = /@([^\s@[\]()]{0,40})$/.exec(upToCaret);
return match ? match[1] : null;
};
useEffect(() => {
if (queryText === null) {
setCandidates([]);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { candidates: found } = await searchApi.mentions(queryText);
if (!cancelled) {
setCandidates(found.slice(0, 8));
setActive(0);
}
} catch {
if (!cancelled) setCandidates([]);
}
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [queryText]);
const insert = (candidate: MentionCandidate) => {
const field = textareaRef.current;
if (!field) return;
const caret = field.selectionStart ?? value.length;
const before = value.slice(0, caret).replace(/@([^\s@[\]()]{0,40})$/, '');
const after = value.slice(caret);
const token = `${mentionToken(candidate)} `;
onChange(`${before}${token}${after}`);
setQueryText(null);
// Die Schreibmarke hinter den eingefuegten Token setzen - sonst springt
// sie ans Ende und der Satz wird von hinten weitergeschrieben.
requestAnimationFrame(() => {
const position = before.length + token.length;
field.focus();
field.setSelectionRange(position, position);
});
};
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (queryText === null || candidates.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive((index) => (index + 1) % candidates.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive((index) => (index - 1 + candidates.length) % candidates.length);
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
insert(candidates[active]);
} else if (event.key === 'Escape') {
event.preventDefault();
setQueryText(null);
}
};
const hint = useMemo(
() => 'Mit @ lassen sich Kontakte, Unternehmen und Kollegen nennen.',
[]
);
if (!editing) {
return (
<div className="rounded-lg border border-ink-200 bg-surface">
<div className="flex items-start gap-2 px-3 py-2">
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm text-ink-800">
{value.trim() ? (
<MentionText text={value} />
) : (
<span className="text-ink-400">{placeholder ?? 'Kein Text'}</span>
)}
</p>
<button
type="button"
onClick={() => {
setEditing(true);
// Nach dem Wechsel steht das Feld erst im naechsten Frame.
requestAnimationFrame(() => textareaRef.current?.focus());
}}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-ink-500 hover:bg-ink-100 hover:text-ink-800"
>
Bearbeiten
</button>
</div>
{hasMentions(value) && (
<p className="border-t border-ink-100 px-3 py-1.5 text-xs text-ink-400">
Erwähnungen sind anklickbar und öffnen das Profil.
</p>
)}
</div>
);
}
return (
<div className="relative">
<textarea
id={id}
ref={textareaRef}
value={value}
rows={rows}
placeholder={placeholder}
onChange={(event) => {
onChange(event.target.value);
setQueryText(detectQuery(event.target.value, event.target.selectionStart ?? 0));
}}
onKeyDown={onKeyDown}
onBlur={() => {
// Verzoegert, damit ein Klick auf einen Vorschlag noch ankommt.
setTimeout(() => setQueryText(null), 150);
}}
className={`w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900 placeholder:text-ink-400 ${className}`}
/>
<div className="mt-1 flex items-start gap-2">
<p className="min-w-0 flex-1 text-xs text-ink-400">{hint}</p>
<button
type="button"
onClick={() => setEditing(false)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-50"
>
Fertig
</button>
</div>
{queryText !== null && candidates.length > 0 && (
<ul className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-ink-200 bg-surface shadow-lift">
{candidates.map((candidate, index) => (
<li key={`${candidate.kind}-${candidate.id}`}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => insert(candidate)}
onMouseEnter={() => setActive(index)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm ${
index === active ? 'bg-brand-50' : ''
}`}
>
{candidate.avatar ? (
<img src={candidate.avatar} alt="" className="h-6 w-6 rounded-full object-cover" />
) : (
<span
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: candidate.color || '#93939b' }}
>
{initials(candidate.label)}
</span>
)}
<span className="font-medium text-ink-900">{candidate.label}</span>
<span className="ml-auto text-xs text-ink-400">
{candidate.kind === 'user'
? 'Benutzer'
: candidate.kind === 'company'
? 'Unternehmen'
: candidate.hint || 'Kontakt'}
</span>
</button>
</li>
))}
</ul>
)}
</div>
);
};
+43
View File
@@ -0,0 +1,43 @@
import React, { createContext, useContext } from 'react';
import type { View } from '../types';
/*
* Ansichtswechsel fuer tief liegende Komponenten.
*
* Eine angeklickte @-Erwaehnung oder ein Treffer der globalen Suche muss die
* passende Karte oeffnen koennen, ohne dass jede Ebene dazwischen einen
* Rueckruf durchreicht.
*/
export type OpenTarget = 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user';
/** Reiter der Einstellungen - auch von aussen ansteuerbar. */
export type SettingsTab = 'konto' | 'benutzer' | 'stammdaten' | 'ki' | 'diktat';
interface NavigationValue {
setView: (view: View) => void;
/** Oeffnet die Karte eines Datensatzes - wechselt dabei die Ansicht. */
open: (type: OpenTarget, id: number) => void;
/**
* Wechselt in die Einstellungen und oeffnet dort einen bestimmten Reiter.
*
* Gebraucht fuer das Diktat: der Knopf im Formular meldet, dass das
* Sprachmodell fehlt, und muss direkt dorthin fuehren koennen, wo es geladen
* wird - ein blosser Hinweis "steht in den Einstellungen" waere eine
* Suchaufgabe.
*/
goToSettings: (tab: SettingsTab) => void;
}
const NavigationContext = createContext<NavigationValue | null>(null);
export const NavigationProvider: React.FC<{ value: NavigationValue; children: React.ReactNode }> = ({
value,
children,
}) => <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
export function useNavigation(): NavigationValue {
const ctx = useContext(NavigationContext);
if (!ctx) throw new Error('useNavigation muss innerhalb von <NavigationProvider> verwendet werden.');
return ctx;
}
+62
View File
@@ -0,0 +1,62 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { optionsApi } from './api';
import { useAuth } from './auth';
import { DEFAULT_CONTACT_KINDS } from '../types';
/*
* Die gepflegten Auswahllisten (Einstellungen → Stammdaten).
*
* Sie werden einmal nach der Anmeldung geladen und von jedem Formular
* mitbenutzt - Branche und Kontaktart tauchen an einem halben Dutzend Stellen
* auf, und jede davon einzeln laden zu lassen hieße, dieselbe Anfrage
* mehrfach zu stellen.
*
* Solange nichts geladen ist, gelten die Vorgabewerte: ein leeres Auswahlfeld
* wäre schlimmer als eine unvollständige Liste.
*/
interface OptionsValue {
industries: string[];
contactKinds: string[];
reload: () => Promise<void>;
}
const OptionsContext = createContext<OptionsValue | null>(null);
export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user } = useAuth();
const [industries, setIndustries] = useState<string[]>([]);
const [contactKinds, setContactKinds] = useState<string[]>(DEFAULT_CONTACT_KINDS);
const reload = useCallback(async () => {
if (!user) return;
try {
const { fields } = await optionsApi.list();
const values = (field: string) =>
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
setIndustries(values('industry'));
const kinds = values('contact_kind');
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
} catch {
/* Ohne Liste gelten die Vorgabewerte - kein Grund, die Anwendung zu stoppen. */
}
}, [user]);
useEffect(() => {
void reload();
}, [reload]);
const value = useMemo<OptionsValue>(
() => ({ industries, contactKinds, reload }),
[industries, contactKinds, reload]
);
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
};
export function useOptions(): OptionsValue {
const ctx = useContext(OptionsContext);
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
return ctx;
}
+115
View File
@@ -0,0 +1,115 @@
/*
* Hilfen für die WYSIWYG-Felder.
*
* Der Bestand ist gemischt: manche Texte sind reiner Text mit Zeilenumbrüchen
* (aus dem alten CRM), manche sind HTML (aus dem Ausschreibungstext kopiert),
* und manche sind beides - ein paar Kopfzeilen als Text, darunter ein HTML-Block.
*
* Ohne Umwandlung zeigt ein WYSIWYG-Feld den reinen Text als einen einzigen
* Absatz und - schlimmer - HTML-Quelltext als sichtbare spitze Klammern.
*/
/** Enthält der Text bereits Auszeichnung? */
function hasTags(text: string): boolean {
return /<[a-z][\s\S]*>/i.test(text);
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Reinen Text in Absätze umwandeln; Leerzeilen trennen, einfache Umbrüche bleiben. */
function paragraphs(text: string): string {
return text
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
.join('');
}
/*
* Der Altbestand kommt aus einem anderen Editor (Quill) und bringt drei Dinge
* mit, die hier stoeren:
*
* 1. Inline-Farben - `background-color: rgb(255,255,255); color: rgb(34,34,34)`
* an fast jedem Textstueck. Im hellen Modus faellt das nicht auf, im
* dunklen wird daraus schwarze Schrift auf weissen Kaesten.
* 2. Geschuetzte Leerzeichen zwischen JEDEM Wort (bis zu 470 je Text). Sie
* verbieten den Zeilenumbruch - der Text laeuft waagerecht aus dem Bild.
* 3. Verschachtelte Listen `<li><ul>…` ohne eigenen Text im aeusseren
* Punkt - daraus werden doppelte Aufzaehlungszeichen.
*
* Bereinigt wird bewusst zurueckhaltend: nur Farbe und Hintergrund fliegen aus
* den Stilangaben, Fett und Kursiv bleiben. Dieselbe Aufraeumarbeit macht die
* ICON Website beim Anzeigen ohnehin schon.
*/
function sanitize(html: string): string {
if (typeof DOMParser === 'undefined') return html;
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const element of Array.from(doc.body.querySelectorAll<HTMLElement>('[style]'))) {
element.style.removeProperty('color');
element.style.removeProperty('background-color');
element.style.removeProperty('background');
element.style.removeProperty('font-family');
element.style.removeProperty('font-size');
if (!element.getAttribute('style')?.trim()) element.removeAttribute('style');
}
// Verschachtelte Liste, deren aeusserer Punkt nichts enthaelt: der Punkt
// faellt weg, die innere Liste rueckt an seine Stelle.
for (const nested of Array.from(doc.body.querySelectorAll('li > ul, li > ol'))) {
const parent = nested.parentElement;
if (!parent) continue;
const hasOwnText = Array.from(parent.childNodes).some(
(node) => node !== nested && (node.textContent ?? '').replace(/\u00a0/g, ' ').trim().length > 0
);
if (!hasOwnText) parent.replaceWith(...Array.from(nested.childNodes));
}
// Geschuetzte Leerzeichen zu normalen machen - sonst bricht keine Zeile um.
return doc.body.innerHTML.replace(/\u00a0/g, ' ').replace(/&nbsp;/g, ' ');
} catch {
return html;
}
}
/**
* Bringt einen gespeicherten Text in eine Form, die der Editor darstellen kann.
*
* Drei Fälle:
* * reiner Text → Absätze
* * reines HTML → unverändert
* * Text, dann HTML → der Kopf wird zu Absätzen, der Rest bleibt
*
* Der dritte Fall ist kein Sonderfall, sondern der Normalfall bei den
* Projekten: dort steht ein kurzer Kopf (KUNDE, KONTAKT, START) und darunter
* der übernommene Ausschreibungstext.
*/
export function toEditorHtml(value: string | null | undefined): string {
const text = String(value ?? '');
if (!text.trim()) return '';
if (!hasTags(text)) return paragraphs(text);
const firstTag = text.search(/<[a-z]/i);
if (firstTag <= 0) return sanitize(text);
const head = text.slice(0, firstTag).trim();
const rest = sanitize(text.slice(firstTag));
return head ? `${paragraphs(head)}${rest}` : rest;
}
/** Reiner Text aus HTML - für Vorschauen und um zu prüfen, ob etwas dasteht. */
export function stripHtml(html: string): string {
return String(html ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();
}
+164
View File
@@ -0,0 +1,164 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { authApi } from './api';
import { useAuth } from './auth';
import { THEME_PALETTES, type ThemePalette } from '../types';
/*
* Modus (hell/dunkel) und Design.
*
* Die Wahl liegt doppelt: im localStorage, damit das Inline-Skript in
* index.html sie vor dem ersten Frame setzen kann, und am Benutzer in der
* Datenbank, damit sie auf jedem Geraet gilt. Ohne Wahl folgt der Modus der
* Systemeinstellung.
*/
export type ThemeName = ThemePalette;
export type ThemeMode = 'light' | 'dark';
const MODE_KEY = 'icon_crm_theme_mode';
const THEME_KEY = 'icon_crm_theme';
interface ThemeContextValue {
theme: ThemeName;
mode: ThemeMode;
/** true, solange der Benutzer nichts gewaehlt hat und das System entscheidet. */
followsSystem: boolean;
setTheme: (theme: ThemeName) => void;
setMode: (mode: ThemeMode) => void;
toggleMode: () => void;
/** Zurueck auf "folgt dem System". */
resetMode: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function systemPrefersDark(): boolean {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
function readStored<T extends string>(key: string): T | null {
try {
return (localStorage.getItem(key) as T | null) ?? null;
} catch {
return null;
}
}
function store(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
/* Privater Modus oder gesperrte Website-Daten - dann gilt nur die Sitzung. */
}
}
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, updateUser } = useAuth();
const [storedMode, setStoredMode] = useState<ThemeMode | null>(() => readStored<ThemeMode>(MODE_KEY));
const [theme, setThemeState] = useState<ThemeName>(() => {
// Ein Wert aus einer früheren Fassung (etwa ein entferntes Design) darf
// nicht dazu führen, dass eine Klasse gesetzt wird, die es nicht gibt.
const stored = readStored<ThemeName>(THEME_KEY);
return stored && THEME_PALETTES.includes(stored) ? stored : 'icon';
});
const [systemDark, setSystemDark] = useState(systemPrefersDark);
// Die Wahl des angemeldeten Benutzers gewinnt gegen den lokalen Speicher:
// sie gilt auf jedem Geraet.
useEffect(() => {
if (!user) return;
if (user.theme_mode && user.theme_mode !== storedMode) {
setStoredMode(user.theme_mode);
store(MODE_KEY, user.theme_mode);
}
/*
* Auch der Wert vom Server wird geprüft. Ein Design, das aus der Liste
* geflogen ist, steht weiter am Konto - und hat die Anwendung schon einmal
* beim Start abstürzen lassen (14.09.2026, "duke").
*/
if (user.theme && THEME_PALETTES.includes(user.theme as ThemeName) && user.theme !== theme) {
setThemeState(user.theme as ThemeName);
store(THEME_KEY, user.theme);
}
// Absichtlich nur an user gebunden - sonst schriebe jede lokale Aenderung
// sich selbst wieder zurueck.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.theme, user?.theme_mode]);
useEffect(() => {
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
if (!media) return;
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, []);
const mode: ThemeMode = storedMode ?? (systemDark ? 'dark' : 'light');
useEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', mode === 'dark');
/*
* Jede theme-* Klasse abräumen, nicht nur die bekannten: das Inline-Skript
* in index.html setzt sie aus dem localStorage, und dort kann ein längst
* entfernter Name stehen. Der bliebe sonst für immer am <html> hängen.
*/
for (const name of [...root.classList]) {
if (name.startsWith('theme-')) root.classList.remove(name);
}
if (theme !== 'icon') root.classList.add(`theme-${theme}`);
}, [mode, theme]);
const persist = useCallback(
async (body: { theme?: ThemeName; theme_mode?: ThemeMode | null }) => {
if (!user) return;
try {
const { user: updated } = await authApi.updateMe(body);
updateUser(updated);
} catch {
/* Die Darstellung steht lokal schon richtig - ein Fehler beim Sichern
darf den Wechsel nicht zurueckdrehen. */
}
},
[user, updateUser]
);
const setMode = useCallback(
(next: ThemeMode) => {
setStoredMode(next);
store(MODE_KEY, next);
void persist({ theme_mode: next });
},
[persist]
);
const value = useMemo<ThemeContextValue>(
() => ({
theme,
mode,
followsSystem: storedMode === null,
setTheme: (next: ThemeName) => {
setThemeState(next);
store(THEME_KEY, next);
void persist({ theme: next });
},
setMode,
toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'),
resetMode: () => {
setStoredMode(null);
store(MODE_KEY, null);
void persist({ theme_mode: null });
},
}),
[theme, mode, storedMode, setMode, persist]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
return ctx;
}
+477
View File
@@ -0,0 +1,477 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { toWhisperAudio } from './audio';
import type { WorkerRequest, WorkerResponse } from './whisper.worker';
/**
* Diktat laeuft vollstaendig im Browser: Whisper als ONNX-Modell ueber
* transformers.js, gerechnet auf der GPU (WebGPU) oder ersatzweise auf der CPU
* (WASM). Der Server ist daran nicht beteiligt - Aufnahmen verlassen das Geraet
* nicht.
*
* Es gibt bewusst nichts einzustellen:
*
* large-v3-turbo - beste Erkennung im Deutschen, auch bei Fachbegriffen
* ("S4 HANA"). small verliert genau dort, tiny ist unbrauchbar.
* q4 - andere Genauigkeiten scheitern: q8 liefert auf der
* Grafikkarte Unsinn, fp16 blockiert den Browser-Tab, fp32
* passt nicht in einen Speicherblock.
* automatisch - Grafikkarte wenn vorhanden, sonst CPU
*
* Die Belege dazu stehen im README. Wer daran drehen will, aendert diese
* Konstanten - und prueft die Kombination vorher mit einer echten Aufnahme.
*/
export const WHISPER_MODEL = 'onnx-community/whisper-large-v3-turbo';
export const WHISPER_DTYPE = 'q4' as const;
export const WHISPER_LANGUAGE = 'de';
/**
* WebGPU steht noch nicht in den TypeScript-Standardtypen. Statt eines eigenen
* Typpakets hier nur das, was wirklich gebraucht wird.
*/
interface GpuAdapterLike {
info?: Record<string, string>;
limits?: Record<string, number>;
}
interface GpuLike {
requestAdapter(): Promise<GpuAdapterLike | null>;
}
function getGpu(): GpuLike | null {
if (typeof navigator === 'undefined') return null;
return (navigator as unknown as { gpu?: GpuLike }).gpu ?? null;
}
/** Cache, in dem transformers.js die Modelldateien ablegt. */
const MODEL_CACHE = 'transformers-cache';
/** Woran man im Cache erkennt, dass die Modelldateien schon vorliegen. */
const MODEL_FILE_PATTERN = /encoder_model[^/]*q4[^/]*\.onnx$/i;
/**
* Bittet den Browser, den Speicher dauerhaft zu behandeln.
*
* Die Modelldateien liegen im Cache-Speicher der Seite (nicht in Cookies - dort
* passen nur wenige Kilobyte). Der ueberlebt Sitzungen ohnehin, gilt ohne diese
* Bitte aber als "best effort": bei Platznot auf dem Geraet darf der Browser ihn
* verwerfen. Mit erteilter Erlaubnis raeumt er ihn nur noch auf ausdruecklichen
* Wunsch des Benutzers weg.
*
* Chrome entscheidet das nach eigenen Kriterien (u. a. wie oft die Seite genutzt
* wird) und kann ablehnen. Dann funktioniert alles genauso - nur ohne diese
* zusaetzliche Zusicherung.
*/
async function requestPersistentStorage(): Promise<boolean> {
try {
if (!navigator.storage?.persist) return false;
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
}
/**
* Liegt das Modell schon im Browser? Wird direkt am Cache geprueft und nicht
* selbst mitgeschrieben - so wird auch erkannt, was in einer anderen Sitzung
* geladen wurde.
*/
async function detectCached(): Promise<boolean> {
try {
if (typeof caches === 'undefined') return false;
const cache = await caches.open(MODEL_CACHE);
const keys = await cache.keys();
return keys.some(
(request) => request.url.includes(`${WHISPER_MODEL}/`) && MODEL_FILE_PATTERN.test(request.url)
);
} catch {
// Cache API gesperrt (privater Modus) - dann eben ohne Erkennung.
return false;
}
}
/**
* Ohne Rueckmeldung so lange gewartet? Dann ist etwas haengen geblieben. Das
* Anlegen der ONNX-Sitzung meldet keinen Fortschritt, darf also dauern - aber
* nicht endlos.
*/
const STALL_TIMEOUT_MS = 120_000;
/**
* Grenze fuer eine einzelne Erkennung, abhaengig von der Aufnahmelaenge: auf
* der CPU ist ein Vielfaches der Aufnahmedauer normal, endlos aber nie.
*/
function transcribeTimeoutMs(seconds: number): number {
return Math.min(300_000, Math.max(45_000, Math.round(seconds * 15_000)));
}
export type WhisperStatus = 'idle' | 'loading' | 'ready' | 'error';
/** Woran gerade gearbeitet wird, solange geladen wird. */
export type WhisperPhase = 'download' | 'init' | null;
export interface WhisperCapabilities {
webgpu: boolean;
adapter: string | null;
cores: number | null;
mediaRecorder: boolean;
secureContext: boolean;
}
interface WhisperContextValue {
status: WhisperStatus;
/** Download oder Initialisierung - nur waehrend des Ladens gesetzt. */
phase: WhisperPhase;
/** 0-100, waehrend des Downloads. */
progress: number;
progressLabel: string;
error: string | null;
/** Modell liegt bereits im Browser-Cache. */
isCached: boolean;
capabilities: WhisperCapabilities;
/** Tatsaechlich genutzter Rechenweg - Grafikkarte wenn vorhanden. */
resolvedDevice: 'webgpu' | 'wasm';
load: () => Promise<void>;
unload: () => void;
clearCache: () => Promise<void>;
/** Nimmt eine Aufnahme und liefert den erkannten Text. */
transcribe: (blob: Blob) => Promise<{ text: string; ms: number; seconds: number }>;
/** Browser kann grundsaetzlich aufnehmen und rechnen. */
isSupported: boolean;
}
const WhisperContext = createContext<WhisperContextValue | null>(null);
export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = useState<WhisperStatus>('idle');
const [progress, setProgress] = useState(0);
const [progressLabel, setProgressLabel] = useState('');
const [error, setError] = useState<string | null>(null);
const [isCached, setIsCached] = useState(false);
const [phase, setPhase] = useState<WhisperPhase>(null);
const [capabilities, setCapabilities] = useState<WhisperCapabilities>({
webgpu: getGpu() !== null,
adapter: null,
cores: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? null : null,
mediaRecorder: typeof MediaRecorder !== 'undefined',
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
});
const workerRef = useRef<Worker | null>(null);
const loadWaiterRef = useRef<{ resolve: () => void; reject: (err: Error) => void } | null>(null);
/** Zeitpunkt der letzten Worker-Nachricht - Grundlage der Haenger-Erkennung. */
const lastActivityRef = useRef(0);
const stallTimerRef = useRef<number | null>(null);
const jobsRef = useRef(
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
);
const nextJobId = useRef(1);
// Details zur GPU nachladen - das geht nur asynchron.
useEffect(() => {
const gpu = getGpu();
if (!gpu) return;
let cancelled = false;
(async () => {
try {
const adapter = await gpu.requestAdapter();
if (cancelled) return;
if (!adapter) {
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfügbar' }));
return;
}
const info = adapter.info ?? {};
const described = [info.vendor, info.architecture, info.device, info.description]
.filter(Boolean)
.join(' / ');
setCapabilities((prev) => ({ ...prev, adapter: described || 'Adapter vorhanden' }));
} catch (err) {
if (!cancelled) {
setCapabilities((prev) => ({
...prev,
webgpu: false,
adapter: err instanceof Error ? err.message : 'Abfrage fehlgeschlagen',
}));
}
}
})();
return () => {
cancelled = true;
};
}, []);
// Grafikkarte wenn verfuegbar, sonst CPU - daran gibt es nichts zu waehlen.
const resolvedDevice: 'webgpu' | 'wasm' = capabilities.webgpu ? 'webgpu' : 'wasm';
const isSupported = capabilities.mediaRecorder && capabilities.secureContext;
// Cache-Zustand am Browser erfragen, nicht selbst mitschreiben.
const refreshCacheState = useCallback(async () => {
setIsCached(await detectCached());
}, []);
useEffect(() => {
refreshCacheState();
}, [refreshCacheState, status]);
const stopStallWatch = useCallback(() => {
if (stallTimerRef.current !== null) {
window.clearInterval(stallTimerRef.current);
stallTimerRef.current = null;
}
}, []);
const ensureWorker = useCallback((): Worker => {
if (workerRef.current) return workerRef.current;
const worker = new Worker(new URL('./whisper.worker.ts', import.meta.url), { type: 'module' });
/** Bricht das Laden mit einer verwertbaren Meldung ab. */
const failLoad = (message: string) => {
stopStallWatch();
setStatus('error');
setPhase(null);
setError(message);
// Nach einem Fehlschlag ist der Worker nicht mehr vertrauenswuerdig.
workerRef.current?.terminate();
workerRef.current = null;
loadWaiterRef.current?.reject(new Error(message));
loadWaiterRef.current = null;
// Laufende Erkennungen warten sonst fuer immer auf einen toten Worker.
for (const job of jobsRef.current.values()) job.reject(new Error(message));
jobsRef.current.clear();
};
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const message = event.data;
lastActivityRef.current = Date.now();
if (message.type === 'progress') {
if (message.total > 0) {
const done = message.loaded >= message.total;
setProgress(Math.min(100, (message.loaded / message.total) * 100));
setProgressLabel(
`${Math.round(message.loaded / 1e6)} von ${Math.round(message.total / 1e6)} MB`
);
// Nach dem letzten Byte folgt das Anlegen der Rechensitzung - das
// meldet keinen Fortschritt mehr und soll trotzdem sichtbar sein.
setPhase(done ? 'init' : 'download');
}
return;
}
if (message.type === 'ready') {
stopStallWatch();
setStatus('ready');
setPhase(null);
setProgress(100);
setProgressLabel('');
loadWaiterRef.current?.resolve();
loadWaiterRef.current = null;
return;
}
if (message.type === 'result') {
jobsRef.current.get(message.id)?.resolve({ text: message.text, ms: message.ms });
jobsRef.current.delete(message.id);
return;
}
// Fehler
if (message.id !== undefined) {
jobsRef.current.get(message.id)?.reject(new Error(message.message));
jobsRef.current.delete(message.id);
} else {
failLoad(message.message);
}
};
worker.onerror = (event) => {
failLoad(event.message || 'Der Whisper-Worker ist abgestürzt.');
};
workerRef.current = worker;
return worker;
}, [stopStallWatch]);
const load = useCallback(async () => {
if (status === 'loading') return;
if (status === 'ready') return;
setStatus('loading');
setPhase('download');
setError(null);
setProgress(0);
setProgressLabel(isCached ? 'aus dem Cache' : 'Download startet...');
// Vor dem Download um dauerhaften Speicher bitten, damit der Browser die
// Dateien danach nicht bei Platznot wieder wegraeumt.
void requestPersistentStorage();
const worker = ensureWorker();
lastActivityRef.current = Date.now();
// Haenger-Erkennung: bleibt der Worker stumm - etwa weil das Anlegen der
// Sitzung am Speicher scheitert - wird das Laden mit Meldung abgebrochen,
// statt endlos "Laedt..." zu zeigen.
stopStallWatch();
stallTimerRef.current = window.setInterval(() => {
if (Date.now() - lastActivityRef.current < STALL_TIMEOUT_MS) return;
stopStallWatch();
const message =
'Das Modell reagiert seit zwei Minuten nicht. Bitte die Seite neu laden und es erneut versuchen.';
setStatus('error');
setPhase(null);
setError(message);
workerRef.current?.terminate();
workerRef.current = null;
loadWaiterRef.current?.reject(new Error(message));
loadWaiterRef.current = null;
}, 5000);
await new Promise<void>((resolve, reject) => {
loadWaiterRef.current = { resolve, reject };
const request: WorkerRequest = {
type: 'load',
model: WHISPER_MODEL,
device: resolvedDevice,
dtype: WHISPER_DTYPE,
};
worker.postMessage(request);
});
}, [status, isCached, ensureWorker, resolvedDevice, stopStallWatch]);
const unload = useCallback(() => {
stopStallWatch();
workerRef.current?.terminate();
workerRef.current = null;
jobsRef.current.clear();
setStatus('idle');
setPhase(null);
setProgress(0);
setProgressLabel('');
}, [stopStallWatch]);
const clearCache = useCallback(async () => {
unload();
try {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
} catch {
// Cache API gesperrt - dann bleibt der Download eben liegen.
}
await refreshCacheState();
}, [unload, refreshCacheState]);
const transcribe = useCallback(
async (blob: Blob) => {
if (status !== 'ready') throw new Error('Das Spracherkennungsmodell ist nicht geladen.');
const { audio, seconds } = await toWhisperAudio(blob);
const worker = ensureWorker();
const id = nextJobId.current++;
const outcome = await new Promise<{ text: string; ms: number }>((resolve, reject) => {
// Ohne Frist bliebe die Oberflaeche bei "Erkenne..." stehen, wenn die
// Rechenumgebung nicht antwortet.
const timer = window.setTimeout(() => {
jobsRef.current.delete(id);
const message =
'Die Erkennung hat nicht geantwortet. Bitte die Seite neu laden und es erneut versuchen.';
// Der Worker steckt fest; ein neuer Versuch braucht einen frischen.
workerRef.current?.terminate();
workerRef.current = null;
setStatus('idle');
setError(message);
reject(new Error(message));
}, transcribeTimeoutMs(seconds));
jobsRef.current.set(id, {
resolve: (value) => {
window.clearTimeout(timer);
resolve(value);
},
reject: (err) => {
window.clearTimeout(timer);
reject(err);
},
});
const request: WorkerRequest = {
type: 'transcribe',
id,
audio,
language: WHISPER_LANGUAGE,
};
// Der Puffer wird uebergeben statt kopiert - bei langen Aufnahmen spart
// das mehrere Megabyte.
worker.postMessage(request, [audio.buffer]);
});
return { ...outcome, seconds };
},
[status, ensureWorker]
);
/*
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache.
*
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
*
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache,
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download
* faellt nur einmal je Browser an.
*/
useEffect(() => () => workerRef.current?.terminate(), []);
const value = useMemo<WhisperContextValue>(
() => ({
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
resolvedDevice,
load,
unload,
clearCache,
transcribe,
isSupported,
}),
[
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
resolvedDevice,
load,
unload,
clearCache,
transcribe,
isSupported,
]
);
return <WhisperContext.Provider value={value}>{children}</WhisperContext.Provider>;
};
export function useWhisper(): WhisperContextValue {
const ctx = useContext(WhisperContext);
if (!ctx) throw new Error('useWhisper muss innerhalb von <WhisperProvider> verwendet werden.');
return ctx;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Whisper laeuft im Worker, nicht im Hauptthread: die Erkennung rechnet je nach
* Modell mehrere Sekunden, und solange darf die Oberflaeche nicht einfrieren.
*
* Das Modell wird beim ersten Laden vom Hugging-Face-CDN geholt und danach im
* Browser-Cache gehalten. Die Aufnahme selbst verlaesst das Geraet nie.
*/
import { pipeline, env, type AutomaticSpeechRecognitionPipeline } from '@huggingface/transformers';
// Ohne das versucht transformers.js zuerst einen lokalen Pfad und laeuft in
// unnoetige 404er.
env.allowLocalModels = false;
env.allowRemoteModels = true;
export type WorkerRequest =
| { type: 'load'; model: string; device: 'webgpu' | 'wasm'; dtype: string }
| { type: 'transcribe'; id: number; audio: Float32Array; language: string };
export type WorkerResponse =
| { type: 'progress'; loaded: number; total: number; file: string }
| { type: 'ready'; ms: number; downloadedBytes: number }
| { type: 'result'; id: number; text: string; ms: number }
| { type: 'error'; id?: number; message: string };
/**
* `pipeline()` ist ueber alle Aufgabenarten hinweg typisiert; TypeScript kann
* die entstehende Union nicht mehr darstellen ("union type too complex").
* Deshalb hier eine schmale Signatur nur fuer die Spracherkennung.
*/
type CreatePipeline = (
task: 'automatic-speech-recognition',
model: string,
options: Record<string, unknown>
) => Promise<AutomaticSpeechRecognitionPipeline>;
const createPipeline = pipeline as unknown as CreatePipeline;
let transcriber: AutomaticSpeechRecognitionPipeline | null = null;
/** Groesse je Datei, damit der Fortschritt ueber alle Dateien stimmt. */
const files = new Map<string, { loaded: number; total: number }>();
const post = (message: WorkerResponse) => self.postMessage(message);
async function load(model: string, device: 'webgpu' | 'wasm', dtype: string) {
files.clear();
const started = performance.now();
transcriber = await createPipeline('automatic-speech-recognition', model, {
device,
dtype,
progress_callback: (event: {
status: string;
file?: string;
loaded?: number;
total?: number;
}) => {
if (event.status !== 'progress' || !event.file) return;
files.set(event.file, { loaded: event.loaded ?? 0, total: event.total ?? 0 });
let loaded = 0;
let total = 0;
for (const entry of files.values()) {
loaded += entry.loaded;
total += entry.total;
}
post({ type: 'progress', loaded, total, file: event.file });
},
});
let downloadedBytes = 0;
for (const entry of files.values()) downloadedBytes += entry.total;
post({ type: 'ready', ms: Math.round(performance.now() - started), downloadedBytes });
}
async function transcribe(id: number, audio: Float32Array, language: string) {
if (!transcriber) throw new Error('Das Modell ist nicht geladen.');
const started = performance.now();
const output = await transcriber(audio, {
language,
task: 'transcribe',
// Laengere Aufnahmen werden in Fenster zerlegt; der Ueberlappungsbereich
// verhindert abgeschnittene Woerter an den Grenzen.
chunk_length_s: 30,
stride_length_s: 5,
});
const text = Array.isArray(output)
? output.map((part) => part.text ?? '').join(' ')
: (output.text ?? '');
post({ type: 'result', id, text: text.trim(), ms: Math.round(performance.now() - started) });
}
/**
* Beim Anlegen der ONNX-Sitzung koennen Fehler ausserhalb der await-Kette
* auftreten - etwa "Array buffer allocation failed", wenn das Modell zu gross
* fuer einen zusammenhaengenden Speicherblock ist. Ohne diese Handler bleibt
* das Lade-Promise fuer immer offen und die Oberflaeche haengt bei "Laedt...".
*/
function reportFatal(reason: unknown) {
const raw = reason instanceof Error ? reason.message : String(reason);
const isMemory = /allocation failed|out of memory|Array buffer/i.test(raw);
post({
type: 'error',
message: isMemory
? `Der Speicher reicht für dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 wählen.`
: raw,
});
}
self.addEventListener('error', (event) => {
event.preventDefault();
reportFatal(event.error ?? event.message);
});
self.addEventListener('unhandledrejection', (event) => {
event.preventDefault();
reportFatal(event.reason);
});
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const request = event.data;
try {
if (request.type === 'load') {
await load(request.model, request.device, request.dtype);
} else {
await transcribe(request.id, request.audio, request.language);
}
} catch (err) {
post({
type: 'error',
id: request.type === 'transcribe' ? request.id : undefined,
message: err instanceof Error ? err.message : String(err),
});
}
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Downloadgroesse des Sprachmodells bei Hugging Face erfragen, damit im Reiter
* eine echte Zahl steht statt einer Schaetzung.
*
* Geladen werden zwei Dateien: der Encoder und der zusammengefuehrte Decoder,
* jeweils in der Variante q4.
*/
const DTYPE_SUFFIX = '_q4';
export interface ModelSize {
totalBytes: number;
/** Groesste Einzeldatei - sie entscheidet, ob der Browser sie halten kann. */
largestBytes: number;
}
interface Sibling {
rfilename: string;
size?: number;
}
const cache = new Map<string, ModelSize>();
export async function fetchModelSize(model: string): Promise<ModelSize | null> {
const cached = cache.get(model);
if (cached) return cached;
const response = await fetch(`https://huggingface.co/api/models/${model}?blobs=true`);
if (!response.ok) throw new Error(`Modellinformationen nicht abrufbar (HTTP ${response.status}).`);
const data = (await response.json()) as { siblings?: Sibling[] };
const files = new Map<string, number>();
for (const sibling of data.siblings ?? []) {
if (sibling.rfilename.endsWith('.onnx')) {
files.set(sibling.rfilename.replace(/^onnx\//, '').replace(/\.onnx$/, ''), sibling.size ?? 0);
}
}
// transformers.js nimmt den zusammengefuehrten Decoder, wenn er vorliegt.
const decoderName = files.has(`decoder_model_merged${DTYPE_SUFFIX}`)
? `decoder_model_merged${DTYPE_SUFFIX}`
: `decoder_model${DTYPE_SUFFIX}`;
const encoder = files.get(`encoder_model${DTYPE_SUFFIX}`);
const decoder = files.get(decoderName);
if (encoder === undefined || decoder === undefined) return null;
const size: ModelSize = {
totalBytes: encoder + decoder,
largestBytes: Math.max(encoder, decoder),
};
cache.set(model, size);
return size;
}
/** "299 MB" bzw. "1,6 GB" */
export function formatSize(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`;
return `${Math.round(bytes / 1e6)} MB`;
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { App } from './App';
import { AuthProvider } from './lib/auth';
import { ThemeProvider } from './lib/theme';
import { OptionsProvider } from './lib/options';
import { ToastProvider } from './components/Toast';
import { WhisperProvider } from './lib/whisper';
const container = document.getElementById('root');
if (!container) throw new Error('Element #root fehlt in index.html.');
createRoot(container).render(
<React.StrictMode>
<AuthProvider>
{/* Das Theme haengt am Benutzer - deshalb innerhalb von AuthProvider. */}
<ThemeProvider>
<ToastProvider>
{/* Die Stammdatenlisten hängen an der Anmeldung. */}
<OptionsProvider>
{/* Haelt den Whisper-Worker; das Diktat laeuft im Browser. */}
<WhisperProvider>
<App />
</WhisperProvider>
</OptionsProvider>
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</React.StrictMode>
);
+415
View File
@@ -0,0 +1,415 @@
/** Gemeinsame Typen und Konstanten des ICON CRM. */
export type Role = 'admin' | 'user';
/*
* Farbwelten. Die Liste muss mit THEMES in server/src/userFields.js
* übereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein
* Tippfehler hier nicht still zum Standarddesign führt.
*/
export const THEME_PALETTES = ['icon', 'halflife', 'pixel'] as const;
export type ThemePalette = (typeof THEME_PALETTES)[number];
/** Beschriftung und Kurzbeschreibung für die Auswahl im Kopf der Anwendung. */
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
icon: { name: 'ICON', hint: 'Das Design des Hauses – Gold auf neutralem Grau' },
halflife: {
name: 'Half-Life',
hint: 'Black Mesa: HEV-Orange auf Beton, harte Kanten, Oswald',
},
pixel: {
name: 'Pixel Art',
hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften',
},
};
export interface AppUser {
id: number;
username: string;
email: string | null;
name: string;
role: Role;
color: string | null;
avatar: string | null;
theme: string | null;
theme_mode: 'light' | 'dark' | null;
is_active: boolean;
created_at?: string;
updated_at?: string;
}
/** Abgespeckter Benutzer aus /users/directory - fuer Auswahl und Erwaehnungen. */
export interface DirectoryUser {
id: number;
username: string;
name: string;
role: Role;
color: string | null;
avatar: string | null;
is_active: boolean;
}
/*
* Die Kontaktart wird in den Einstellungen gepflegt (Stammdaten) und ist
* deshalb kein fester Typ mehr. Die Vorgabewerte dienen nur als Rückfall,
* solange die Liste noch nicht geladen ist.
*/
export type ContactKind = string;
export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner', 'Lead'];
/*
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom
* 14.09.2026). Was bleibt, beschreibt den Stand der Zusammenarbeit.
*/
export type ContactStatus = 'Interessiert' | 'Interview' | 'Gebucht' | 'Inaktiv';
export const CONTACT_STATUS: ContactStatus[] = ['Interessiert', 'Interview', 'Gebucht', 'Inaktiv'];
export type Tristate = 'unbekannt' | 'ja' | 'nein';
export type ContactState = 'unbekannt' | 'persoenlich' | 'kein Kontakt';
export const CONTACT_STATE_LABELS: Record<ContactState, string> = {
unbekannt: 'Unbekannt',
persoenlich: 'Persönlicher Kontakt',
'kein Kontakt': 'Noch kein Kontakt gehabt',
};
export interface Contact {
id: number;
first_name: string | null;
last_name: string;
/** Vor- und Nachname, vom Backend zusammengesetzt. */
name: string;
position: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
avatar: string | null;
company_id: number | null;
company_name?: string | null;
company_industry?: string | null;
kind: ContactKind;
status: ContactStatus;
lead_source: string | null;
contact_state: ContactState;
freelancers: Tristate;
bio: string | null;
tags: string[];
rate_onsite: number | null;
rate_remote: number | null;
cv_url: string | null;
/** Dateiname des hochgeladenen Lebenslaufs (aus dem CV-Import). */
cv_file: string | null;
owner_id: number | null;
created_by: number | null;
created_at: string;
updated_at: string;
interview_count?: number;
}
/*
* Ein Kontakt aus dem perinco CRM.
*
* Er wird nur angezeigt, nie gespeichert. Die Kennung ist eine Zeichenkette
* ("perinco-31"), weil sie in einer anderen Datenbank zaehlt und nie mit einer
* ICON-Kennung verwechselt werden darf.
*/
export interface PerincoContact {
id: string;
first_name: string | null;
last_name: string;
name: string;
position: string | null;
company_name: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
avatar: string | null;
/** Drüben: Recruiter, Partner oder Endkunde - unverändert übernommen. */
kind: string | null;
lead_source: string | null;
tags: string[];
bio: string | null;
created_at: string | null;
updated_at: string | null;
source_system: 'perinco';
}
export interface Company {
id: number;
name: string;
group_name: string | null;
industry: string | null;
employees: number | null;
website: string | null;
address: string | null;
logo: string | null;
/** 0 = nicht bewertet, sonst 1 bis 5 Sterne. */
rating: number;
is_customer: boolean;
customer_state: 'aktiv' | 'alumni';
freelancers: Tristate;
notes: string | null;
owner_id: number | null;
/** Hauptansprechpartner – Auswahl aus den Kontakten dieses Unternehmens. */
primary_contact_id: number | null;
created_by: number | null;
created_at: string;
updated_at: string;
contact_count?: number;
former_count?: number;
}
export interface Employment {
id: number;
contact_id: number;
company_id: number;
company?: string;
industry?: string | null;
position: string | null;
started_on: string | null;
ended_on: string | null;
is_current: 0 | 1;
}
export interface Interview {
id: number;
contact_id: number | null;
company_id: number | null;
user_id: number | null;
user_name?: string | null;
contact_name?: string | null;
company?: string | null;
held_on: string;
notes: string | null;
}
export type HistoryEntityType =
| 'contact'
| 'company'
| 'tender'
| 'project'
| 'lead'
| 'user'
| 'system';
export interface HistoryEntry {
id: number;
entity_type: HistoryEntityType;
entity_id: number | null;
entity_label: string | null;
action: string;
text: string | null;
changes: Record<string, [unknown, unknown]> | null;
user_id: number | null;
user_name: string | null;
user_color?: string | null;
user_avatar?: string | null;
created_at: string;
}
export type TenderStatus = 'Aktiv' | 'Inaktiv' | 'Ausgelaufen' | 'Archiviert';
export type TenderStage =
| 'Ausschreibung'
| 'Kontakte sichten'
| 'Interview'
| 'Kunden Interview'
| 'Vertragsgestaltung'
| 'Abschluss'
| 'Geschlossen';
export const TENDER_STAGES: TenderStage[] = [
'Ausschreibung',
'Kontakte sichten',
'Interview',
'Kunden Interview',
'Vertragsgestaltung',
'Abschluss',
'Geschlossen',
];
export interface TenderContact {
id: number;
name: string;
position: string | null;
avatar: string | null;
status: ContactStatus;
}
export interface Tender {
id: number;
title: string;
source: string | null;
link: string | null;
company_id: number | null;
company_name?: string | null;
company_logo?: string | null;
status: TenderStatus;
stage: TenderStage;
/** Steuert die Anzeige auf der ICON Website. */
online: boolean;
/**
* Der Ausschreibungstext als HTML.
*
* Er liegt in `notes` und nicht in `details` - so war es im ursprünglichen
* ICON CRM, und die Website liest genau dieses Feld.
*/
notes: string | null;
details: string | null;
deadline: string | null;
contacts?: TenderContact[];
contact_count?: number;
created_at: string;
updated_at: string;
}
export interface Project {
id: number;
title: string;
company_id: number | null;
company_name?: string | null;
contact_id: number | null;
contact_name?: string | null;
contact_avatar?: string | null;
started_on: string | null;
ended_on: string | null;
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
rate: number | null;
notes: string | null;
}
export interface Connection {
id: number;
contact_a: number;
contact_b: number;
a_name?: string;
b_name?: string;
kind: string;
}
/*
* Die Netzwerkansicht ist bewusst kein eigener Menuepunkt: sie ist eine Ansicht
* der Kontakte bzw. der Unternehmen - wie im perinco CRM. Ein eigener Eintrag
* haette dieselben Daten ein zweites Mal aufgemacht, mit eigenen Filtern.
*/
/*
* Lead-Phasen. In der Datenbank stehen sie ohne Umlaute (ENUM-Werte werden
* verglichen, nicht angezeigt) - die Beschriftung kommt aus LEAD_STAGE_LABELS.
*/
export type LeadStage =
| 'Fuer Spaeter'
| 'Neuer Lead'
| 'Erstkontakt'
| '1. Gespraech'
| 'Followup geplant'
| 'Followup Gespraech'
| 'Closed'
| 'Absage';
export const LEAD_STAGES: LeadStage[] = [
'Fuer Spaeter',
'Neuer Lead',
'Erstkontakt',
'1. Gespraech',
'Followup geplant',
'Followup Gespraech',
'Closed',
'Absage',
];
export const LEAD_STAGE_LABELS: Record<LeadStage, string> = {
// In der Oberfläche heißt die erste Spalte Backlog; der Wert in der
// Datenbank bleibt unverändert, sonst wäre es eine Migration.
'Fuer Spaeter': 'Backlog',
'Neuer Lead': 'Neuer Lead',
Erstkontakt: 'Erstkontakt',
'1. Gespraech': '1. Gespräch',
'Followup geplant': 'Followup geplant',
'Followup Gespraech': 'Followup Gespräch',
Closed: 'Closed',
Absage: 'Absage',
};
export interface Lead {
id: number;
title: string;
company_id: number | null;
company_name: string | null;
company_industry: string | null;
company_logo: string | null;
company_rating: number | null;
contact_id: number | null;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
contact_avatar: string | null;
stage: LeadStage;
contact_state: ContactState;
archived: boolean;
position: number;
notes: string | null;
remind_at: string | null;
reminder_days: number | null;
last_contact_at: string | null;
owner_id: number | null;
owner_name: string | null;
todo_count: number;
todo_done: number;
created_at: string;
updated_at: string;
}
export interface LeadTodo {
id: number;
lead_id: number;
text: string;
done: boolean;
due_on: string | null;
position: number;
}
export type View =
| 'Dashboard'
| 'Leads'
| 'Leads · Archiviert'
| 'Kontakte'
| 'Unternehmen'
| 'Ausschreibungen'
| 'Live!'
| 'Ausschreibungen · Archiv'
| 'Laufende Projekte'
| 'Historie'
| 'Einstellungen';
/** Farbe eines Benutzers; ohne eigene Wahl aus der Kennung abgeleitet. */
const USER_COLORS = ['#c1862f', '#8a6d3b', '#4f7d8c', '#7d5ba6', '#3f8f6b', '#b4623c'];
export function userColor(user: { id: number; color?: string | null } | null | undefined): string {
if (!user) return '#93939b';
return user.color || USER_COLORS[user.id % USER_COLORS.length];
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/** 14.09.'26 09:35 - das Format aus der Featureliste. */
export function formatStamp(value: string | null | undefined): string {
if (!value) return '';
const date = new Date(value.replace(' ', 'T'));
if (Number.isNaN(date.getTime())) return String(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.'${String(date.getFullYear()).slice(
2
)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function formatDate(value: string | null | undefined): string {
if (!value) return '–';
const date = new Date(value.length <= 10 ? `${value}T00:00:00` : value.replace(' ', 'T'));
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["node", "vite/client"],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"paths": { "@/*": ["./src/*"] },
"noEmit": true
},
"include": ["src"]
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
server: {
port: 3101,
host: '0.0.0.0',
// Im Dev-Modus laeuft das Backend lokal auf 5007; im Betrieb uebernimmt
// Apache das Weiterreichen von /api an 127.0.0.1:5007.
proxy: {
'/api': {
target: 'http://127.0.0.1:5007',
changeOrigin: true,
},
},
},
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
build: {
outDir: 'dist',
sourcemap: false,
},
});