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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user