INSIGHT-MVP/packages/frontend/src/crm/pipelines/PipelinesPage.tsx
Thomas Reitz fdab2d5bcb feat(crm): Phase 2.2-2.4 frontend — Forecast, Import, Enrichment
Phase 2.3 Forecast:
- probability field on PipelineStage (types, edit UI, add-stage form)
- ForecastPage with pipeline filter, period selector, summary cards, table
- forecastApi + useForecast hook
- /crm/forecast route + "Prognose" nav link

Phase 2.2 CSV/Excel Import:
- 3-step wizard ImportPage (Upload → Mapping → Result)
- Entity type selection, auto-mapping, duplicate strategy, preview table
- importApi (preview + execute) + useImportPreview/useImportExecute hooks
- /crm/import route + "Import" nav link

Phase 2.4 Data Enrichment:
- "Anreichern" button on CompanyDetailPage with suggestions modal
- Per-field accept with PATCH update
- enrichmentApi (enrich, getConfig, setConfig) + hooks
- NorthDataConfig in CRM Settings "Integrationen" tab
- API-Key management UI

All changes pass TypeScript strict mode (npx tsc --noEmit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:37:54 +01:00

690 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import {
usePipelines,
useCreatePipeline,
useDeletePipeline,
useAddStage,
useUpdateStage,
useRemoveStage,
} from '../hooks';
import { Modal } from '../../components/Modal';
import type { Pipeline, PipelineStage } from '../types';
import styles from './PipelinesPage.module.css';
/* ------------------------------------------------------------------ */
/* Einzelne Stage-Zeile (View + Edit) */
/* ------------------------------------------------------------------ */
function StageRow({
stage,
pipelineId,
}: {
stage: PipelineStage;
pipelineId: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(stage.name);
const [editColor, setEditColor] = useState(stage.color);
const [editProbability, setEditProbability] = useState(
Math.round((stage.probability ?? 0) * 100),
);
const updateStageMutation = useUpdateStage();
const removeStageMutation = useRemoveStage();
const handleSave = () => {
const changes: Record<string, string | number> = {};
if (editName.trim() !== stage.name) changes.name = editName.trim();
if (editColor !== stage.color) changes.color = editColor;
const newProb = editProbability / 100;
if (newProb !== (stage.probability ?? 0)) changes.probability = newProb;
if (Object.keys(changes).length === 0) {
setIsEditing(false);
return;
}
updateStageMutation.mutate(
{ pipelineId, stageId: stage.id, data: changes },
{ onSuccess: () => setIsEditing(false) },
);
};
const handleCancel = () => {
setEditName(stage.name);
setEditColor(stage.color);
setEditProbability(Math.round((stage.probability ?? 0) * 100));
setIsEditing(false);
};
if (isEditing) {
return (
<div className={styles.stageItem}>
<span className={styles.stageOrder}>{stage.sortOrder + 1}</span>
<input
type="color"
className={styles.stageEditColor}
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
title="Farbe"
/>
<input
className={styles.stageEditInput}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
<input
type="number"
min={0}
max={100}
value={editProbability}
onChange={(e) => setEditProbability(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
if (e.key === 'Escape') handleCancel();
}}
style={{
width: '4rem',
padding: '0.375rem 0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
textAlign: 'right',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
title="Wahrscheinlichkeit (%)"
/>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>%</span>
<div className={styles.stageEditActions}>
<button
className={`${styles.stageEditBtn} ${styles.stageEditSave}`}
onClick={handleSave}
disabled={!editName.trim() || updateStageMutation.isPending}
>
{updateStageMutation.isPending ? '...' : '✓'}
</button>
<button
className={`${styles.stageEditBtn} ${styles.stageEditCancel}`}
onClick={handleCancel}
disabled={updateStageMutation.isPending}
>
</button>
</div>
</div>
);
}
return (
<div className={styles.stageItem}>
<span className={styles.stageOrder}>{stage.sortOrder + 1}</span>
<span
className={styles.stageColor}
style={{ background: stage.color }}
/>
<span
className={styles.stageName}
onDoubleClick={() => setIsEditing(true)}
title="Doppelklick zum Bearbeiten"
style={{ cursor: 'pointer' }}
>
{stage.name}
</span>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
background: 'var(--color-bg)',
padding: '0.125rem 0.375rem',
borderRadius: '9999px',
border: '1px solid var(--color-border)',
whiteSpace: 'nowrap',
}}
title="Wahrscheinlichkeit"
>
{Math.round((stage.probability ?? 0) * 100)}%
</span>
<button
onClick={() => setIsEditing(true)}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '0.8125rem',
padding: '0 0.25rem',
}}
title="Stufe bearbeiten"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
>
<path d="M8.5 2.5l3 3M1.5 9.5l6-6 3 3-6 6H1.5v-3z" />
</svg>
</button>
<button
onClick={() =>
removeStageMutation.mutate({
pipelineId,
stageId: stage.id,
})
}
disabled={removeStageMutation.isPending}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '1rem',
padding: '0 0.25rem',
}}
title="Stufe entfernen"
>
×
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Pipeline Card (klappbar) */
/* ------------------------------------------------------------------ */
function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isOpen, setIsOpen] = useState(true);
const [newStageName, setNewStageName] = useState('');
const [newStageColor, setNewStageColor] = useState('#6B7280');
const [newStageProbability, setNewStageProbability] = useState(0);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const addStageMutation = useAddStage();
const deletePipelineMutation = useDeletePipeline();
const stages = [...pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder);
const handleAddStage = () => {
if (!newStageName.trim()) return;
addStageMutation.mutate(
{
pipelineId: pipeline.id,
data: {
name: newStageName.trim(),
sortOrder: stages.length,
color: newStageColor,
probability: newStageProbability / 100,
},
},
{
onSuccess: () => {
setNewStageName('');
setNewStageColor('#6B7280');
setNewStageProbability(0);
},
},
);
};
return (
<div className={styles.pipelineCard}>
<div
className={styles.pipelineHeader}
onClick={() => setIsOpen((p) => !p)}
>
<div className={styles.pipelineHeaderLeft}>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="var(--color-text-muted)"
strokeWidth="2"
style={{
transform: isOpen ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform 0.2s',
}}
>
<path d="M4 2l4 4-4 4" />
</svg>
<span className={styles.pipelineName}>{pipeline.name}</span>
{pipeline.isDefault && (
<span
style={{
display: 'inline-block',
padding: '0.0625rem 0.375rem',
borderRadius: '9999px',
fontSize: '0.6875rem',
fontWeight: 500,
background: '#dbeafe',
color: '#1e40af',
}}
>
Standard
</span>
)}
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
}}
>
{stages.length} Stufen
</span>
</div>
<div
style={{ display: 'flex', gap: '0.5rem' }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setDeleteOpen(true)}
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--color-error)',
}}
>
Löschen
</button>
</div>
</div>
{isOpen && (
<div className={styles.stageList}>
{stages.length === 0 && (
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
padding: '0.5rem 0',
}}
>
Keine Stufen vorhanden
</p>
)}
{stages.map((stage) => (
<StageRow
key={stage.id}
stage={stage}
pipelineId={pipeline.id}
/>
))}
{/* Neue Stufe hinzufügen */}
<div className={styles.addStageForm}>
<input
className={styles.addStageInput}
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
placeholder="Neue Stufe..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddStage();
}
}}
/>
<input
type="color"
className={styles.colorInput}
value={newStageColor}
onChange={(e) => setNewStageColor(e.target.value)}
title="Farbe"
/>
<input
type="number"
min={0}
max={100}
value={newStageProbability}
onChange={(e) => setNewStageProbability(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddStage();
}
}}
placeholder="0"
style={{
width: '3.5rem',
padding: '0.375rem 0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
textAlign: 'right',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
title="Wahrscheinlichkeit (%)"
/>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>%</span>
<button
onClick={handleAddStage}
disabled={
!newStageName.trim() || addStageMutation.isPending
}
style={{
padding: '0.375rem 0.75rem',
fontSize: '0.8125rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor:
!newStageName.trim() || addStageMutation.isPending
? 'not-allowed'
: 'pointer',
opacity:
!newStageName.trim() || addStageMutation.isPending
? 0.5
: 1,
whiteSpace: 'nowrap',
}}
>
Hinzufügen
</button>
</div>
</div>
)}
{/* Löschen-Modal */}
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title="Pipeline löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '0.5rem',
}}
>
Soll die Pipeline <strong>{pipeline.name}</strong> wirklich gelöscht
werden?
</p>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '1.5rem',
}}
>
Alle Stufen und zugehörige Vorgänge werden ebenfalls gelöscht.
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
onClick={() => setDeleteOpen(false)}
disabled={deletePipelineMutation.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
onClick={() =>
deletePipelineMutation.mutate(pipeline.id, {
onSuccess: () => setDeleteOpen(false),
})
}
disabled={deletePipelineMutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-error)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: deletePipelineMutation.isPending ? 'wait' : 'pointer',
opacity: deletePipelineMutation.isPending ? 0.7 : 1,
}}
>
{deletePipelineMutation.isPending
? 'Löschen...'
: 'Endgültig löschen'}
</button>
</div>
</Modal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Hauptseite */
/* ------------------------------------------------------------------ */
export function PipelinesPage() {
const { data, isLoading, error } = usePipelines();
const createMutation = useCreatePipeline();
const [showNewForm, setShowNewForm] = useState(false);
const [newName, setNewName] = useState('');
const [newIsDefault, setNewIsDefault] = useState(false);
if (isLoading) return <p>Laden...</p>;
if (error)
return (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Pipelines
</p>
);
const pipelines = data?.data ?? [];
const handleCreate = () => {
if (!newName.trim()) return;
createMutation.mutate(
{
name: newName.trim(),
isDefault: newIsDefault,
},
{
onSuccess: () => {
setNewName('');
setNewIsDefault(false);
setShowNewForm(false);
},
},
);
};
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Pipelines</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
{pipelines.length} Pipelines
</span>
<button
onClick={() => setShowNewForm(true)}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
}}
>
+ Neue Pipeline
</button>
</div>
</div>
{/* Neue Pipeline Form */}
{showNewForm && (
<div className={styles.newPipelineForm}>
<div className={styles.newPipelineRow}>
<div style={{ flex: 1 }}>
<label
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
display: 'block',
marginBottom: '0.25rem',
}}
>
Pipeline-Name
</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z.B. Vertrieb"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
}
}}
style={{
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)',
color: 'var(--color-text)',
}}
/>
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
fontSize: '0.875rem',
color: 'var(--color-text)',
cursor: 'pointer',
paddingBottom: '0.375rem',
}}
>
<input
type="checkbox"
checked={newIsDefault}
onChange={(e) => setNewIsDefault(e.target.checked)}
/>
Standard
</label>
<button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
style={{
padding: '0.625rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor:
!newName.trim() || createMutation.isPending
? 'not-allowed'
: 'pointer',
opacity:
!newName.trim() || createMutation.isPending ? 0.5 : 1,
whiteSpace: 'nowrap',
}}
>
{createMutation.isPending ? 'Erstellen...' : 'Erstellen'}
</button>
<button
onClick={() => {
setShowNewForm(false);
setNewName('');
setNewIsDefault(false);
}}
style={{
padding: '0.625rem 0.75rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
</div>
</div>
)}
{/* Pipeline-Liste */}
{pipelines.length === 0 && !showNewForm && (
<div
style={{
padding: '3rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
}}
>
<p style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>
Noch keine Pipelines vorhanden
</p>
<p style={{ fontSize: '0.875rem' }}>
Erstelle eine Pipeline, um Vorgänge zu verwalten.
</p>
</div>
)}
{pipelines.map((pipeline) => (
<PipelineCard key={pipeline.id} pipeline={pipeline} />
))}
</div>
);
}