mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
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>
690 lines
20 KiB
TypeScript
690 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|