INSIGHT-MVP/packages/frontend/src/crm/contacts/ContactsPage.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

418 lines
14 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/Modal';
import { useContacts, useDeleteContact } from '../hooks';
import { ContactFormModal } from './ContactFormModal';
import type { Contact, ContactType, ContactsQueryParams } from '../types';
import styles from './ContactsPage.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 thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
};
function contactDisplayName(c: Contact): string {
if (c.type === 'ORGANIZATION') return c.companyName ?? '—';
return [c.firstName, c.lastName].filter(Boolean).join(' ') || '—';
}
export function ContactsPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ContactType | ''>('');
const [isCreateOpen, setCreateOpen] = useState(false);
const [editingContact, setEditingContact] = useState<Contact | null>(null);
const [deletingContact, setDeletingContact] = useState<Contact | null>(null);
// Debounced search
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput]);
const params: ContactsQueryParams = {
page,
pageSize: 25,
...(search ? { search } : {}),
...(typeFilter ? { type: typeFilter } : {}),
sort: 'createdAt',
order: 'desc',
};
const { data, isLoading, error } = useContacts(params);
const deleteMutation = useDeleteContact();
if (isLoading) return <p>Laden...</p>;
if (error)
return (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Kontakte
</p>
);
const contacts = data?.data ?? [];
const pagination = data?.pagination;
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Kontakte</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
{pagination?.total ?? 0} Kontakte gesamt
</span>
<button
onClick={() => setCreateOpen(true)}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
}}
>
+ Neuer Kontakt
</button>
</div>
</div>
{/* Filterleiste */}
<div className={styles.filterBar}>
<input
type="search"
placeholder="Suche nach Name, Firma, E-Mail..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className={styles.searchInput}
/>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as ContactType | '');
setPage(1);
}}
className={styles.filterSelect}
>
<option value="">Alle Typen</option>
<option value="PERSON">Person</option>
<option value="ORGANIZATION">Organisation</option>
</select>
</div>
{/* Tabelle */}
<div
style={{
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr
style={{
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-bg)',
}}
>
<th style={thStyle}>Name / Firma</th>
<th style={thStyle}>Typ</th>
<th style={thStyle}>E-Mail</th>
<th style={thStyle}>Telefon</th>
<th style={thStyle}>Stadt</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Aktionen</th>
</tr>
</thead>
<tbody>
{contacts.length === 0 && (
<tr>
<td
colSpan={7}
style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
}}
>
Keine Kontakte gefunden
</td>
</tr>
)}
{contacts.map((contact) => {
const typeStyle = TYPE_COLORS[contact.type];
return (
<tr
key={contact.id}
style={{
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
onClick={() => navigate(`/crm/contacts/${contact.id}`)}
>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{contactDisplayName(contact)}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span
style={{
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
background: typeStyle.bg,
color: typeStyle.color,
}}
>
{TYPE_LABELS[contact.type]}
</span>
</td>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{contact.email ?? '—'}
</td>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{contact.phone ?? contact.mobile ?? '—'}
</td>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{contact.city ?? '—'}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: contact.isActive
? 'var(--color-success)'
: 'var(--color-error)',
marginRight: '0.375rem',
}}
/>
<span style={{ fontSize: '0.875rem' }}>
{contact.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td
style={{ padding: '0.75rem 1rem' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => setEditingContact(contact)}
style={{
padding: '0.25rem 0.625rem',
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={() => setDeletingContact(contact)}
style={{
padding: '0.25rem 0.625rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--color-error)',
}}
>
Löschen
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Paginierung */}
{pagination && pagination.totalPages > 1 && (
<div className={styles.pagination}>
<span>
Seite {pagination.page} von {pagination.totalPages} ({pagination.total}{' '}
Einträge)
</span>
<div className={styles.paginationButtons}>
<button
className={styles.paginationBtn}
disabled={pagination.page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Zurück
</button>
<button
className={styles.paginationBtn}
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Weiter
</button>
</div>
</div>
)}
</div>
{/* Modal: Neuen Kontakt anlegen */}
<ContactFormModal
isOpen={isCreateOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => setCreateOpen(false)}
/>
{/* Modal: Kontakt bearbeiten */}
<ContactFormModal
isOpen={!!editingContact}
onClose={() => setEditingContact(null)}
contact={editingContact}
onSuccess={() => setEditingContact(null)}
/>
{/* Modal: Kontakt löschen — Bestätigung */}
<Modal
isOpen={!!deletingContact}
onClose={() => setDeletingContact(null)}
title="Kontakt löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '0.5rem',
}}
>
Soll der Kontakt{' '}
<strong>
{deletingContact ? contactDisplayName(deletingContact) : ''}
</strong>{' '}
wirklich gelöscht werden?
</p>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '1.5rem',
}}
>
Alle Aktivitäten dieses Kontakts werden ebenfalls gelöscht. Deals
bleiben erhalten, verlieren aber die Kontaktverknüpfung.
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
onClick={() => setDeletingContact(null)}
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={() =>
deletingContact &&
deleteMutation.mutate(deletingContact.id, {
onSuccess: () => setDeletingContact(null),
})
}
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>
);
}