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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
30164e0ae7
commit
025390e18a
+94
-41
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
@@ -15,6 +15,57 @@ const SIZES: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
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,
|
||||
@@ -23,18 +74,13 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
footer,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
// Escape schliesst den Dialog - erwartetes Verhalten und schneller als
|
||||
// zum Schliessen-Button zu greifen.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
const dismiss = useDismiss(onClose);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm">
|
||||
<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"
|
||||
@@ -81,35 +127,42 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
confirmLabel = 'Löschen',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => (
|
||||
<div 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>
|
||||
}) => {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user