Files
perinco-CRM/src/components/leads/LeadModal.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

558 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker';
import { Avatar, inputClass, labelClass } from './leadUi';
import { connectionsApi, leadsApi } from '../../lib/api';
import { useToast } from '../Toast';
import {
CONNECTION_TYPES,
LEAD_STATUSES,
leadFullName,
leadInitials,
type AppUser,
type ConnectionType,
type Lead,
type LeadConnection,
type LeadStatus,
} from '../../types';
interface LeadModalProps {
lead: Lead | null;
leads: Lead[];
users: AppUser[];
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 {
salutation: string;
first_name: string;
last_name: string;
company: string;
position: string;
email: string;
phone: string;
mobile: string;
linkedin: string;
website: string;
street: string;
zip: string;
city: string;
country: string;
status: LeadStatus;
source: string;
notes: string;
avatar: string;
owner_id: string;
tags: string[];
/** Einverstaendnis in Werbemails - Voraussetzung fuer die Mailingliste. */
mail_consent: boolean;
}
function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormState {
return {
salutation: lead?.salutation ?? '',
first_name: lead?.first_name ?? '',
last_name: lead?.last_name ?? '',
company: lead?.company ?? '',
position: lead?.position ?? '',
email: lead?.email ?? '',
phone: lead?.phone ?? '',
mobile: lead?.mobile ?? '',
linkedin: lead?.linkedin ?? '',
website: lead?.website ?? '',
street: lead?.street ?? '',
zip: lead?.zip ?? '',
city: lead?.city ?? '',
country: lead?.country ?? '',
status: lead?.status ?? 'Neu',
source: lead?.source ?? '',
notes: lead?.notes ?? '',
avatar: lead?.avatar ?? '',
owner_id: String(lead?.owner_id ?? fallbackOwnerId ?? ''),
tags: lead?.tags ?? [],
mail_consent: lead?.mail_consent ?? false,
};
}
export const LeadModal: React.FC<LeadModalProps> = ({
lead,
leads,
users,
connections,
onClose,
onSaved,
onCreated,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null));
const [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false);
// Verbindungen dieses Kontakts
const [newConnectionId, setNewConnectionId] = useState('');
const [newConnectionType, setNewConnectionType] = useState<ConnectionType>('Netzwerk');
const [isLinking, setIsLinking] = useState(false);
const leadById = useMemo(() => new Map(leads.map((l) => [l.id, l])), [leads]);
const ownConnections = useMemo(() => {
if (!lead) return [];
return connections
.filter((c) => c.from_lead_id === lead.id || c.to_lead_id === lead.id)
.map((c) => {
const otherId = c.from_lead_id === lead.id ? c.to_lead_id : c.from_lead_id;
return { connection: c, other: leadById.get(otherId) ?? null };
})
.filter((entry) => entry.other !== null);
}, [lead, connections, leadById]);
const linkableLeads = useMemo(() => {
if (!lead) return [];
const linked = new Set(ownConnections.map((entry) => entry.other!.id));
return leads
.filter((candidate) => candidate.id !== lead.id && !linked.has(candidate.id))
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de'));
}, [lead, leads, ownConnections]);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const addTags = () => {
const parsed = tagInput
.split(',')
.map((t) => t.trim())
.filter((t) => t && !form.tags.includes(t));
if (parsed.length > 0) set('tags', [...form.tags, ...parsed]);
setTagInput('');
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.first_name.trim() || !form.last_name.trim()) {
addToast('Vorname und Nachname sind erforderlich.', 'error');
return;
}
if (form.linkedin && !form.linkedin.includes('linkedin.com/')) {
addToast('Bitte eine gültige LinkedIn-URL angeben.', 'error');
return;
}
setIsSaving(true);
try {
const payload = {
...form,
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
avatar: form.avatar || null,
};
if (lead) {
await leadsApi.update(lead.id, payload);
addToast('Kontakt aktualisiert.', 'success');
} else {
const created = await leadsApi.create(payload);
addToast('Kontakt angelegt.', 'success');
onCreated?.(created);
}
onSaved();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const handleAddConnection = async () => {
if (!lead || !newConnectionId) return;
setIsLinking(true);
try {
await connectionsApi.create({
from_lead_id: lead.id,
to_lead_id: Number(newConnectionId),
type: newConnectionType,
});
addToast('Verbindung angelegt.', 'success');
setNewConnectionId('');
onSaved();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.', 'error');
} finally {
setIsLinking(false);
}
};
const handleRemoveConnection = async (id: number) => {
try {
await connectionsApi.remove(id);
addToast('Verbindung entfernt.', 'success');
onSaved();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
}
};
return (
<Modal
title={lead ? leadFullName(lead) : 'Neuer Kontakt'}
subtitle={
lead
? `Angelegt von ${lead.created_by_name ?? 'unbekannt'}${
lead.created_at ? ` am ${new Date(lead.created_at).toLocaleDateString('de-DE')}` : ''
}`
: 'Pflege einen neuen Lead-Kontakt ein'
}
onClose={onClose}
size="xl"
footer={
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
type="submit"
form="lead-form"
disabled={isSaving}
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Speichere...' : 'Speichern'}
</button>
</div>
}
>
<form id="lead-form" onSubmit={handleSubmit} className="space-y-8">
{/* Foto + Kerndaten */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<AvatarPicker
value={form.avatar}
onChange={(value) => set('avatar', value)}
initials={leadInitials({ first_name: form.first_name, last_name: form.last_name })}
/>
<div className="grid flex-1 grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Anrede</label>
<select value={form.salutation} onChange={(e) => set('salutation', e.target.value)} className={inputClass}>
<option value="">&mdash;</option>
<option value="Frau">Frau</option>
<option value="Herr">Herr</option>
<option value="Divers">Divers</option>
</select>
</div>
<div>
<label className={labelClass}>Status</label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value as LeadStatus)}
className={inputClass}
>
{LEAD_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Vorname *</label>
<input required value={form.first_name} onChange={(e) => set('first_name', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Nachname *</label>
<input required value={form.last_name} onChange={(e) => set('last_name', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Unternehmen</label>
<input value={form.company} onChange={(e) => set('company', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Position</label>
<input value={form.position} onChange={(e) => set('position', e.target.value)} className={inputClass} />
</div>
</div>
</div>
{/* Kontaktdaten */}
<section>
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Kontaktdaten
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>E-Mail</label>
<input type="email" value={form.email} onChange={(e) => set('email', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Telefon</label>
<input value={form.phone} onChange={(e) => set('phone', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>Mobil</label>
<input value={form.mobile} onChange={(e) => set('mobile', e.target.value)} className={inputClass} />
</div>
<div>
<label className={labelClass}>LinkedIn</label>
<input
type="url"
placeholder="https://linkedin.com/in/..."
value={form.linkedin}
onChange={(e) => set('linkedin', e.target.value)}
className={inputClass}
/>
</div>
<div className="sm:col-span-2">
<label className={labelClass}>Website</label>
<input type="url" value={form.website} onChange={(e) => set('website', e.target.value)} className={inputClass} />
</div>
{/*
* Einverstaendnis in Werbemails. Steht bewusst direkt unter der
* E-Mail-Adresse und nicht bei den Tags: es gehoert zur Adresse und
* entscheidet, ob sie in eine Mailingliste darf.
*/}
<div className="sm:col-span-2">
<label
className={`flex cursor-pointer items-start gap-3 rounded-xl border px-4 py-3 transition ${
form.mail_consent
? 'border-brand-200 bg-brand-50'
: 'border-ink-200 bg-surface hover:bg-ink-50'
}`}
>
<input
type="checkbox"
checked={form.mail_consent}
onChange={(e) => set('mail_consent', e.target.checked)}
disabled={!form.email.trim()}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25 disabled:opacity-40"
/>
<span className="min-w-0">
<span className="block text-sm font-bold text-ink-900">Einverständnis für Mails</span>
<span className="block text-xs leading-relaxed text-ink-500">
{form.email.trim()
? 'Diese Adresse darf für Mailings verwendet werden und erscheint in der Mailingliste.'
: 'Erst eine E-Mail-Adresse eintragen – ohne Adresse gibt es nichts einzuwilligen.'}
{lead?.mail_consent && lead.mail_consent_at && (
<>
{' '}
Erteilt am{' '}
{new Date(lead.mail_consent_at.replace(' ', 'T')).toLocaleDateString('de-DE')}.
</>
)}
</span>
</span>
</label>
</div>
</div>
</section>
{/* Adresse */}
<section>
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Adresse</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-6">
<label className={labelClass}>Straße und Hausnummer</label>
<input value={form.street} onChange={(e) => set('street', e.target.value)} className={inputClass} />
</div>
<div className="sm:col-span-1">
<label className={labelClass}>PLZ</label>
<input value={form.zip} onChange={(e) => set('zip', e.target.value)} className={inputClass} />
</div>
<div className="sm:col-span-3">
<label className={labelClass}>Ort</label>
<input value={form.city} onChange={(e) => set('city', e.target.value)} className={inputClass} />
</div>
<div className="sm:col-span-2">
<label className={labelClass}>Land</label>
<input value={form.country} onChange={(e) => set('country', e.target.value)} className={inputClass} />
</div>
</div>
</section>
{/* Einordnung */}
<section>
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Einordnung
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Quelle</label>
<input
placeholder="z. B. Messe, Empfehlung, LinkedIn"
value={form.source}
onChange={(e) => set('source', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Zuständig</label>
<select value={form.owner_id} onChange={(e) => set('owner_id', e.target.value)} className={inputClass}>
<option value="">Niemand</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
</div>
<div className="mt-4">
<label className={labelClass}>Tags</label>
{form.tags.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-2 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-bold text-white"
>
{tag}
<button
type="button"
onClick={() => set('tags', form.tags.filter((t) => t !== tag))}
className="transition hover:text-white/70"
aria-label={`Tag ${tag} entfernen`}
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTags();
}
}}
placeholder="Mit Komma trennen, z. B. S/4HANA, M365"
className={inputClass}
/>
<button
type="button"
onClick={addTags}
className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
Hinzufügen
</button>
</div>
</div>
<div className="mt-4">
<label className={labelClass}>Notizen</label>
<textarea
rows={4}
value={form.notes}
onChange={(e) => set('notes', e.target.value)}
placeholder="Gesprächsnotizen, nächste Schritte, Hintergrund..."
className={`${inputClass} resize-none`}
/>
</div>
</section>
{/* Verbindungen */}
<section>
<h3 className="mb-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Verbindungen
</h3>
<p className="mb-4 text-xs text-ink-400">
Wer steht mit diesem Kontakt in Verbindung? Diese Kanten erscheinen in der Netzwerkansicht.
</p>
{!lead ? (
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
Verbindungen können gepflegt werden, sobald der Kontakt gespeichert ist.
</div>
) : (
<div className="space-y-3">
{ownConnections.length === 0 ? (
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
Noch keine Verbindungen hinterlegt.
</div>
) : (
<ul className="space-y-2">
{ownConnections.map(({ connection, other }) => (
<li
key={connection.id}
className="flex items-center gap-3 rounded-xl border border-ink-200 bg-ink-50/50 px-3 py-2.5"
>
<Avatar src={other!.avatar} initials={leadInitials(other!)} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-ink-900">{leadFullName(other!)}</p>
<p className="truncate text-xs text-ink-400">
{connection.type}
{other!.company ? ` · ${other!.company}` : ''}
</p>
</div>
<button
type="button"
onClick={() => handleRemoveConnection(connection.id)}
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
aria-label="Verbindung entfernen"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
<div className="flex flex-col gap-2 rounded-xl border border-ink-200 p-3 sm:flex-row">
<select
value={newConnectionId}
onChange={(e) => setNewConnectionId(e.target.value)}
className={`${inputClass} sm:flex-[2]`}
>
<option value="">Kontakt auswählen...</option>
{linkableLeads.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{leadFullName(candidate)}
{candidate.company ? ` (${candidate.company})` : ''}
</option>
))}
</select>
<select
value={newConnectionType}
onChange={(e) => setNewConnectionType(e.target.value as ConnectionType)}
className={`${inputClass} sm:flex-1`}
>
{CONNECTION_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<button
type="button"
onClick={handleAddConnection}
disabled={!newConnectionId || isLinking}
className="shrink-0 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
Verbinden
</button>
</div>
</div>
)}
</section>
</form>
</Modal>
);
};