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
+1
View File
@@ -64,6 +64,7 @@ erDiagram
tinyint probability "0-100" tinyint probability "0-100"
decimal daily_rate decimal daily_rate
decimal duration_days "Personentage, halbe möglich" decimal duration_days "Personentage, halbe möglich"
decimal fixed_price "Festpreis statt Tagessatz x Laufzeit"
date last_interaction_at date last_interaction_at
varchar next_step varchar next_step
int owner_id FK int owner_id FK
+19 -2
View File
@@ -58,6 +58,11 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
* Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre * Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre
Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem
Postfach noch auf eine Entscheidung warten. Postfach noch auf eine Entscheidung warten.
* Jedes Fenster, das sich in den Vordergrund legt, schließt auf drei Wege: das
**X** oben rechts, die Taste **Esc** oder ein **Klick daneben** in den
abgedunkelten Hintergrund. Liegen zwei Fenster übereinander, betrifft das
immer nur das obere. Eine Textmarkierung, die im Fenster beginnt und
daneben endet, schließt nichts.
> Alle Abbildungen in dieser Anleitung zeigen die Anwendung mit **erfundenen > Alle Abbildungen in dieser Anleitung zeigen die Anwendung mit **erfundenen
> Beispieldaten**. Firmen, Personen und Zahlen darin gibt es nicht. > Beispieldaten**. Firmen, Personen und Zahlen darin gibt es nicht.
@@ -69,7 +74,7 @@ wechselt man die Darstellung:
| Ansicht | wofür | | Ansicht | wofür |
| --- | --- | | --- | --- |
| **Kachel** | Überblick mit Volumen, Tagessatz, Laufzeit, letzter Interaktion | | **Kachel** | Überblick mit Volumen, Tagessatz oder Projektpreis, Laufzeit, letzter Interaktion |
| **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren | | **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren |
| **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben | | **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben |
@@ -85,8 +90,20 @@ Spalte steht ihr Volumen; eine Karte wird per Maus in die nächste Phase gezogen
Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe über der Liste, Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe über der Liste,
je Pipeline-Spalte und in der Kopfzeile. je Pipeline-Spalte und in der Kopfzeile.
Nicht jedes Projekt rechnet nach Aufwand. Für Festpreise gibt es unter
*Projektdaten* das Feld **Projektpreis**. Steht dort eine Zahl, gilt sie als
Volumen - Tagessatz und Laufzeit werden dann nicht mehr multipliziert. Die
Vorschau im Formular schreibt dazu, welche Grundlage gerade zählt
(*Festpreis* oder *Tagessatz x Laufzeit*); Kachel und Liste zeigen statt des
Tagessatzes den Projektpreis. Tagessatz und Laufzeit dürfen trotzdem gefüllt
bleiben, etwa zur eigenen Kalkulation.
Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den
Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt
jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste
dasselbe Formular wie unter *Kontakte*. Nach dem Speichern hängt der neue
Kontakt sofort als Ansprechpartner an der Opportunity - die man dabei nicht
verlässt.
Ändern und löschen darf der **Ersteller**, der **Zuständige** und jeder Ändern und löschen darf der **Ersteller**, der **Zuständige** und jeder
**Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prüft **Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prüft
+2 -2
View File
@@ -250,7 +250,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` | | `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` | | `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt | | `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
| `components/ui/Modal.tsx` | `Modal` (Escape schließt) und `ConfirmDialog` | | `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG | | `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG |
@@ -265,7 +265,7 @@ nicht auseinanderlaufen:
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter | | `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter |
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter | | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Tagessatz x Personentage, gewichtet, formatiert | | `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | | `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | | `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
+2 -2
View File
@@ -20,7 +20,7 @@ Quelltext.
| **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. | | **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. | | **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). | | **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
| **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. | | **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
@@ -31,7 +31,7 @@ Quelltext.
| **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. | | **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. |
| **Schwellenwert Benachrichtigung** | `match_threshold` - ab hier gibt es Signalton und E-Mail. | | **Schwellenwert Benachrichtigung** | `match_threshold` - ab hier gibt es Signalton und E-Mail. |
| **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. | | **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heißt zusätzlich mit der Abschlusswahrscheinlichkeit multipliziert. | | **Volumen** | Tagessatz x Personentage - oder der **Projektpreis**, wenn einer eingetragen ist. **Gewichtet** heißt zusätzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Whisper** | Das Spracherkennungsmodell für das Diktat. Läuft im Browser, nicht auf dem Server. | | **Whisper** | Das Spracherkennungsmodell für das Diktat. Läuft im Browser, nicht auf dem Server. |
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfächer - ohne sie gilt sie tenant-weit. | | **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfächer - ohne sie gilt sie tenant-weit. |
| **Zuständiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie ändern, auch wenn er sie nicht angelegt hat. | | **Zuständiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie ändern, auch wenn er sie nicht angelegt hat. |
+8 -4
View File
@@ -16,7 +16,7 @@ const STAGES = new Set([
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes']; const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at']; const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at'];
const NUMBER_FIELDS = ['daily_rate', 'duration_days']; const NUMBER_FIELDS = ['daily_rate', 'duration_days', 'fixed_price'];
function emptyToNull(value) { function emptyToNull(value) {
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
@@ -85,6 +85,7 @@ function mapOpportunity(row, contacts) {
...row, ...row,
daily_rate: row.daily_rate === null ? null : Number(row.daily_rate), daily_rate: row.daily_rate === null ? null : Number(row.daily_rate),
duration_days: row.duration_days === null ? null : Number(row.duration_days), duration_days: row.duration_days === null ? null : Number(row.duration_days),
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
probability: Number(row.probability), probability: Number(row.probability),
tags: parseTags(row.tags), tags: parseTags(row.tags),
contacts: contacts ?? [], contacts: contacts ?? [],
@@ -220,6 +221,7 @@ router.post('/', async (req, res, next) => {
for (const [field, label, max] of [ for (const [field, label, max] of [
['daily_rate', 'Der Tagessatz', 1_000_000], ['daily_rate', 'Der Tagessatz', 1_000_000],
['duration_days', 'Die Laufzeit', 100_000], ['duration_days', 'Die Laufzeit', 100_000],
['fixed_price', 'Der Projektpreis', 1_000_000_000],
]) { ]) {
const result = normalizeNumber(req.body?.[field], label, max); const result = normalizeNumber(req.body?.[field], label, max);
if (result.error) return res.status(400).json({ error: result.error }); if (result.error) return res.status(400).json({ error: result.error });
@@ -253,9 +255,9 @@ router.post('/', async (req, res, next) => {
const [result] = await conn.query( const [result] = await conn.query(
`INSERT INTO crm_opportunities `INSERT INTO crm_opportunities
(title, company, description, stage, probability, daily_rate, duration_days, (title, company, description, stage, probability, daily_rate, duration_days,
start_date, end_date, last_interaction_at, last_interaction_note, next_step, fixed_price, start_date, end_date, last_interaction_at, last_interaction_note,
tags, notes, owner_id, created_by) next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
title, title,
emptyToNull(req.body?.company), emptyToNull(req.body?.company),
@@ -264,6 +266,7 @@ router.post('/', async (req, res, next) => {
Math.round(probability.value ?? 10), Math.round(probability.value ?? 10),
numbers.daily_rate, numbers.daily_rate,
numbers.duration_days, numbers.duration_days,
numbers.fixed_price,
dates.start_date, dates.start_date,
dates.end_date, dates.end_date,
dates.last_interaction_at, dates.last_interaction_at,
@@ -334,6 +337,7 @@ router.patch('/:id', async (req, res, next) => {
for (const [field, label, max] of [ for (const [field, label, max] of [
['daily_rate', 'Der Tagessatz', 1_000_000], ['daily_rate', 'Der Tagessatz', 1_000_000],
['duration_days', 'Die Laufzeit', 100_000], ['duration_days', 'Die Laufzeit', 100_000],
['fixed_price', 'Der Projektpreis', 1_000_000_000],
]) { ]) {
if (req.body?.[field] === undefined) continue; if (req.body?.[field] === undefined) continue;
const result = normalizeNumber(req.body[field], label, max); const result = normalizeNumber(req.body[field], label, max);
+5
View File
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 % probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
duration_days DECIMAL(6,1) NULL, -- Personentage, halbe moeglich duration_days DECIMAL(6,1) NULL, -- Personentage, halbe moeglich
fixed_price DECIMAL(12,2) NULL, -- Festpreis; Alternative zu Tagessatz x Laufzeit
start_date DATE NULL, start_date DATE NULL,
end_date DATE NULL, end_date DATE NULL,
last_interaction_at DATE NULL, last_interaction_at DATE NULL,
@@ -137,6 +138,10 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
CONSTRAINT fk_crm_opp_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL CONSTRAINT fk_crm_opp_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Festpreisprojekte rechnen nicht ueber Tagessatz x Personentage. Ist der
-- Projektpreis gesetzt, gilt er als Volumen (siehe opportunityVolume()).
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS fixed_price DECIMAL(12,2) NULL AFTER duration_days;
CREATE TABLE IF NOT EXISTS crm_opportunity_contacts ( CREATE TABLE IF NOT EXISTS crm_opportunity_contacts (
opportunity_id INT UNSIGNED NOT NULL, opportunity_id INT UNSIGNED NOT NULL,
lead_id INT UNSIGNED NOT NULL, lead_id INT UNSIGNED NOT NULL,
+9 -1
View File
@@ -23,6 +23,12 @@ interface LeadModalProps {
connections: LeadConnection[]; connections: LeadConnection[];
onClose: () => void; onClose: () => void;
onSaved: () => 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 { interface FormState {
@@ -83,6 +89,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
connections, connections,
onClose, onClose,
onSaved, onSaved,
onCreated,
}) => { }) => {
const { addToast } = useToast(); const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null)); 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); await leadsApi.update(lead.id, payload);
addToast('Kontakt aktualisiert.', 'success'); addToast('Kontakt aktualisiert.', 'success');
} else { } else {
await leadsApi.create(payload); const created = await leadsApi.create(payload);
addToast('Kontakt angelegt.', 'success'); addToast('Kontakt angelegt.', 'success');
onCreated?.(created);
} }
onSaved(); onSaved();
onClose(); onClose();
@@ -108,7 +108,12 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
{/* Projektdaten */} {/* Projektdaten */}
<div className="grid grid-cols-2 gap-3 border-t border-ink-100 pt-3"> <div className="grid grid-cols-2 gap-3 border-t border-ink-100 pt-3">
<Metric label="Volumen" value={formatEuro(opportunityVolume(opportunity))} /> <Metric label="Volumen" value={formatEuro(opportunityVolume(opportunity))} />
{/* 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="Tagessatz" value={formatEuro(opportunity.daily_rate)} />
)}
<Metric label="Laufzeit" value={formatDays(opportunity.duration_days)} /> <Metric label="Laufzeit" value={formatDays(opportunity.duration_days)} />
<Metric <Metric
label="Zeitraum" label="Zeitraum"
@@ -178,7 +178,15 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
{formatEuro(opportunityVolume(opportunity))} {formatEuro(opportunityVolume(opportunity))}
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700"> <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>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700"> <td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700">
{formatDays(opportunity.duration_days)} {formatDays(opportunity.duration_days)}
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { LeadModal } from '../leads/LeadModal';
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField'; import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
import { Avatar, inputClass, labelClass } from '../leads/leadUi'; import { Avatar, inputClass, labelClass } from '../leads/leadUi';
import { STAGE_COLORS } from './opportunityUi'; import { STAGE_COLORS } from './opportunityUi';
@@ -39,6 +40,7 @@ interface FormState {
probability: string; probability: string;
daily_rate: string; daily_rate: string;
duration_days: string; duration_days: string;
fixed_price: string;
start_date: string; start_date: string;
end_date: string; end_date: string;
last_interaction_at: string; last_interaction_at: string;
@@ -63,6 +65,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number |
probability: String(opportunity?.probability ?? 10), probability: String(opportunity?.probability ?? 10),
daily_rate: numberToInput(opportunity?.daily_rate ?? null), daily_rate: numberToInput(opportunity?.daily_rate ?? null),
duration_days: numberToInput(opportunity?.duration_days ?? null), duration_days: numberToInput(opportunity?.duration_days ?? null),
fixed_price: numberToInput(opportunity?.fixed_price ?? null),
start_date: opportunity?.start_date?.slice(0, 10) ?? '', start_date: opportunity?.start_date?.slice(0, 10) ?? '',
end_date: opportunity?.end_date?.slice(0, 10) ?? '', end_date: opportunity?.end_date?.slice(0, 10) ?? '',
last_interaction_at: opportunity?.last_interaction_at?.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 ?? '' })) ?? [] () => opportunity?.contacts.map((c) => ({ lead_id: c.lead_id, role: c.role ?? '' })) ?? []
); );
const [contactToAdd, setContactToAdd] = useState(''); 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 [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -102,18 +107,28 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')); .sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de'));
}, [leads, contacts]); }, [leads, contacts]);
// Volumen live mitrechnen, damit die Wirkung von Tagessatz und Laufzeit // Volumen live mitrechnen, damit die Wirkung von Tagessatz, Laufzeit und
// sofort sichtbar ist. // Festpreis sofort sichtbar ist. Ein gesetzter Projektpreis gewinnt - genau
// wie in opportunityVolume().
const preview = useMemo(() => { 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; const probability = Number(form.probability) || 0;
return { volume, weighted: (volume * probability) / 100 }; const toNumber = (value: string) => Number(value.replace(',', '.'));
}, [form.daily_rate, form.duration_days, form.probability]);
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 addTags = () => {
const parsed = tagInput const parsed = tagInput
@@ -152,6 +167,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
probability: Number(form.probability) || 0, probability: Number(form.probability) || 0,
daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'), daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'),
duration_days: form.duration_days.trim() === '' ? null : form.duration_days.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, start_date: form.start_date || null,
end_date: form.end_date || null, end_date: form.end_date || null,
last_interaction_at: form.last_interaction_at || null, last_interaction_at: form.last_interaction_at || null,
@@ -183,6 +199,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
}; };
return ( return (
<>
<Modal <Modal
title={opportunity ? opportunity.title : 'Neue Opportunity'} title={opportunity ? opportunity.title : 'Neue Opportunity'}
subtitle={ 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"> <h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Projektdaten Projektdaten
</h3> </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> <div>
<label className={labelClass} htmlFor="opp-rate"> <label className={labelClass} htmlFor="opp-rate">
Tagessatz (EUR) Tagessatz (EUR)
@@ -310,6 +327,26 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
className={inputClass} className={inputClass}
/> />
</div> </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> <div>
<label className={labelClass} htmlFor="opp-start"> <label className={labelClass} htmlFor="opp-start">
Projektstart 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 className="mt-4 flex flex-wrap gap-6 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3">
<div> <div>
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400"> <p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Volumen Volumen {preview.isFixed ? '(Festpreis)' : '(Tagessatz × Laufzeit)'}
</p> </p>
<p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p> <p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p>
</div> </div>
@@ -399,6 +436,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
</h3> </h3>
<p className="mb-4 text-xs text-ink-400"> <p className="mb-4 text-xs text-ink-400">
Kontakte aus dem CRM; die Rolle ist Freitext, z. B. Entscheider oder Fachbereich. 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> </p>
<div className="space-y-2"> <div className="space-y-2">
@@ -486,6 +524,13 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
> >
Hinzufügen Hinzufügen
</button> </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>
</div> </div>
</section> </section>
@@ -628,5 +673,29 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
)} )}
</form> </form>
</Modal> </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}
/>
)}
</>
); );
}; };
+67 -14
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef } from 'react';
interface ModalProps { interface ModalProps {
title: string; title: string;
@@ -15,6 +15,57 @@ const SIZES: Record<NonNullable<ModalProps['size']>, string> = {
xl: 'max-w-4xl', 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> = ({ export const Modal: React.FC<ModalProps> = ({
title, title,
subtitle, subtitle,
@@ -23,18 +74,13 @@ export const Modal: React.FC<ModalProps> = ({
footer, footer,
size = 'lg', size = 'lg',
}) => { }) => {
// Escape schliesst den Dialog - erwartetes Verhalten und schneller als const dismiss = useDismiss(onClose);
// 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]);
return ( 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 <div
className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-lift ${SIZES[size]}`} 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" role="dialog"
@@ -81,8 +127,14 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
confirmLabel = 'Löschen', confirmLabel = 'Löschen',
onConfirm, onConfirm,
onCancel, onCancel,
}) => ( }) => {
<div className="fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm"> 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="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"> <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"> <svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -112,4 +164,5 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</div> </div>
</div> </div>
</div> </div>
); );
};
+7 -1
View File
@@ -210,6 +210,8 @@ export interface Opportunity {
daily_rate: number | null; daily_rate: number | null;
/** Laufzeit in Personentagen */ /** Laufzeit in Personentagen */
duration_days: number | null; duration_days: number | null;
/** Festpreis in Euro; ersetzt die Rechnung aus Tagessatz und Laufzeit */
fixed_price: number | null;
start_date: string | null; start_date: string | null;
end_date: string | null; end_date: string | null;
last_interaction_at: string | null; last_interaction_at: string | null;
@@ -231,8 +233,12 @@ export interface Opportunity {
export type OpportunityViewMode = 'grid' | 'list' | 'pipeline'; 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 { 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; if (opportunity.daily_rate === null || opportunity.duration_days === null) return null;
return opportunity.daily_rate * opportunity.duration_days; return opportunity.daily_rate * opportunity.duration_days;
} }