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>
);
}