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>
558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
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="">—</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>
|
||
);
|
||
};
|