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>
418 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|