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, CreatePipelinePayload,
UpdatePipelinePayload, UpdatePipelinePayload,
CreateStagePayload, CreateStagePayload,
UpdateStagePayload,
PipelineStage, PipelineStage,
Activity, Activity,
CreateActivityPayload, CreateActivityPayload,
@ -121,6 +122,14 @@ export const pipelinesApi = {
) )
.then((r) => r.data), .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) => removeStage: (pipelineId: string, stageId: string) =>
api api
.delete<SingleResponse<PipelineStage>>( .delete<SingleResponse<PipelineStage>>(

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import type {
CreatePipelinePayload, CreatePipelinePayload,
UpdatePipelinePayload, UpdatePipelinePayload,
CreateStagePayload, CreateStagePayload,
UpdateStagePayload,
CreateActivityPayload, CreateActivityPayload,
UpdateActivityPayload, UpdateActivityPayload,
} from './types'; } 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() { export function useAddStage() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View file

@ -108,6 +108,53 @@
background: none; 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 { .newPipelineForm {
background: var(--color-bg-card); background: var(--color-bg-card);
border: 2px dashed var(--color-border); border: 2px dashed var(--color-border);

View file

@ -4,12 +4,163 @@ import {
useCreatePipeline, useCreatePipeline,
useDeletePipeline, useDeletePipeline,
useAddStage, useAddStage,
useUpdateStage,
useRemoveStage, useRemoveStage,
} from '../hooks'; } from '../hooks';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import type { Pipeline } from '../types'; import type { Pipeline, PipelineStage } from '../types';
import styles from './PipelinesPage.module.css'; 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 }) { function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const [newStageName, setNewStageName] = useState(''); const [newStageName, setNewStageName] = useState('');
@ -17,7 +168,6 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isDeleteOpen, setDeleteOpen] = useState(false); const [isDeleteOpen, setDeleteOpen] = useState(false);
const addStageMutation = useAddStage(); const addStageMutation = useAddStage();
const removeStageMutation = useRemoveStage();
const deletePipelineMutation = useDeletePipeline(); const deletePipelineMutation = useDeletePipeline();
const stages = [...pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder); const stages = [...pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder);
@ -123,34 +273,11 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
</p> </p>
)} )}
{stages.map((stage) => ( {stages.map((stage) => (
<div key={stage.id} className={styles.stageItem}> <StageRow
<span className={styles.stageOrder}>{stage.sortOrder + 1}</span> key={stage.id}
<span stage={stage}
className={styles.stageColor} pipelineId={pipeline.id}
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>
))} ))}
{/* Neue Stufe hinzufügen */} {/* Neue Stufe hinzufügen */}
@ -227,7 +354,7 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
marginBottom: '1.5rem', 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> </p>
<div <div
style={{ style={{
@ -280,6 +407,10 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
); );
} }
/* ------------------------------------------------------------------ */
/* Hauptseite */
/* ------------------------------------------------------------------ */
export function PipelinesPage() { export function PipelinesPage() {
const { data, isLoading, error } = usePipelines(); const { data, isLoading, error } = usePipelines();
const createMutation = useCreatePipeline(); const createMutation = useCreatePipeline();
@ -471,7 +602,7 @@ export function PipelinesPage() {
Noch keine Pipelines vorhanden Noch keine Pipelines vorhanden
</p> </p>
<p style={{ fontSize: '0.875rem' }}> <p style={{ fontSize: '0.875rem' }}>
Erstelle eine Pipeline, um Deals zu verwalten. Erstelle eine Pipeline, um Vorgänge zu verwalten.
</p> </p>
</div> </div>
)} )}

View file

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

View file

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