Quellcode des ICON CRM (6)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user