Files
perinco-CRM/src/components/ui/Modal.tsx
T
Sascha WolfandClaude Opus 5 025390e18a feat(opportunities): Festpreis, Klick daneben, Kontakt von hier
Drei Dinge, die im Alltag gefehlt haben:

- Projektdaten kennen jetzt einen Projektpreis. Ist er gesetzt, gilt er als
  Volumen statt Tagessatz x Laufzeit - Festpreisprojekte mussten bisher
  ausgerechnet und in den Tagessatz gefaelscht werden.
- Dialoge schliessen auch per Klick in den Hintergrund, nicht nur ueber das X.
  Escape trifft dabei nur den obersten Dialog; eine Textmarkierung, die im
  Dialog beginnt und daneben endet, schliesst nichts.
- Fehlt ein Ansprechpartner noch im CRM, oeffnet "Neuer Kontakt..." in der
  Opportunity denselben Dialog wie unter Kontakte. Der frische Kontakt haengt
  danach sofort als Ansprechpartner dran, ohne die Opportunity zu verlassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:41:11 +02:00

169 lines
5.4 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
interface ModalProps {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'md' | 'lg' | 'xl';
}
const SIZES: Record<NonNullable<ModalProps['size']>, string> = {
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
/**
* Offene Dialoge in der Reihenfolge ihres Oeffnens. Escape darf nur den
* obersten schliessen, sonst raeumt ein Tastendruck verschachtelte Dialoge
* (z. B. "Neuer Kontakt" in einer Opportunity) gleich alle mit weg.
*/
const openModals: symbol[] = [];
/**
* Schliessen per Escape und per Klick in den Hintergrund. Beides greift in jedem
* Dialog, weil alle Dialoge im CRM ueber diese Komponente laufen.
*/
function useDismiss(onClose: () => void) {
// Ueber die Ref bleibt der Escape-Handler an den Dialog gebunden, auch wenn
// die Elternkomponente bei jedem Rendern ein neues onClose durchgibt - sonst
// wuerde sich der Dialog beim Neuaufbau des Handlers wieder nach oben
// sortieren und dem verschachtelten Dialog das Escape wegnehmen.
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const token = Symbol('modal');
openModals.push(token);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (openModals[openModals.length - 1] !== token) return;
closeRef.current();
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
const index = openModals.indexOf(token);
if (index !== -1) openModals.splice(index, 1);
};
}, []);
// Nur ein Klick, der im Hintergrund beginnt *und* endet, schliesst. So
// ueberlebt eine Textmarkierung, die im Dialog anfaengt und daneben aufhoert.
const pressedBackdrop = useRef(false);
return {
onMouseDown: (event: React.MouseEvent) => {
pressedBackdrop.current = event.target === event.currentTarget;
},
onClick: (event: React.MouseEvent) => {
if (pressedBackdrop.current && event.target === event.currentTarget) closeRef.current();
},
};
}
export const Modal: React.FC<ModalProps> = ({
title,
subtitle,
onClose,
children,
footer,
size = 'lg',
}) => {
const dismiss = useDismiss(onClose);
return (
<div
{...dismiss}
className="fixed inset-0 z-[100] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm"
>
<div
className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-lift ${SIZES[size]}`}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-6 py-5">
<div className="min-w-0">
<h2 className="truncate text-xl text-ink-900">{title}</h2>
{subtitle && <p className="mt-0.5 truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Dialog schließen"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-6 py-6">{children}</div>
{footer && (
<div className="shrink-0 border-t border-ink-100 bg-ink-50/60 px-6 py-4">{footer}</div>
)}
</div>
</div>
);
};
interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title,
message,
confirmLabel = 'Löschen',
onConfirm,
onCancel,
}) => {
const dismiss = useDismiss(onCancel);
return (
<div
{...dismiss}
className="fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm"
>
<div className="w-full max-w-sm rounded-2xl border border-ink-200 bg-surface p-7 text-center shadow-lift">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-600">
<svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h3 className="text-lg text-ink-900">{title}</h3>
<p className="mt-2 text-sm text-ink-500">{message}</p>
<div className="mt-7 grid grid-cols-2 gap-3">
<button
onClick={onCancel}
className="rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
onClick={onConfirm}
className="rounded-xl bg-red-600 py-3 text-sm font-bold text-white transition hover:bg-red-700"
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};