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:
Sascha Wolf
2026-08-18 10:41:11 +02:00
co-authored by Claude Opus 5
parent 30164e0ae7
commit 025390e18a
12 changed files with 243 additions and 67 deletions
+9 -1
View File
@@ -23,6 +23,12 @@ interface LeadModalProps {
connections: LeadConnection[];
onClose: () => void;
onSaved: () => void;
/**
* Nur beim Neuanlegen: liefert den frisch gespeicherten Kontakt zurueck.
* Damit kann der Aufrufer ihn direkt weiterverwenden - etwa als
* Ansprechpartner einer Opportunity.
*/
onCreated?: (lead: Lead) => void;
}
interface FormState {
@@ -83,6 +89,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
connections,
onClose,
onSaved,
onCreated,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null));
@@ -149,8 +156,9 @@ export const LeadModal: React.FC<LeadModalProps> = ({
await leadsApi.update(lead.id, payload);
addToast('Kontakt aktualisiert.', 'success');
} else {
await leadsApi.create(payload);
const created = await leadsApi.create(payload);
addToast('Kontakt angelegt.', 'success');
onCreated?.(created);
}
onSaved();
onClose();
@@ -108,7 +108,12 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
{/* Projektdaten */}
<div className="grid grid-cols-2 gap-3 border-t border-ink-100 pt-3">
<Metric label="Volumen" value={formatEuro(opportunityVolume(opportunity))} />
<Metric label="Tagessatz" value={formatEuro(opportunity.daily_rate)} />
{/* Festpreisprojekte haben keinen Tagessatz - dann zaehlt der Preis. */}
{opportunity.fixed_price !== null ? (
<Metric label="Projektpreis" value={formatEuro(opportunity.fixed_price)} />
) : (
<Metric label="Tagessatz" value={formatEuro(opportunity.daily_rate)} />
)}
<Metric label="Laufzeit" value={formatDays(opportunity.duration_days)} />
<Metric
label="Zeitraum"
@@ -178,7 +178,15 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
{formatEuro(opportunityVolume(opportunity))}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700">
{formatEuro(opportunity.daily_rate)}
{/* Festpreis statt Tagessatz, sonst stuende hier nur ein Strich. */}
{opportunity.fixed_price !== null ? (
<>
{formatEuro(opportunity.fixed_price)}
<p className="text-[11px] text-ink-400">Festpreis</p>
</>
) : (
formatEuro(opportunity.daily_rate)
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700">
{formatDays(opportunity.duration_days)}
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { LeadModal } from '../leads/LeadModal';
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
import { Avatar, inputClass, labelClass } from '../leads/leadUi';
import { STAGE_COLORS } from './opportunityUi';
@@ -39,6 +40,7 @@ interface FormState {
probability: string;
daily_rate: string;
duration_days: string;
fixed_price: string;
start_date: string;
end_date: string;
last_interaction_at: string;
@@ -63,6 +65,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number |
probability: String(opportunity?.probability ?? 10),
daily_rate: numberToInput(opportunity?.daily_rate ?? null),
duration_days: numberToInput(opportunity?.duration_days ?? null),
fixed_price: numberToInput(opportunity?.fixed_price ?? null),
start_date: opportunity?.start_date?.slice(0, 10) ?? '',
end_date: opportunity?.end_date?.slice(0, 10) ?? '',
last_interaction_at: opportunity?.last_interaction_at?.slice(0, 10) ?? '',
@@ -87,6 +90,8 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
() => opportunity?.contacts.map((c) => ({ lead_id: c.lead_id, role: c.role ?? '' })) ?? []
);
const [contactToAdd, setContactToAdd] = useState('');
// Fehlt der Ansprechpartner noch im CRM, laesst er sich von hier aus anlegen.
const [isCreatingLead, setIsCreatingLead] = useState(false);
const [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false);
@@ -102,18 +107,28 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de'));
}, [leads, contacts]);
// Volumen live mitrechnen, damit die Wirkung von Tagessatz und Laufzeit
// sofort sichtbar ist.
// Volumen live mitrechnen, damit die Wirkung von Tagessatz, Laufzeit und
// Festpreis sofort sichtbar ist. Ein gesetzter Projektpreis gewinnt - genau
// wie in opportunityVolume().
const preview = useMemo(() => {
const rate = Number(form.daily_rate.replace(',', '.'));
const days = Number(form.duration_days.replace(',', '.'));
if (!Number.isFinite(rate) || !Number.isFinite(days) || !form.daily_rate || !form.duration_days) {
return null;
}
const volume = rate * days;
const probability = Number(form.probability) || 0;
return { volume, weighted: (volume * probability) / 100 };
}, [form.daily_rate, form.duration_days, form.probability]);
const toNumber = (value: string) => Number(value.replace(',', '.'));
const fixed = toNumber(form.fixed_price);
const hasFixed = form.fixed_price.trim() !== '' && Number.isFinite(fixed);
const rate = toNumber(form.daily_rate);
const days = toNumber(form.duration_days);
const hasRate =
form.daily_rate.trim() !== '' &&
form.duration_days.trim() !== '' &&
Number.isFinite(rate) &&
Number.isFinite(days);
if (!hasFixed && !hasRate) return null;
const volume = hasFixed ? fixed : rate * days;
return { volume, weighted: (volume * probability) / 100, isFixed: hasFixed };
}, [form.daily_rate, form.duration_days, form.fixed_price, form.probability]);
const addTags = () => {
const parsed = tagInput
@@ -152,6 +167,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
probability: Number(form.probability) || 0,
daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'),
duration_days: form.duration_days.trim() === '' ? null : form.duration_days.replace(',', '.'),
fixed_price: form.fixed_price.trim() === '' ? null : form.fixed_price.replace(',', '.'),
start_date: form.start_date || null,
end_date: form.end_date || null,
last_interaction_at: form.last_interaction_at || null,
@@ -183,6 +199,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
};
return (
<>
<Modal
title={opportunity ? opportunity.title : 'Neue Opportunity'}
subtitle={
@@ -283,7 +300,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Projektdaten
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className={labelClass} htmlFor="opp-rate">
Tagessatz (EUR)
@@ -310,6 +327,26 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
className={inputClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="opp-fixed-price">
Projektpreis (EUR)
</label>
<input
id="opp-fixed-price"
inputMode="decimal"
value={form.fixed_price}
onChange={(event) => set('fixed_price', event.target.value)}
placeholder="46000"
aria-describedby="opp-fixed-price-hint"
className={inputClass}
/>
<p id="opp-fixed-price-hint" className="mt-1.5 text-[11px] text-ink-400">
Festpreis. Ist er gesetzt, gilt er als Volumen statt Tagessatz × Laufzeit.
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="opp-start">
Projektstart
@@ -376,7 +413,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
<div className="mt-4 flex flex-wrap gap-6 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Volumen
Volumen {preview.isFixed ? '(Festpreis)' : '(Tagessatz × Laufzeit)'}
</p>
<p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p>
</div>
@@ -399,6 +436,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
</h3>
<p className="mb-4 text-xs text-ink-400">
Kontakte aus dem CRM; die Rolle ist Freitext, z. B. Entscheider oder Fachbereich.
Fehlt jemand, lässt er sich hier direkt als neuer Kontakt anlegen.
</p>
<div className="space-y-2">
@@ -486,6 +524,13 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
>
Hinzufügen
</button>
<button
type="button"
onClick={() => setIsCreatingLead(true)}
className="shrink-0 rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
Neuer Kontakt...
</button>
</div>
</div>
</section>
@@ -628,5 +673,29 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
)}
</form>
</Modal>
{/* Neuer Kontakt aus der Opportunity heraus - derselbe Dialog wie unter
"Kontakte". Er steht spaeter im DOM und liegt damit obenauf. */}
{isCreatingLead && (
<LeadModal
lead={null}
leads={leads}
users={users}
connections={[]}
onClose={() => setIsCreatingLead(false)}
onCreated={(created) => {
setContacts((prev) =>
prev.some((contact) => contact.lead_id === created.id)
? prev
: [...prev, { lead_id: created.id, role: '' }]
);
setContactToAdd('');
}}
// Laedt die Daten der App neu, damit der frische Kontakt in `leads`
// steht und hier mit Namen und Firma erscheint.
onSaved={onSaved}
/>
)}
</>
);
};
+94 -41
View File
@@ -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>
);
);
};
+7 -1
View File
@@ -210,6 +210,8 @@ export interface Opportunity {
daily_rate: number | null;
/** Laufzeit in Personentagen */
duration_days: number | null;
/** Festpreis in Euro; ersetzt die Rechnung aus Tagessatz und Laufzeit */
fixed_price: number | null;
start_date: string | null;
end_date: string | null;
last_interaction_at: string | null;
@@ -231,8 +233,12 @@ export interface Opportunity {
export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
/** Projektvolumen = Tagessatz x Personentage; null, wenn eines fehlt. */
/**
* Projektvolumen. Ein gesetzter Projektpreis gilt als Festpreis und schlaegt die
* Rechnung aus Tagessatz x Personentagen; sonst braucht es beide Werte.
*/
export function opportunityVolume(opportunity: Opportunity): number | null {
if (opportunity.fixed_price !== null) return opportunity.fixed_price;
if (opportunity.daily_rate === null || opportunity.duration_days === null) return null;
return opportunity.daily_rate * opportunity.duration_days;
}