INSIGHT-MVP/packages/frontend/src/crm/deals/DealDetailPage.tsx
Thomas Reitz c739dce161 feat: CRM Frontend-Modul mit Kontakte, Deals, Pipelines und Aktivitäten
Komplette CRM-Frontend-Integration in die bestehende React-GUI:

- Types, API-Client und React Query Hooks für alle CRM-Entitäten
- Kontakte: Liste mit Suche/Filter, Detail mit Aktivitäten-Timeline, Create/Edit Modal
- Deals: Liste mit Pipeline/Stage/Status-Filter, Detail mit Fortschrittsbalken, Create/Edit Modal
- Pipelines: Verwaltungsseite mit klappbaren Cards und Stage-Management
- Aktivitäten: Formular-Modal für Notizen, Anrufe, E-Mails, Meetings, Aufgaben
- CRM-Navigation in Sidebar (aufklappbar, mit Inline-SVG-Icons)
- Routen in App.tsx für alle CRM-Seiten
- Vite-Proxy für lokale CRM-API-Entwicklung

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:13:02 +01:00

307 lines
9 KiB
TypeScript

import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useDeal, usePipeline, useDeleteDeal } from '../hooks';
import { DealFormModal } from './DealFormModal';
import { Modal } from '../../components/Modal';
import type { DealStatus } from '../types';
import styles from './DealDetailPage.module.css';
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
OPEN: { bg: '#dbeafe', color: '#1e40af' },
WON: { bg: '#d1fae5', color: '#065f46' },
LOST: { bg: '#fee2e2', color: '#991b1b' },
};
const STATUS_LABELS: Record<DealStatus, string> = {
OPEN: 'Offen',
WON: 'Gewonnen',
LOST: 'Verloren',
};
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
export function DealDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data, isLoading, error } = useDeal(id!);
const deleteMutation = useDeleteDeal();
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)
: [];
const [isEditOpen, setEditOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
if (isLoading) return <p>Laden...</p>;
if (error || !deal)
return (
<p style={{ color: 'var(--color-error)' }}>
Deal konnte nicht geladen werden
</p>
);
const contactName = deal.contact
? deal.contact.companyName ||
[deal.contact.firstName, deal.contact.lastName]
.filter(Boolean)
.join(' ') ||
'Kontakt'
: null;
return (
<div>
{/* Zurück */}
<Link to="/crm/deals" className={styles.backLink}>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M9 2L4 7l5 5" />
</svg>
Zurück zu Deals
</Link>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<h1 className={styles.title}>{deal.title}</h1>
<span
style={{
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
background: STATUS_COLORS[deal.status].bg,
color: STATUS_COLORS[deal.status].color,
}}
>
{STATUS_LABELS[deal.status]}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => setEditOpen(true)}
style={{
padding: '0.375rem 0.75rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Bearbeiten
</button>
<button
onClick={() => setDeleteOpen(true)}
style={{
padding: '0.375rem 0.75rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--color-error)',
}}
>
Löschen
</button>
</div>
</div>
{/* Stage-Fortschrittsbalken */}
{pipelineStages.length > 0 && (
<div className={styles.stageProgress}>
{pipelineStages.map((stage) => {
const isActive = stage.id === deal.stageId;
return (
<div
key={stage.id}
className={`${styles.stageStep} ${isActive ? styles.stageStepActive : ''}`}
style={
isActive
? { background: stage.color, borderColor: stage.color }
: undefined
}
>
{stage.name}
</div>
);
})}
</div>
)}
{/* Info Card */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>Deal-Details</h2>
<div className={styles.infoGrid}>
<span className={styles.infoLabel}>Wert</span>
<span className={styles.infoValue} style={{ fontWeight: 600 }}>
{deal.value
? currencyFormatter.format(parseFloat(deal.value))
: '—'}
</span>
<span className={styles.infoLabel}>Pipeline</span>
<span className={styles.infoValue}>
{deal.pipeline?.name ?? '—'}
</span>
<span className={styles.infoLabel}>Stage</span>
<span className={styles.infoValue}>
{deal.stage && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: deal.stage.color,
display: 'inline-block',
}}
/>
{deal.stage.name}
</span>
)}
</span>
<span className={styles.infoLabel}>Kontakt</span>
<span className={styles.infoValue}>
{deal.contact ? (
<Link
to={`/crm/contacts/${deal.contact.id}`}
style={{ color: 'var(--color-primary)' }}
>
{contactName}
</Link>
) : (
'—'
)}
</span>
<span className={styles.infoLabel}>Erw. Abschluss</span>
<span className={styles.infoValue}>
{deal.expectedCloseDate
? formatDate(deal.expectedCloseDate)
: '—'}
</span>
{deal.closedAt && (
<>
<span className={styles.infoLabel}>Abgeschlossen am</span>
<span className={styles.infoValue}>
{formatDate(deal.closedAt)}
</span>
</>
)}
<span className={styles.infoLabel}>Erstellt am</span>
<span className={styles.infoValue}>
{formatDate(deal.createdAt)}
</span>
</div>
{deal.notes && (
<p className={styles.notesText}>{deal.notes}</p>
)}
</div>
{/* Modals */}
<DealFormModal
isOpen={isEditOpen}
onClose={() => setEditOpen(false)}
deal={deal}
onSuccess={() => setEditOpen(false)}
/>
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title="Deal löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '1.5rem',
}}
>
Soll der Deal <strong>{deal.title}</strong> wirklich gelöscht werden?
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
onClick={() => setDeleteOpen(false)}
disabled={deleteMutation.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={() =>
deleteMutation.mutate(deal.id, {
onSuccess: () => navigate('/crm/deals'),
})
}
disabled={deleteMutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-error)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: deleteMutation.isPending ? 'wait' : 'pointer',
opacity: deleteMutation.isPending ? 0.7 : 1,
}}
>
{deleteMutation.isPending ? 'Löschen...' : 'Endgültig löschen'}
</button>
</div>
</Modal>
</div>
);
}