INSIGHT-MVP/packages/frontend/src/crm/deals/DealFormModal.tsx
Thomas Reitz 6bfce4af97 feat(crm): Vorgangsart (DealType) konfigurierbares Dropdown + Pipeline-Leerstate
- crm-service: Neues DealType-Model (deal_types Tabelle) mit name, color, sortOrder
  und Relation zu Deal.dealTypeId; Migration 20260313_deal_type
- crm-service: Vollstaendiger CRUD REST-Endpoint /crm/deal-types (TenantGuard)
- crm-service: CreateDealDto um optionales dealTypeId erweitert
- frontend: DealType Interface, API (dealTypesApi), Hooks (useDealTypes/...)
- frontend: CrmSettingsPage > Weitere Einstellungen > DealTypesConfig mit Farbpicker
- frontend: DealFormModal: Vorgangsart-Dropdown + Hinweis bei leerer Pipeline-Liste

Deployment: prisma migrate deploy && prisma generate im crm-service ausfuehren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 16:15:02 +01:00

795 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useState, useEffect, useRef, useCallback } from 'react';
import { Modal } from '../../components/Modal';
import { useCreateDeal, useUpdateDeal, usePipelines, useDealTypes, useSetCustomFieldValues } from '../hooks';
import { contactsApi, companiesApi } from '../api';
import { CustomFieldsForm } from '../CustomFieldsForm';
import type { Deal, DealStatus, LostReason, Contact, Company, CustomFieldValue } from '../types';
import { LOST_REASON_LABELS } from '../types';
interface DealFormModalProps {
isOpen: boolean;
onClose: () => void;
deal?: Deal | null;
onSuccess: () => void;
}
const STATUS_OPTIONS: { value: DealStatus; label: string }[] = [
{ value: 'OPEN', label: 'Offen' },
{ value: 'WON', label: 'Gewonnen' },
{ value: 'LOST', label: 'Verloren' },
];
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
};
const rowStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
};
export function DealFormModal({
isOpen,
onClose,
deal,
onSuccess,
}: DealFormModalProps) {
const isEditMode = !!deal;
const createMutation = useCreateDeal();
const updateMutation = useUpdateDeal();
const setCustomFieldValues = useSetCustomFieldValues();
const mutation = isEditMode ? updateMutation : createMutation;
const customFieldValuesRef = useRef<Record<string, string | number | boolean | string[] | null>>({});
const customFields: CustomFieldValue[] = deal?.customFields ?? [];
const handleCustomFieldsChange = useCallback(
(values: Record<string, string | number | boolean | string[] | null>) => {
customFieldValuesRef.current = values;
},
[],
);
const { data: pipelinesData } = usePipelines();
const pipelines = pipelinesData?.data ?? [];
const { data: dealTypesData } = useDealTypes();
const dealTypes = dealTypesData?.data ?? [];
const [error, setError] = useState('');
const [title, setTitle] = useState('');
const [pipelineId, setPipelineId] = useState('');
const [stageId, setStageId] = useState('');
const [status, setStatus] = useState<DealStatus>('OPEN');
const [value, setValue] = useState('');
const [currency, setCurrency] = useState('EUR');
const [expectedCloseDate, setExpectedCloseDate] = useState('');
const [notes, setNotes] = useState('');
const [lostReason, setLostReason] = useState<LostReason | ''>('');
const [lostReasonText, setLostReasonText] = useState('');
const [dealTypeId, setDealTypeId] = useState('');
// Kontakt-Suche
const [contactSearch, setContactSearch] = useState('');
const [contactResults, setContactResults] = useState<Contact[]>([]);
const [selectedContact, setSelectedContact] = useState<{
id: string;
name: string;
} | null>(null);
const [showContactDropdown, setShowContactDropdown] = useState(false);
const contactRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Unternehmen-Suche
const [companySearch, setCompanySearch] = useState('');
const [companyResults, setCompanyResults] = useState<Company[]>([]);
const [selectedCompany, setSelectedCompany] = useState<{
id: string;
name: string;
} | null>(null);
const [showCompanyDropdown, setShowCompanyDropdown] = useState(false);
const companyRef = useRef<HTMLDivElement>(null);
const companySearchTimeout = useRef<ReturnType<typeof setTimeout>>();
// Stages der gewaehlten Pipeline
const selectedPipeline = pipelines.find((p) => p.id === pipelineId);
const stages = selectedPipeline?.stages
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
: [];
// Click-Outside für Kontakt- und Unternehmen-Dropdown
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
contactRef.current &&
!contactRef.current.contains(e.target as Node)
) {
setShowContactDropdown(false);
}
if (
companyRef.current &&
!companyRef.current.contains(e.target as Node)
) {
setShowCompanyDropdown(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Kontakt suchen (debounced)
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
if (!contactSearch || contactSearch.length < 2) {
setContactResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await contactsApi.list({
search: contactSearch,
pageSize: 8,
});
setContactResults(res.data);
setShowContactDropdown(true);
} catch {
setContactResults([]);
}
}, 300);
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, [contactSearch]);
// Unternehmen suchen (debounced)
useEffect(() => {
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
if (!companySearch || companySearch.length < 2) {
setCompanyResults([]);
return;
}
companySearchTimeout.current = setTimeout(async () => {
try {
const res = await companiesApi.list({
search: companySearch,
pageSize: 8,
});
setCompanyResults(res.data);
setShowCompanyDropdown(true);
} catch {
setCompanyResults([]);
}
}, 300);
return () => {
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
};
}, [companySearch]);
useEffect(() => {
if (isOpen) {
setError('');
if (deal) {
setTitle(deal.title);
setPipelineId(deal.pipelineId);
setStageId(deal.stageId);
setStatus(deal.status);
setValue(deal.value ? String(parseFloat(deal.value)) : '');
setCurrency(deal.currency ?? 'EUR');
setExpectedCloseDate(
deal.expectedCloseDate
? deal.expectedCloseDate.slice(0, 10)
: '',
);
setNotes(deal.notes ?? '');
setLostReason((deal.lostReason as LostReason) ?? '');
setLostReasonText(deal.lostReasonText ?? '');
setDealTypeId(deal.dealTypeId ?? '');
if (deal.contact) {
const { id, firstName, lastName, companyName } = deal.contact;
const name =
companyName ||
[firstName, lastName].filter(Boolean).join(' ') ||
'Kontakt';
setSelectedContact({ id, name });
setContactSearch(name);
} else {
setSelectedContact(null);
setContactSearch('');
}
if (deal.company) {
setSelectedCompany({ id: deal.company.id, name: deal.company.name });
setCompanySearch(deal.company.name);
} else {
setSelectedCompany(null);
setCompanySearch('');
}
} else {
setTitle('');
setPipelineId(pipelines.find((p) => p.isDefault)?.id ?? pipelines[0]?.id ?? '');
setStageId('');
setStatus('OPEN');
setValue('');
setCurrency('EUR');
setExpectedCloseDate('');
setNotes('');
setLostReason('');
setLostReasonText('');
setDealTypeId('');
setSelectedContact(null);
setContactSearch('');
setSelectedCompany(null);
setCompanySearch('');
}
setContactResults([]);
setShowContactDropdown(false);
setCompanyResults([]);
setShowCompanyDropdown(false);
}
}, [isOpen, deal, pipelines]);
// Wenn Pipeline wechselt, erste Stage auswaehlen
useEffect(() => {
if (!isEditMode && stages.length > 0 && !stages.find((s) => s.id === stageId)) {
setStageId(stages[0].id);
}
}, [pipelineId, stages, stageId, isEditMode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!title.trim()) {
setError('Titel ist ein Pflichtfeld');
return;
}
if (!pipelineId) {
setError('Pipeline auswählen');
return;
}
if (!stageId) {
setError('Stage auswählen');
return;
}
if (status === 'LOST' && !lostReason) {
setError('Bitte einen Grund fuer den Verlust angeben');
return;
}
const payload = {
title: title.trim(),
pipelineId,
stageId,
status,
...(selectedContact ? { contactId: selectedContact.id } : {}),
...(selectedCompany ? { companyId: selectedCompany.id } : {}),
...(value ? { value: parseFloat(value) } : {}),
currency,
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
...(notes ? { notes } : {}),
...(status === 'LOST' && lostReason ? { lostReason } : {}),
...(status === 'LOST' && lostReasonText ? { lostReasonText } : {}),
...(dealTypeId ? { dealTypeId } : {}),
};
const saveCustomFields = (entityId: string) => {
const vals = customFieldValuesRef.current;
const entries = Object.entries(vals);
if (entries.length === 0) return;
setCustomFieldValues.mutate({
entityId,
data: {
values: entries.map(([fieldDefId, value]) => ({ fieldDefId, value })),
},
});
};
if (isEditMode && deal) {
updateMutation.mutate(
{ id: deal.id, data: payload },
{
onSuccess: () => {
saveCustomFields(deal.id);
onSuccess();
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
} else {
createMutation.mutate(payload, {
onSuccess: (res) => {
if (res?.data?.id) saveCustomFields(res.data.id);
onSuccess();
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
setError(msg);
},
});
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? 'Vorgang bearbeiten' : 'Neuer Vorgang'}
maxWidth="600px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
{/* Titel */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Titel *</label>
<input
style={inputStyle}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Vorgangs-Titel"
required
/>
</div>
{/* Pipeline leer Hinweis */}
{pipelines.length === 0 && (
<div
style={{
padding: '0.75rem',
background: 'var(--color-warning-bg, #fffbeb)',
border: '1px solid var(--color-warning-border, #fde68a)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-warning, #92400e)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
Keine Pipelines vorhanden. Bitte zuerst unter{' '}
<a href="/crm/settings" style={{ color: 'inherit', fontWeight: 600 }}>
CRM&nbsp;Einstellungen&nbsp;&nbsp;Pipelines
</a>{' '}
eine Pipeline anlegen.
</div>
)}
{/* Pipeline + Stage */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Pipeline *</label>
<select
value={pipelineId}
onChange={(e) => setPipelineId(e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="">Pipeline wählen</option>
{pipelines
.filter((p) => p.isActive)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? ' (Standard)' : ''}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Stage *</label>
<select
value={stageId}
onChange={(e) => setStageId(e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
disabled={stages.length === 0}
>
<option value="">Stage wählen</option>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
</div>
{/* Vorgangsart */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Vorgangsart</label>
<select
value={dealTypeId}
onChange={(e) => setDealTypeId(e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value=""> Keine </option>
{dealTypes.map((dt) => (
<option key={dt.id} value={dt.id}>
{dt.name}
</option>
))}
</select>
{dealTypes.length === 0 && (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
Keine Vorgangsarten konfiguriert.{' '}
<a href="/crm/settings" style={{ color: 'var(--color-primary)' }}>
In CRM Einstellungen anlegen
</a>
</p>
)}
</div>
{/* Kontakt-Suche */}
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={contactRef}>
<label style={labelStyle}>Kontakt</label>
<input
style={inputStyle}
value={contactSearch}
onChange={(e) => {
setContactSearch(e.target.value);
if (selectedContact) setSelectedContact(null);
}}
onFocus={() => {
if (contactResults.length > 0) setShowContactDropdown(true);
}}
placeholder="Kontakt suchen..."
/>
{selectedContact && (
<button
type="button"
onClick={() => {
setSelectedContact(null);
setContactSearch('');
}}
style={{
position: 'absolute',
right: 8,
top: 30,
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '1rem',
}}
>
×
</button>
)}
{showContactDropdown && contactResults.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-md)',
zIndex: 10,
maxHeight: 200,
overflowY: 'auto',
}}
>
{contactResults.map((c) => {
const name =
c.type === 'ORGANIZATION'
? c.companyName
: [c.firstName, c.lastName].filter(Boolean).join(' ');
return (
<div
key={c.id}
onClick={() => {
setSelectedContact({ id: c.id, name: name ?? '' });
setContactSearch(name ?? '');
setShowContactDropdown(false);
}}
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontSize: '0.875rem',
borderBottom: '1px solid var(--color-border)',
}}
onMouseEnter={(e) =>
((e.target as HTMLDivElement).style.background =
'var(--color-bg)')
}
onMouseLeave={(e) =>
((e.target as HTMLDivElement).style.background =
'transparent')
}
>
{name}
{c.email && (
<span
style={{
color: 'var(--color-text-muted)',
marginLeft: '0.5rem',
fontSize: '0.8125rem',
}}
>
{c.email}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* Unternehmen-Suche */}
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={companyRef}>
<label style={labelStyle}>Unternehmen</label>
<input
style={inputStyle}
value={companySearch}
onChange={(e) => {
setCompanySearch(e.target.value);
if (selectedCompany) setSelectedCompany(null);
}}
onFocus={() => {
if (companyResults.length > 0) setShowCompanyDropdown(true);
}}
placeholder="Unternehmen suchen..."
/>
{selectedCompany && (
<button
type="button"
onClick={() => {
setSelectedCompany(null);
setCompanySearch('');
}}
style={{
position: 'absolute',
right: 8,
top: 30,
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '1rem',
}}
>
×
</button>
)}
{showCompanyDropdown && companyResults.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-md)',
zIndex: 10,
maxHeight: 200,
overflowY: 'auto',
}}
>
{companyResults.map((comp) => (
<div
key={comp.id}
onClick={() => {
setSelectedCompany({ id: comp.id, name: comp.name });
setCompanySearch(comp.name);
setShowCompanyDropdown(false);
}}
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontSize: '0.875rem',
borderBottom: '1px solid var(--color-border)',
}}
onMouseEnter={(e) =>
((e.target as HTMLDivElement).style.background =
'var(--color-bg)')
}
onMouseLeave={(e) =>
((e.target as HTMLDivElement).style.background =
'transparent')
}
>
{comp.name}
{comp.industry && (
<span
style={{
color: 'var(--color-text-muted)',
marginLeft: '0.5rem',
fontSize: '0.8125rem',
}}
>
{comp.industry}
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Wert + Währung */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Wert</label>
<input
type="number"
step="0.01"
min="0"
style={inputStyle}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.00"
/>
</div>
<div>
<label style={labelStyle}>Währung</label>
<input
style={inputStyle}
value={currency}
onChange={(e) => setCurrency(e.target.value)}
maxLength={3}
placeholder="EUR"
/>
</div>
</div>
{/* Status + Erw. Abschluss */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as DealStatus)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Erw. Abschluss</label>
<input
type="date"
style={inputStyle}
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
/>
</div>
</div>
{/* Lost-Reason (nur bei Status LOST) */}
{status === 'LOST' && (
<>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Verlustgrund *</label>
<select
value={lostReason}
onChange={(e) => setLostReason(e.target.value as LostReason)}
style={{ ...inputStyle, cursor: 'pointer', borderColor: !lostReason ? 'var(--color-error)' : undefined }}
>
<option value="">-- Bitte waehlen --</option>
{Object.entries(LOST_REASON_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Bemerkung zum Verlust</label>
<textarea
style={{ ...inputStyle, minHeight: 50, resize: 'vertical' }}
value={lostReasonText}
onChange={(e) => setLostReasonText(e.target.value)}
placeholder="Optionale Details..."
/>
</div>
</>
)}
{/* Notizen */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* Custom Fields */}
{customFields.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<CustomFieldsForm
fields={customFields}
onChange={handleCustomFieldsChange}
/>
</div>
)}
{/* Buttons */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
type="button"
onClick={onClose}
disabled={mutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={mutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: mutation.isPending ? 'wait' : 'pointer',
opacity: mutation.isPending ? 0.7 : 1,
}}
>
{mutation.isPending
? 'Speichern...'
: isEditMode
? 'Speichern'
: 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}