INSIGHT-MVP/packages/frontend/src/crm/contacts/ContactDetailPage.tsx
Thomas Reitz 0b78160f33 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>
2026-03-10 19:38:58 +01:00

575 lines
18 KiB
TypeScript

import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useContact, useDeals, useDeleteContact } from '../hooks';
import { ContactFormModal } from './ContactFormModal';
import { ActivityFormModal } from '../activities/ActivityFormModal';
import { Modal } from '../../components/Modal';
import type { Contact, Activity, ActivityType, ContactType } from '../types';
import styles from './ContactDetailPage.module.css';
const TYPE_COLORS: Record<ContactType, { bg: string; color: string }> = {
PERSON: { bg: '#dbeafe', color: '#1e40af' },
ORGANIZATION: { bg: '#d1fae5', color: '#065f46' },
};
const TYPE_LABELS: Record<ContactType, string> = {
PERSON: 'Person',
ORGANIZATION: 'Organisation',
};
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
NOTE: 'Notiz',
CALL: 'Anruf',
EMAIL: 'E-Mail',
MEETING: 'Meeting',
TASK: 'Aufgabe',
};
function activityIcon(type: ActivityType): React.ReactNode {
const s = {
width: 14,
height: 14,
stroke: 'currentColor',
fill: 'none',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
switch (type) {
case 'NOTE':
return (
<svg viewBox="0 0 16 16" {...s}>
<path d="M12 1l3 3-9 9H3v-3z" />
</svg>
);
case 'CALL':
return (
<svg viewBox="0 0 16 16" {...s}>
<path d="M1 3a2 2 0 012-2h1.5l2 3.5-1.5 1a7.5 7.5 0 004 4l1-1.5L13.5 10H15a2 2 0 01-2 2h-1A10 10 0 011 4V3z" />
</svg>
);
case 'EMAIL':
return (
<svg viewBox="0 0 16 16" {...s}>
<rect x="1" y="3" width="14" height="10" rx="1" />
<path d="M1 4l7 5 7-5" />
</svg>
);
case 'MEETING':
return (
<svg viewBox="0 0 16 16" {...s}>
<rect x="2" y="3" width="12" height="11" rx="1" />
<path d="M5 1v3M11 1v3M2 7h12" />
</svg>
);
case 'TASK':
return (
<svg viewBox="0 0 16 16" {...s}>
<path d="M4 8l3 3 5-6" />
<rect x="1" y="1" width="14" height="14" rx="2" />
</svg>
);
}
}
function contactDisplayName(c: Contact): string {
if (c.type === 'ORGANIZATION') return c.companyName ?? '—';
return [c.firstName, c.lastName].filter(Boolean).join(' ') || '—';
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
export function ContactDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data, isLoading, error } = useContact(id!);
const { data: dealsData } = useDeals({ contactId: id, pageSize: 50 });
const deleteMutation = useDeleteContact();
const [isEditOpen, setEditOpen] = useState(false);
const [isActivityOpen, setActivityOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
if (isLoading) return <p>Laden...</p>;
if (error || !data)
return (
<p style={{ color: 'var(--color-error)' }}>
Kontakt konnte nicht geladen werden
</p>
);
const contact = data.data;
const activities: Activity[] = contact.activities ?? [];
const deals = dealsData?.data ?? [];
return (
<div>
{/* Zurück */}
<Link to="/crm/contacts" 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 Kontakte
</Link>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<h1 className={styles.name}>{contactDisplayName(contact)}</h1>
<span
style={{
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
background: TYPE_COLORS[contact.type].bg,
color: TYPE_COLORS[contact.type].color,
}}
>
{TYPE_LABELS[contact.type]}
</span>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: contact.isActive
? 'var(--color-success)'
: 'var(--color-error)',
}}
title={contact.isActive ? 'Aktiv' : 'Inaktiv'}
/>
</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>
{/* 2-Spalten Layout */}
<div className={styles.layout}>
{/* Links: Info + Deals */}
<div>
{/* Info Card */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>Kontaktdaten</h2>
<div className={styles.infoGrid}>
{contact.type === 'PERSON' && contact.firstName && (
<>
<span className={styles.infoLabel}>Vorname</span>
<span className={styles.infoValue}>{contact.firstName}</span>
</>
)}
{contact.type === 'PERSON' && contact.lastName && (
<>
<span className={styles.infoLabel}>Nachname</span>
<span className={styles.infoValue}>{contact.lastName}</span>
</>
)}
{contact.companyName && (
<>
<span className={styles.infoLabel}>Firma</span>
<span className={styles.infoValue}>
{contact.companyName}
</span>
</>
)}
{contact.email && (
<>
<span className={styles.infoLabel}>E-Mail</span>
<span className={styles.infoValue}>
<a
href={`mailto:${contact.email}`}
style={{ color: 'var(--color-primary)' }}
>
{contact.email}
</a>
</span>
</>
)}
{contact.phone && (
<>
<span className={styles.infoLabel}>Telefon</span>
<span className={styles.infoValue}>{contact.phone}</span>
</>
)}
{contact.mobile && (
<>
<span className={styles.infoLabel}>Mobil</span>
<span className={styles.infoValue}>{contact.mobile}</span>
</>
)}
{contact.website && (
<>
<span className={styles.infoLabel}>Website</span>
<span className={styles.infoValue}>
<a
href={contact.website}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}
>
{contact.website}
</a>
</span>
</>
)}
{(contact.street || contact.zip || contact.city) && (
<>
<span className={styles.infoLabel}>Adresse</span>
<span className={styles.infoValue}>
{contact.street && <>{contact.street}<br /></>}
{contact.zip} {contact.city}
{contact.country && contact.country !== 'DE' && (
<>, {contact.country}</>
)}
</span>
</>
)}
</div>
{/* Tags */}
{contact.tags && contact.tags.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Tags
</span>
<div className={styles.tags}>
{contact.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</div>
)}
{/* Notizen */}
{contact.notes && (
<div className={styles.notesSection}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Notizen
</span>
<p className={styles.notesText}>{contact.notes}</p>
</div>
)}
</div>
{/* Verknüpfte Vorgänge */}
{deals.length > 0 && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<h2 className={styles.cardTitle}>
Verknüpfte Vorgänge ({deals.length})
</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr
style={{
borderBottom: '1px solid var(--color-border)',
}}
>
<th
style={{
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Titel
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'left',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Stage
</th>
<th
style={{
padding: '0.5rem 0',
textAlign: 'right',
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
}}
>
Wert
</th>
</tr>
</thead>
<tbody>
{deals.map((deal) => (
<tr
key={deal.id}
style={{
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
onClick={() => navigate(`/crm/deals/${deal.id}`)}
>
<td
style={{
padding: '0.5rem 0',
fontSize: '0.875rem',
}}
>
{deal.title}
</td>
<td style={{ padding: '0.5rem 0' }}>
{deal.stage && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
fontSize: '0.8125rem',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: deal.stage.color,
display: 'inline-block',
}}
/>
{deal.stage.name}
</span>
)}
</td>
<td
style={{
padding: '0.5rem 0',
textAlign: 'right',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{deal.value
? currencyFormatter.format(parseFloat(deal.value))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Rechts: Aktivitäten-Timeline */}
<div className={styles.card}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}}
>
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
Aktivitäten
</h2>
<button
onClick={() => setActivityOpen(true)}
style={{
padding: '0.25rem 0.625rem',
fontSize: '0.8125rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontWeight: 500,
}}
>
+ Neu
</button>
</div>
{activities.length === 0 ? (
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
Keine Aktivitäten vorhanden
</p>
) : (
<div className={styles.timeline}>
{activities.map((act) => (
<div key={act.id} className={styles.timelineItem}>
<div className={styles.timelineIcon}>
{activityIcon(act.type)}
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineSubject}>{act.subject}</div>
<div className={styles.timelineMeta}>
{ACTIVITY_TYPE_LABELS[act.type]} &middot;{' '}
{formatDate(act.createdAt)}
</div>
{act.description && (
<div className={styles.timelineDesc}>
{act.description}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Modals */}
<ContactFormModal
isOpen={isEditOpen}
onClose={() => setEditOpen(false)}
contact={contact}
onSuccess={() => setEditOpen(false)}
/>
<ActivityFormModal
isOpen={isActivityOpen}
onClose={() => setActivityOpen(false)}
contactId={contact.id}
onSuccess={() => setActivityOpen(false)}
/>
{/* Löschen-Modal */}
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title="Kontakt löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '0.5rem',
}}
>
Soll der Kontakt{' '}
<strong>{contactDisplayName(contact)}</strong> wirklich gelöscht
werden?
</p>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '1.5rem',
}}
>
Alle Aktivitäten werden ebenfalls gelöscht.
</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(contact.id, {
onSuccess: () => navigate('/crm/contacts'),
})
}
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>
);
}