feat(crm): inline stage editing, DealDetail optimization, rename Deals to Vorgänge

- PipelinesPage: Stages können jetzt per Doppelklick oder Stift-Icon
  inline bearbeitet werden (Name, Farbe) via PATCH endpoint
- DealDetailPage: Nutzt jetzt deal.pipeline.stages direkt statt
  separatem usePipeline() API-Call (Backend liefert alle Stages mit)
- UI-Texte: "Deals" → "Vorgänge", "Deal" → "Vorgang" in allen
  user-facing Strings (Sidebar, Seiten, Modals, Fehlermeldungen)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 19:38:58 +01:00
parent 56a9ed9647
commit 0b78160f33
10 changed files with 270 additions and 59 deletions

View file

@ -16,6 +16,7 @@ import type {
CreatePipelinePayload,
UpdatePipelinePayload,
CreateStagePayload,
UpdateStagePayload,
PipelineStage,
Activity,
CreateActivityPayload,
@ -121,6 +122,14 @@ export const pipelinesApi = {
)
.then((r) => r.data),
updateStage: (pipelineId: string, stageId: string, data: UpdateStagePayload) =>
api
.patch<SingleResponse<PipelineStage>>(
`/crm/pipelines/${pipelineId}/stages/${stageId}`,
data,
)
.then((r) => r.data),
removeStage: (pipelineId: string, stageId: string) =>
api
.delete<SingleResponse<PipelineStage>>(

View file

@ -309,11 +309,11 @@ export function ContactDetailPage() {
)}
</div>
{/* Verknüpfte Deals */}
{/* Verknüpfte Vorgänge */}
{deals.length > 0 && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<h2 className={styles.cardTitle}>
Verknüpfte Deals ({deals.length})
Verknüpfte Vorgänge ({deals.length})
</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useDeal, usePipeline, useDeleteDeal } from '../hooks';
import { useDeal, useDeleteDeal } from '../hooks';
import { DealFormModal } from './DealFormModal';
import { Modal } from '../../components/Modal';
import type { DealStatus } from '../types';
@ -39,10 +39,9 @@ export function DealDetailPage() {
const deal = data?.data;
// Pipeline mit allen Stages laden fuer Fortschrittsbalken
const { data: pipelineData } = usePipeline(deal?.pipelineId ?? '');
const pipelineStages = pipelineData?.data?.stages
? [...pipelineData.data.stages].sort((a, b) => a.sortOrder - b.sortOrder)
// Pipeline-Stages direkt aus dem Deal-Objekt (Backend liefert alle Stages mit)
const pipelineStages = deal?.pipeline?.stages
? [...deal.pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
: [];
const [isEditOpen, setEditOpen] = useState(false);
@ -52,7 +51,7 @@ export function DealDetailPage() {
if (error || !deal)
return (
<p style={{ color: 'var(--color-error)' }}>
Deal konnte nicht geladen werden
Vorgang konnte nicht geladen werden
</p>
);
@ -78,7 +77,7 @@ export function DealDetailPage() {
>
<path d="M9 2L4 7l5 5" />
</svg>
Zurück zu Deals
Zurück zu Vorgänge
</Link>
{/* Header */}
@ -155,7 +154,7 @@ export function DealDetailPage() {
{/* Info Card */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>Deal-Details</h2>
<h2 className={styles.cardTitle}>Vorgangs-Details</h2>
<div className={styles.infoGrid}>
<span className={styles.infoLabel}>Wert</span>
<span className={styles.infoValue} style={{ fontWeight: 600 }}>
@ -245,7 +244,7 @@ export function DealDetailPage() {
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title="Deal löschen"
title="Vorgang löschen"
maxWidth="420px"
>
<p
@ -255,7 +254,7 @@ export function DealDetailPage() {
marginBottom: '1.5rem',
}}
>
Soll der Deal <strong>{deal.title}</strong> wirklich gelöscht werden?
Soll der Vorgang <strong>{deal.title}</strong> wirklich gelöscht werden?
</p>
<div
style={{

View file

@ -233,7 +233,7 @@ export function DealFormModal({
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? 'Deal bearbeiten' : 'Neuer Deal'}
title={isEditMode ? 'Vorgang bearbeiten' : 'Neuer Vorgang'}
maxWidth="600px"
>
<form onSubmit={handleSubmit}>
@ -260,7 +260,7 @@ export function DealFormModal({
style={inputStyle}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal-Titel"
placeholder="Vorgangs-Titel"
required
/>
</div>

View file

@ -90,7 +90,7 @@ export function DealsPage() {
if (error)
return (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Deals
Fehler beim Laden der Vorgänge
</p>
);
@ -108,7 +108,7 @@ export function DealsPage() {
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Deals</h1>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Vorgänge</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
@ -116,7 +116,7 @@ export function DealsPage() {
fontSize: '0.875rem',
}}
>
{pagination?.total ?? 0} Deals gesamt
{pagination?.total ?? 0} Vorgänge gesamt
</span>
<button
onClick={() => setCreateOpen(true)}
@ -131,7 +131,7 @@ export function DealsPage() {
cursor: 'pointer',
}}
>
+ Neuer Deal
+ Neuer Vorgang
</button>
</div>
</div>
@ -229,7 +229,7 @@ export function DealsPage() {
color: 'var(--color-text-muted)',
}}
>
Keine Deals gefunden
Keine Vorgänge gefunden
</td>
</tr>
)}
@ -389,14 +389,14 @@ export function DealsPage() {
)}
</div>
{/* Modal: Neuen Deal anlegen */}
{/* Modal: Neuen Vorgang anlegen */}
<DealFormModal
isOpen={isCreateOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => setCreateOpen(false)}
/>
{/* Modal: Deal bearbeiten */}
{/* Modal: Vorgang bearbeiten */}
<DealFormModal
isOpen={!!editingDeal}
onClose={() => setEditingDeal(null)}
@ -404,11 +404,11 @@ export function DealsPage() {
onSuccess={() => setEditingDeal(null)}
/>
{/* Modal: Deal löschen */}
{/* Modal: Vorgang löschen */}
<Modal
isOpen={!!deletingDeal}
onClose={() => setDeletingDeal(null)}
title="Deal löschen"
title="Vorgang löschen"
maxWidth="420px"
>
<p
@ -418,7 +418,7 @@ export function DealsPage() {
marginBottom: '1.5rem',
}}
>
Soll der Deal <strong>{deletingDeal?.title}</strong> wirklich gelöscht
Soll der Vorgang <strong>{deletingDeal?.title}</strong> wirklich gelöscht
werden?
</p>
<div

View file

@ -15,6 +15,7 @@ import type {
CreatePipelinePayload,
UpdatePipelinePayload,
CreateStagePayload,
UpdateStagePayload,
CreateActivityPayload,
UpdateActivityPayload,
} from './types';
@ -203,6 +204,24 @@ export function useDeletePipeline() {
});
}
export function useUpdateStage() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
pipelineId,
stageId,
data,
}: {
pipelineId: string;
stageId: string;
data: UpdateStagePayload;
}) => pipelinesApi.updateStage(pipelineId, stageId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
export function useAddStage() {
const qc = useQueryClient();
return useMutation({

View file

@ -108,6 +108,53 @@
background: none;
}
.stageEditInput {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
font-size: 0.875rem;
outline: none;
background: var(--color-bg);
color: var(--color-text);
}
.stageEditColor {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
flex-shrink: 0;
}
.stageEditActions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.stageEditBtn {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
}
.stageEditSave {
background: var(--color-primary);
color: white;
}
.stageEditCancel {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.newPipelineForm {
background: var(--color-bg-card);
border: 2px dashed var(--color-border);

View file

@ -4,12 +4,163 @@ import {
useCreatePipeline,
useDeletePipeline,
useAddStage,
useUpdateStage,
useRemoveStage,
} from '../hooks';
import { Modal } from '../../components/Modal';
import type { Pipeline } from '../types';
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 updateStageMutation = useUpdateStage();
const removeStageMutation = useRemoveStage();
const handleSave = () => {
const changes: Record<string, string> = {};
if (editName.trim() !== stage.name) changes.name = editName.trim();
if (editColor !== stage.color) changes.color = editColor;
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);
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
/>
<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>
<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('');
@ -17,7 +168,6 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isDeleteOpen, setDeleteOpen] = useState(false);
const addStageMutation = useAddStage();
const removeStageMutation = useRemoveStage();
const deletePipelineMutation = useDeletePipeline();
const stages = [...pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder);
@ -123,34 +273,11 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
</p>
)}
{stages.map((stage) => (
<div key={stage.id} className={styles.stageItem}>
<span className={styles.stageOrder}>{stage.sortOrder + 1}</span>
<span
className={styles.stageColor}
style={{ background: stage.color }}
/>
<span className={styles.stageName}>{stage.name}</span>
<button
onClick={() =>
removeStageMutation.mutate({
pipelineId: pipeline.id,
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>
<StageRow
key={stage.id}
stage={stage}
pipelineId={pipeline.id}
/>
))}
{/* Neue Stufe hinzufügen */}
@ -227,7 +354,7 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
marginBottom: '1.5rem',
}}
>
Alle Stufen und zugehörige Deals werden ebenfalls gelöscht.
Alle Stufen und zugehörige Vorgänge werden ebenfalls gelöscht.
</p>
<div
style={{
@ -280,6 +407,10 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
);
}
/* ------------------------------------------------------------------ */
/* Hauptseite */
/* ------------------------------------------------------------------ */
export function PipelinesPage() {
const { data, isLoading, error } = usePipelines();
const createMutation = useCreatePipeline();
@ -471,7 +602,7 @@ export function PipelinesPage() {
Noch keine Pipelines vorhanden
</p>
<p style={{ fontSize: '0.875rem' }}>
Erstelle eine Pipeline, um Deals zu verwalten.
Erstelle eine Pipeline, um Vorgänge zu verwalten.
</p>
</div>
)}

View file

@ -128,6 +128,12 @@ export interface UpdatePipelinePayload {
isActive?: boolean;
}
export interface UpdateStagePayload {
name?: string;
sortOrder?: number;
color?: string;
}
export interface CreateStagePayload {
name: string;
sortOrder?: number;
@ -153,7 +159,7 @@ export interface Deal {
updatedBy: string | null;
createdAt: string;
updatedAt: string;
pipeline?: { id: string; name: string };
pipeline?: { id: string; name: string; stages?: PipelineStage[] };
stage?: { id: string; name: string; color: string };
contact?: {
id: string;

View file

@ -285,7 +285,7 @@ export function AppLayout() {
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Deals"
title="Vorgänge"
>
<svg
width="16"
@ -301,7 +301,7 @@ export function AppLayout() {
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
<path d="M1 9h14" />
</svg>
{!collapsed && 'Deals'}
{!collapsed && 'Vorgänge'}
</NavLink>
<NavLink
to="/crm/pipelines"