mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- 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>
795 lines
25 KiB
TypeScript
795 lines
25 KiB
TypeScript
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 Einstellungen → 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>
|
||
);
|
||
}
|