mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
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>
307 lines
9 KiB
TypeScript
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>
|
|
);
|
|
}
|