mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
- 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>
575 lines
18 KiB
TypeScript
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]} ·{' '}
|
|
{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>
|
|
);
|
|
}
|