mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(crm): Dediziertes Projektanfrage-Formular + Button in Vorgänge-Liste
- ProjectRequestFormModal: Eigenständiges Formular mit Projektdetails oben (Beschreibung, Auslastung/Start, Laufzeit/Vorort-Anteil, Stundensätze) und Standard-Vorgangsdaten darunter (Titel, Pipeline/Stage, Kontakt, etc.) Auto-Select bei genau einem isProjectType-Typ; Warnung wenn nicht konfiguriert - DealsPage: Neuer outlined Button "Neue Projektanfrage" neben "Neuer Vorgang" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4c739945f0
commit
b197660ac8
3 changed files with 655 additions and 0 deletions
|
|
@ -6,6 +6,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Aenderungen 2026-03-13 (10): Dediziertes Projektanfrage-Formular + Button in Vorgänge-Liste
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
- `crm/deals/ProjectRequestFormModal.tsx` — Neues dediziertes Modal "Neue Projektanfrage": Projektdetails-Sektion oben (Beschreibung, Auslastung/Start, Laufzeit/Vorort-Anteil, Stundensätze), darunter Vorgangsdaten (Titel, Pipeline/Stage, Kontakt, Unternehmen, Volumen/Abschluss, Notizen); Auto-Select bei genau einem isProjectType-Typ; Warnung wenn kein Typ konfiguriert; Submit-Button disabled bei fehlendem Typ
|
||||||
|
- `crm/deals/DealsPage.tsx` — Zweiter Button "Neue Projektanfrage" (outlined, primary) neben "Neuer Vorgang"; `isProjectRequestOpen` State; `ProjectRequestFormModal` eingebunden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Aenderungen 2026-03-13 (9): Projektanfrage-Vorgangstyp (isProjectType + ProjectRequestDetails)
|
### Aenderungen 2026-03-13 (9): Projektanfrage-Vorgangstyp (isProjectType + ProjectRequestDetails)
|
||||||
|
|
||||||
#### Backend (crm-service)
|
#### Backend (crm-service)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useDeals, useDeleteDeal, usePipelines } from '../hooks';
|
import { useDeals, useDeleteDeal, usePipelines } from '../hooks';
|
||||||
import { DealFormModal } from './DealFormModal';
|
import { DealFormModal } from './DealFormModal';
|
||||||
|
import { ProjectRequestFormModal } from './ProjectRequestFormModal';
|
||||||
import type { Deal, DealStatus, DealsQueryParams } from '../types';
|
import type { Deal, DealStatus, DealsQueryParams } from '../types';
|
||||||
import styles from './DealsPage.module.css';
|
import styles from './DealsPage.module.css';
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ export function DealsPage() {
|
||||||
const [pipelineFilter, setPipelineFilter] = useState('');
|
const [pipelineFilter, setPipelineFilter] = useState('');
|
||||||
const [stageFilter, setStageFilter] = useState('');
|
const [stageFilter, setStageFilter] = useState('');
|
||||||
const [isCreateOpen, setCreateOpen] = useState(false);
|
const [isCreateOpen, setCreateOpen] = useState(false);
|
||||||
|
const [isProjectRequestOpen, setProjectRequestOpen] = useState(false);
|
||||||
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
||||||
const [deletingDeal, setDeletingDeal] = useState<Deal | null>(null);
|
const [deletingDeal, setDeletingDeal] = useState<Deal | null>(null);
|
||||||
|
|
||||||
|
|
@ -118,6 +120,21 @@ export function DealsPage() {
|
||||||
>
|
>
|
||||||
{pagination?.total ?? 0} Vorgänge gesamt
|
{pagination?.total ?? 0} Vorgänge gesamt
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setProjectRequestOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
border: '1px solid var(--color-primary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Neue Projektanfrage
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -415,6 +432,13 @@ export function DealsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Neue Projektanfrage */}
|
||||||
|
<ProjectRequestFormModal
|
||||||
|
isOpen={isProjectRequestOpen}
|
||||||
|
onClose={() => setProjectRequestOpen(false)}
|
||||||
|
onSuccess={() => { setProjectRequestOpen(false); }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Modal: Neuen Vorgang anlegen */}
|
{/* Modal: Neuen Vorgang anlegen */}
|
||||||
<DealFormModal
|
<DealFormModal
|
||||||
isOpen={isCreateOpen}
|
isOpen={isCreateOpen}
|
||||||
|
|
|
||||||
623
packages/frontend/src/crm/deals/ProjectRequestFormModal.tsx
Normal file
623
packages/frontend/src/crm/deals/ProjectRequestFormModal.tsx
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
/**
|
||||||
|
* ProjectRequestFormModal
|
||||||
|
* Dediziertes Formular für Projektanfragen – separates Modal neben "Neuer Vorgang".
|
||||||
|
* Zeigt projektspezifische Felder prominent am Anfang, gefolgt von Standard-Vorgangs-Feldern.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { useCreateDeal, usePipelines, useDealTypes } from '../hooks';
|
||||||
|
import { contactsApi, companiesApi } from '../api';
|
||||||
|
import type { Contact, Company } from '../types';
|
||||||
|
|
||||||
|
interface ProjectRequestFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Styles (inline, konsistent mit DealFormModal)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dividerStyle: React.CSSProperties = {
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
margin: '1.25rem 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionLabelStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
marginBottom: '0.875rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
export function ProjectRequestFormModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: ProjectRequestFormModalProps) {
|
||||||
|
const createMutation = useCreateDeal();
|
||||||
|
const { data: pipelinesData } = usePipelines();
|
||||||
|
const pipelines = pipelinesData?.data ?? [];
|
||||||
|
const { data: dealTypesData } = useDealTypes();
|
||||||
|
const dealTypes = dealTypesData?.data ?? [];
|
||||||
|
|
||||||
|
// Nur isProjectType-Typen
|
||||||
|
const projectDealTypes = dealTypes.filter((dt) => dt.isProjectType);
|
||||||
|
|
||||||
|
// ---- Projektanfrage-Felder ----
|
||||||
|
const [prNotes, setPrNotes] = useState('');
|
||||||
|
const [prWorkload, setPrWorkload] = useState('');
|
||||||
|
const [prStartDate, setPrStartDate] = useState('');
|
||||||
|
const [prDuration, setPrDuration] = useState('');
|
||||||
|
const [prOnsitePercent, setPrOnsitePercent] = useState('');
|
||||||
|
const [prRateRemote, setPrRateRemote] = useState('');
|
||||||
|
const [prRateOnsite, setPrRateOnsite] = useState('');
|
||||||
|
|
||||||
|
// ---- Standard-Vorgang-Felder ----
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [dealTypeId, setDealTypeId] = useState('');
|
||||||
|
const [pipelineId, setPipelineId] = useState('');
|
||||||
|
const [stageId, setStageId] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [currency, setCurrency] = useState('EUR');
|
||||||
|
const [expectedCloseDate, setExpectedCloseDate] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [error, setError] = 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 contactTimeout = 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 companyTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Stages der gewählten Pipeline
|
||||||
|
const selectedPipeline = pipelines.find((p) => p.id === pipelineId);
|
||||||
|
const stages = selectedPipeline?.stages
|
||||||
|
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// ---- Reset beim Öffnen ----
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPrNotes('');
|
||||||
|
setPrWorkload('');
|
||||||
|
setPrStartDate('');
|
||||||
|
setPrDuration('');
|
||||||
|
setPrOnsitePercent('');
|
||||||
|
setPrRateRemote('');
|
||||||
|
setPrRateOnsite('');
|
||||||
|
setTitle('');
|
||||||
|
setValue('');
|
||||||
|
setCurrency('EUR');
|
||||||
|
setExpectedCloseDate('');
|
||||||
|
setNotes('');
|
||||||
|
setError('');
|
||||||
|
setSelectedContact(null);
|
||||||
|
setContactSearch('');
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
|
setContactResults([]);
|
||||||
|
setCompanyResults([]);
|
||||||
|
setShowContactDropdown(false);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
reset();
|
||||||
|
// DealType: auto-select falls genau einer vorhanden
|
||||||
|
setDealTypeId(projectDealTypes.length === 1 ? projectDealTypes[0].id : '');
|
||||||
|
// Pipeline: Standard-Pipeline vorauswählen
|
||||||
|
const defaultPl = pipelines.find((p) => p.isDefault) ?? pipelines[0];
|
||||||
|
setPipelineId(defaultPl?.id ?? '');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Wenn Pipeline wechselt → erste Stage
|
||||||
|
useEffect(() => {
|
||||||
|
if (stages.length > 0) setStageId(stages[0].id);
|
||||||
|
else setStageId('');
|
||||||
|
}, [pipelineId, stages.length]); // eslint-disable-line
|
||||||
|
|
||||||
|
// Click-outside
|
||||||
|
useEffect(() => {
|
||||||
|
function h(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', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Kontakt suchen (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (contactTimeout.current) clearTimeout(contactTimeout.current);
|
||||||
|
if (!contactSearch || contactSearch.length < 2) { setContactResults([]); return; }
|
||||||
|
contactTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await contactsApi.list({ search: contactSearch, pageSize: 8 });
|
||||||
|
setContactResults(res.data);
|
||||||
|
setShowContactDropdown(true);
|
||||||
|
} catch { setContactResults([]); }
|
||||||
|
}, 300);
|
||||||
|
return () => { if (contactTimeout.current) clearTimeout(contactTimeout.current); };
|
||||||
|
}, [contactSearch]);
|
||||||
|
|
||||||
|
// Unternehmen suchen (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (companyTimeout.current) clearTimeout(companyTimeout.current);
|
||||||
|
if (!companySearch || companySearch.length < 2) { setCompanyResults([]); return; }
|
||||||
|
companyTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await companiesApi.list({ search: companySearch, pageSize: 8 });
|
||||||
|
setCompanyResults(res.data);
|
||||||
|
setShowCompanyDropdown(true);
|
||||||
|
} catch { setCompanyResults([]); }
|
||||||
|
}, 300);
|
||||||
|
return () => { if (companyTimeout.current) clearTimeout(companyTimeout.current); };
|
||||||
|
}, [companySearch]);
|
||||||
|
|
||||||
|
// ---- Submit ----
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!dealTypeId) {
|
||||||
|
setError('Bitte eine Vorgangsart (Projektanfrage-Typ) auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Titel ist ein Pflichtfeld.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pipelineId) {
|
||||||
|
setError('Bitte eine Pipeline auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stageId) {
|
||||||
|
setError('Bitte eine Stage auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProjectData =
|
||||||
|
prNotes || prWorkload || prStartDate || prDuration ||
|
||||||
|
prOnsitePercent || prRateRemote || prRateOnsite;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
pipelineId,
|
||||||
|
stageId,
|
||||||
|
dealTypeId,
|
||||||
|
status: 'OPEN' as const,
|
||||||
|
currency,
|
||||||
|
...(selectedContact ? { contactId: selectedContact.id } : {}),
|
||||||
|
...(selectedCompany ? { companyId: selectedCompany.id } : {}),
|
||||||
|
...(value ? { value: parseFloat(value) } : {}),
|
||||||
|
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
||||||
|
...(notes ? { notes } : {}),
|
||||||
|
...(hasProjectData
|
||||||
|
? {
|
||||||
|
projectRequest: {
|
||||||
|
...(prNotes ? { notes: prNotes } : {}),
|
||||||
|
...(prWorkload ? { workload: parseFloat(prWorkload) } : {}),
|
||||||
|
...(prStartDate ? { startDate: new Date(prStartDate).toISOString() } : {}),
|
||||||
|
...(prDuration ? { duration: prDuration } : {}),
|
||||||
|
...(prOnsitePercent ? { onsitePercent: parseFloat(prOnsitePercent) } : {}),
|
||||||
|
...(prRateRemote ? { rateRemote: parseFloat(prRateRemote) } : {}),
|
||||||
|
...(prRateOnsite ? { rateOnsite: parseFloat(prRateOnsite) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
|
?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
|
||||||
|
setError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- UI-Hilfskomponenten ----
|
||||||
|
const SearchDropdown = ({
|
||||||
|
results,
|
||||||
|
show,
|
||||||
|
onSelect,
|
||||||
|
renderLabel,
|
||||||
|
renderSub,
|
||||||
|
}: {
|
||||||
|
results: (Contact | Company)[];
|
||||||
|
show: boolean;
|
||||||
|
onSelect: (item: Contact | Company) => void;
|
||||||
|
renderLabel: (item: Contact | Company) => string;
|
||||||
|
renderSub?: (item: Contact | Company) => string | undefined;
|
||||||
|
}) => {
|
||||||
|
if (!show || results.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<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: 20,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{results.map((item) => {
|
||||||
|
const label = renderLabel(item);
|
||||||
|
const sub = renderSub?.(item);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontSize: '0.875rem', borderBottom: '1px solid var(--color-border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLDivElement).style.background = 'var(--color-bg)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLDivElement).style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sub && <span style={{ color: 'var(--color-text-muted)', marginLeft: '0.5rem', fontSize: '0.8125rem' }}>{sub}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Neue Projektanfrage"
|
||||||
|
maxWidth="620px"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Fehler-Banner */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kein Projektanfrage-Typ konfiguriert – Warnung */}
|
||||||
|
{projectDealTypes.length === 0 && (
|
||||||
|
<div style={{ padding: '0.875rem 1rem', 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: '1.25rem' }}>
|
||||||
|
⚠️ Noch kein Projektanfrage-Typ konfiguriert. Bitte zuerst unter{' '}
|
||||||
|
<a href="/crm/settings" style={{ color: 'inherit', fontWeight: 600 }}>
|
||||||
|
CRM Einstellungen → Vorgangsarten
|
||||||
|
</a>{' '}
|
||||||
|
eine Vorgangsart anlegen und "Projektanfrage" aktivieren.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vorgangsart-Auswahl (nur wenn mehrere Projektanfrage-Typen) */}
|
||||||
|
{projectDealTypes.length > 1 && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Vorgangsart *</label>
|
||||||
|
<select
|
||||||
|
value={dealTypeId}
|
||||||
|
onChange={(e) => setDealTypeId(e.target.value)}
|
||||||
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<option value="">— Typ wählen —</option>
|
||||||
|
{projectDealTypes.map((dt) => (
|
||||||
|
<option key={dt.id} value={dt.id}>{dt.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ================================================ */}
|
||||||
|
{/* PROJEKTANFRAGE-FELDER */}
|
||||||
|
{/* ================================================ */}
|
||||||
|
<div style={sectionLabelStyle}>📋 Projektdetails</div>
|
||||||
|
|
||||||
|
{/* Beschreibung */}
|
||||||
|
<div style={{ marginBottom: '0.875rem' }}>
|
||||||
|
<label style={labelStyle}>Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 88, resize: 'vertical' }}
|
||||||
|
value={prNotes}
|
||||||
|
onChange={(e) => setPrNotes(e.target.value)}
|
||||||
|
placeholder="Projektbeschreibung, Anforderungen, Rahmenbedingungen..."
|
||||||
|
autoFocus={projectDealTypes.length > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auslastung + Start */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '0.875rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Auslastung (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
style={inputStyle}
|
||||||
|
value={prWorkload}
|
||||||
|
onChange={(e) => setPrWorkload(e.target.value)}
|
||||||
|
placeholder="z.B. 80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Start</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
style={inputStyle}
|
||||||
|
value={prStartDate}
|
||||||
|
onChange={(e) => setPrStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Laufzeit + Vorort-Anteil */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '0.875rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Laufzeit</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={prDuration}
|
||||||
|
onChange={(e) => setPrDuration(e.target.value)}
|
||||||
|
placeholder="z.B. 6 Monate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Vorort-Anteil (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
style={inputStyle}
|
||||||
|
value={prOnsitePercent}
|
||||||
|
onChange={(e) => setPrOnsitePercent(e.target.value)}
|
||||||
|
placeholder="z.B. 20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stundensätze */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '0.875rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Stundensatz Remote (€/h)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
style={inputStyle}
|
||||||
|
value={prRateRemote}
|
||||||
|
onChange={(e) => setPrRateRemote(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Stundensatz Vorort (€/h)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
style={inputStyle}
|
||||||
|
value={prRateOnsite}
|
||||||
|
onChange={(e) => setPrRateOnsite(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={dividerStyle} />
|
||||||
|
|
||||||
|
{/* ================================================ */}
|
||||||
|
{/* STANDARD-VORGANG-FELDER */}
|
||||||
|
{/* ================================================ */}
|
||||||
|
<div style={sectionLabelStyle}>📁 Vorgangsdaten</div>
|
||||||
|
|
||||||
|
{/* Titel */}
|
||||||
|
<div style={{ marginBottom: '0.875rem' }}>
|
||||||
|
<label style={labelStyle}>Titel *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Projektanfrage – Kundenname / Thema"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline + Stage */}
|
||||||
|
{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: '0.875rem' }}>
|
||||||
|
⚠️ Keine Pipelines konfiguriert.{' '}
|
||||||
|
<a href="/crm/settings" style={{ color: 'inherit', fontWeight: 600 }}>CRM Einstellungen → Pipelines</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '0.875rem' }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kontakt-Suche */}
|
||||||
|
<div style={{ marginBottom: '0.875rem', 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>
|
||||||
|
)}
|
||||||
|
<SearchDropdown
|
||||||
|
results={contactResults}
|
||||||
|
show={showContactDropdown}
|
||||||
|
onSelect={(item) => {
|
||||||
|
const c = item as Contact;
|
||||||
|
const name = c.type === 'ORGANIZATION' ? c.companyName : [c.firstName, c.lastName].filter(Boolean).join(' ');
|
||||||
|
setSelectedContact({ id: c.id, name: name ?? '' });
|
||||||
|
setContactSearch(name ?? '');
|
||||||
|
setShowContactDropdown(false);
|
||||||
|
}}
|
||||||
|
renderLabel={(item) => {
|
||||||
|
const c = item as Contact;
|
||||||
|
return (c.type === 'ORGANIZATION' ? c.companyName : [c.firstName, c.lastName].filter(Boolean).join(' ')) ?? '';
|
||||||
|
}}
|
||||||
|
renderSub={(item) => (item as Contact).email ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unternehmen-Suche */}
|
||||||
|
<div style={{ marginBottom: '0.875rem', 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>
|
||||||
|
)}
|
||||||
|
<SearchDropdown
|
||||||
|
results={companyResults}
|
||||||
|
show={showCompanyDropdown}
|
||||||
|
onSelect={(item) => {
|
||||||
|
const comp = item as Company;
|
||||||
|
setSelectedCompany({ id: comp.id, name: comp.name });
|
||||||
|
setCompanySearch(comp.name);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}}
|
||||||
|
renderLabel={(item) => (item as Company).name}
|
||||||
|
renderSub={(item) => (item as Company).industry ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wert + Erwarteter Abschluss */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '0.875rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Volumen (€)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="500"
|
||||||
|
min="0"
|
||||||
|
style={inputStyle}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Erw. Abschluss</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
style={inputStyle}
|
||||||
|
value={expectedCloseDate}
|
||||||
|
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interne Notizen */}
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<label style={labelStyle}>Interne Notizen</label>
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 52, resize: 'vertical' }}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Interne Bemerkungen zum Vorgang..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={createMutation.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={createMutation.isPending || projectDealTypes.length === 0}
|
||||||
|
style={{ padding: '0.5rem 1.25rem', background: 'var(--color-primary)', color: 'white', border: 'none', borderRadius: 'var(--radius-sm)', fontSize: '0.875rem', fontWeight: 600, cursor: createMutation.isPending ? 'wait' : 'pointer', opacity: (createMutation.isPending || projectDealTypes.length === 0) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Anlegen...' : 'Projektanfrage anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue