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>
This commit is contained in:
Thomas Reitz 2026-03-10 19:13:02 +01:00
parent f65b9fb930
commit c739dce161
19 changed files with 4792 additions and 0 deletions

View file

@ -0,0 +1,291 @@
import { useState, useEffect } from 'react';
import { Modal } from '../../components/Modal';
import { useCreateActivity, useUpdateActivity } from '../hooks';
import type { Activity, ActivityType } from '../types';
interface ActivityFormModalProps {
isOpen: boolean;
onClose: () => void;
contactId: string;
activity?: Activity | null;
onSuccess: () => void;
}
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
NOTE: 'Notiz',
CALL: 'Anruf',
EMAIL: 'E-Mail',
MEETING: 'Meeting',
TASK: 'Aufgabe',
};
const ACTIVITY_TYPES: ActivityType[] = [
'NOTE',
'CALL',
'EMAIL',
'MEETING',
'TASK',
];
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
};
export function ActivityFormModal({
isOpen,
onClose,
contactId,
activity,
onSuccess,
}: ActivityFormModalProps) {
const isEditMode = !!activity;
const createMutation = useCreateActivity();
const updateMutation = useUpdateActivity();
const mutation = isEditMode ? updateMutation : createMutation;
const [error, setError] = useState('');
const [type, setType] = useState<ActivityType>('NOTE');
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [completedAt, setCompletedAt] = useState('');
useEffect(() => {
if (isOpen) {
setError('');
if (activity) {
setType(activity.type);
setSubject(activity.subject);
setDescription(activity.description ?? '');
setScheduledAt(
activity.scheduledAt
? activity.scheduledAt.slice(0, 16)
: '',
);
setCompletedAt(
activity.completedAt
? activity.completedAt.slice(0, 16)
: '',
);
} else {
setType('NOTE');
setSubject('');
setDescription('');
setScheduledAt('');
setCompletedAt('');
}
}
}, [isOpen, activity]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!subject.trim()) {
setError('Betreff ist ein Pflichtfeld');
return;
}
if (isEditMode && activity) {
updateMutation.mutate(
{
id: activity.id,
data: {
type,
subject: subject.trim(),
...(description ? { description } : {}),
...(scheduledAt ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}),
...(completedAt ? { completedAt: new Date(completedAt).toISOString() } : {}),
},
},
{
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
} else {
createMutation.mutate(
{
contactId,
type,
subject: subject.trim(),
...(description ? { description } : {}),
...(scheduledAt ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}),
...(completedAt ? { completedAt: new Date(completedAt).toISOString() } : {}),
},
{
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
setError(msg);
},
},
);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? 'Aktivität bearbeiten' : 'Neue Aktivität'}
maxWidth="500px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
{/* Typ */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Typ</label>
<select
value={type}
onChange={(e) => setType(e.target.value as ActivityType)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{ACTIVITY_TYPES.map((t) => (
<option key={t} value={t}>
{ACTIVITY_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
{/* Betreff */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Betreff *</label>
<input
style={inputStyle}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Betreff der Aktivität"
required
/>
</div>
{/* Beschreibung */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Beschreibung</label>
<textarea
style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung..."
/>
</div>
{/* Geplant am / Erledigt am */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
marginBottom: '1.5rem',
}}
>
<div>
<label style={labelStyle}>Geplant am</label>
<input
type="datetime-local"
style={inputStyle}
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>Erledigt am</label>
<input
type="datetime-local"
style={inputStyle}
value={completedAt}
onChange={(e) => setCompletedAt(e.target.value)}
/>
</div>
</div>
{/* Buttons */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
type="button"
onClick={onClose}
disabled={mutation.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
type="submit"
disabled={mutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: mutation.isPending ? 'wait' : 'pointer',
opacity: mutation.isPending ? 0.7 : 1,
}}
>
{mutation.isPending
? 'Speichern...'
: isEditMode
? 'Speichern'
: 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,159 @@
// ============================================================
// CRM API-Funktionen (nutzt bestehenden Axios-Client)
// ============================================================
import api from '../api/client';
import type {
Contact,
CreateContactPayload,
UpdateContactPayload,
ContactsQueryParams,
Deal,
CreateDealPayload,
UpdateDealPayload,
DealsQueryParams,
Pipeline,
CreatePipelinePayload,
UpdatePipelinePayload,
CreateStagePayload,
PipelineStage,
Activity,
CreateActivityPayload,
UpdateActivityPayload,
ActivitiesQueryParams,
PaginatedResponse,
SingleResponse,
} from './types';
// --- Contacts ---
export const contactsApi = {
list: (params: ContactsQueryParams) =>
api
.get<PaginatedResponse<Contact>>('/crm/contacts', { params })
.then((r) => r.data),
getById: (id: string) =>
api
.get<SingleResponse<Contact>>(`/crm/contacts/${id}`)
.then((r) => r.data),
create: (data: CreateContactPayload) =>
api
.post<SingleResponse<Contact>>('/crm/contacts', data)
.then((r) => r.data),
update: (id: string, data: UpdateContactPayload) =>
api
.patch<SingleResponse<Contact>>(`/crm/contacts/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<Contact>>(`/crm/contacts/${id}`)
.then((r) => r.data),
};
// --- Deals ---
export const dealsApi = {
list: (params: DealsQueryParams) =>
api
.get<PaginatedResponse<Deal>>('/crm/deals', { params })
.then((r) => r.data),
getById: (id: string) =>
api
.get<SingleResponse<Deal>>(`/crm/deals/${id}`)
.then((r) => r.data),
create: (data: CreateDealPayload) =>
api
.post<SingleResponse<Deal>>('/crm/deals', data)
.then((r) => r.data),
update: (id: string, data: UpdateDealPayload) =>
api
.patch<SingleResponse<Deal>>(`/crm/deals/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<Deal>>(`/crm/deals/${id}`)
.then((r) => r.data),
};
// --- Pipelines ---
export const pipelinesApi = {
list: () =>
api
.get<{ success: boolean; data: Pipeline[]; meta: { timestamp: string } }>(
'/crm/pipelines',
)
.then((r) => r.data),
getById: (id: string) =>
api
.get<SingleResponse<Pipeline>>(`/crm/pipelines/${id}`)
.then((r) => r.data),
create: (data: CreatePipelinePayload) =>
api
.post<SingleResponse<Pipeline>>('/crm/pipelines', data)
.then((r) => r.data),
update: (id: string, data: UpdatePipelinePayload) =>
api
.patch<SingleResponse<Pipeline>>(`/crm/pipelines/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<Pipeline>>(`/crm/pipelines/${id}`)
.then((r) => r.data),
addStage: (pipelineId: string, data: CreateStagePayload) =>
api
.post<SingleResponse<PipelineStage>>(
`/crm/pipelines/${pipelineId}/stages`,
data,
)
.then((r) => r.data),
removeStage: (pipelineId: string, stageId: string) =>
api
.delete<SingleResponse<PipelineStage>>(
`/crm/pipelines/${pipelineId}/stages/${stageId}`,
)
.then((r) => r.data),
};
// --- Activities ---
export const activitiesApi = {
list: (params: ActivitiesQueryParams) =>
api
.get<PaginatedResponse<Activity>>('/crm/activities', { params })
.then((r) => r.data),
getById: (id: string) =>
api
.get<SingleResponse<Activity>>(`/crm/activities/${id}`)
.then((r) => r.data),
create: (data: CreateActivityPayload) =>
api
.post<SingleResponse<Activity>>('/crm/activities', data)
.then((r) => r.data),
update: (id: string, data: UpdateActivityPayload) =>
api
.patch<SingleResponse<Activity>>(`/crm/activities/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
api
.delete<SingleResponse<Activity>>(`/crm/activities/${id}`)
.then((r) => r.data),
};

View file

@ -0,0 +1,170 @@
/* ============================================================
ContactDetailPage Layout & Komponenten
============================================================ */
.backLink {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1rem;
transition: color 0.15s;
}
.backLink:hover {
color: var(--color-primary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: 0.75rem;
}
.name {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
}
.layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
padding: 1.5rem;
}
.cardTitle {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--color-text);
}
.infoGrid {
display: grid;
grid-template-columns: 110px 1fr;
gap: 0.5rem 1rem;
font-size: 0.875rem;
}
.infoLabel {
color: var(--color-text-muted);
font-weight: 500;
}
.infoValue {
color: var(--color-text);
word-break: break-word;
}
/* Tags */
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag {
display: inline-block;
padding: 0.125rem 0.5rem;
background: var(--color-primary-light, #dbeafe);
color: var(--color-primary, #1a56db);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
/* Timeline */
.timeline {
display: flex;
flex-direction: column;
gap: 0;
}
.timelineItem {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.timelineItem:last-child {
border-bottom: none;
}
.timelineIcon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
}
.timelineContent {
flex: 1;
min-width: 0;
}
.timelineSubject {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.timelineMeta {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.125rem;
}
.timelineDesc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-top: 0.375rem;
white-space: pre-wrap;
}
/* Notes */
.notesSection {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.notesText {
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: pre-wrap;
line-height: 1.5;
}

View file

@ -0,0 +1,575 @@
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 Deals */}
{deals.length > 0 && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<h2 className={styles.cardTitle}>
Verknüpfte Deals ({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>
);
}

View file

@ -0,0 +1,377 @@
import { useState, useEffect } from 'react';
import { Modal } from '../../components/Modal';
import { useCreateContact, useUpdateContact } from '../hooks';
import type { Contact, ContactType } from '../types';
interface ContactFormModalProps {
isOpen: boolean;
onClose: () => void;
contact?: Contact | null;
onSuccess: () => void;
}
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
};
const rowStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
};
export function ContactFormModal({
isOpen,
onClose,
contact,
onSuccess,
}: ContactFormModalProps) {
const isEditMode = !!contact;
const createMutation = useCreateContact();
const updateMutation = useUpdateContact();
const mutation = isEditMode ? updateMutation : createMutation;
const [error, setError] = useState('');
const [type, setType] = useState<ContactType>('PERSON');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [companyName, setCompanyName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [mobile, setMobile] = useState('');
const [website, setWebsite] = useState('');
const [street, setStreet] = useState('');
const [zip, setZip] = useState('');
const [city, setCity] = useState('');
const [country, setCountry] = useState('DE');
const [notes, setNotes] = useState('');
const [tagsInput, setTagsInput] = useState('');
useEffect(() => {
if (isOpen) {
setError('');
if (contact) {
setType(contact.type);
setFirstName(contact.firstName ?? '');
setLastName(contact.lastName ?? '');
setCompanyName(contact.companyName ?? '');
setEmail(contact.email ?? '');
setPhone(contact.phone ?? '');
setMobile(contact.mobile ?? '');
setWebsite(contact.website ?? '');
setStreet(contact.street ?? '');
setZip(contact.zip ?? '');
setCity(contact.city ?? '');
setCountry(contact.country ?? 'DE');
setNotes(contact.notes ?? '');
setTagsInput((contact.tags ?? []).join(', '));
} else {
setType('PERSON');
setFirstName('');
setLastName('');
setCompanyName('');
setEmail('');
setPhone('');
setMobile('');
setWebsite('');
setStreet('');
setZip('');
setCity('');
setCountry('DE');
setNotes('');
setTagsInput('');
}
}
}, [isOpen, contact]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const payload = {
type,
...(type === 'PERSON' ? { firstName, lastName } : { companyName }),
...(email ? { email } : {}),
...(phone ? { phone } : {}),
...(mobile ? { mobile } : {}),
...(website ? { website } : {}),
...(street ? { street } : {}),
...(zip ? { zip } : {}),
...(city ? { city } : {}),
country,
...(notes ? { notes } : {}),
...(tags.length > 0 ? { tags } : {}),
};
if (isEditMode && contact) {
updateMutation.mutate(
{ id: contact.id, data: payload },
{
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
} else {
createMutation.mutate(payload, {
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
setError(msg);
},
});
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? 'Kontakt bearbeiten' : 'Neuer Kontakt'}
maxWidth="640px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
{/* Typ */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Typ</label>
<select
value={type}
onChange={(e) => setType(e.target.value as ContactType)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="PERSON">Person</option>
<option value="ORGANIZATION">Organisation</option>
</select>
</div>
{/* Name */}
{type === 'PERSON' ? (
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Vorname</label>
<input
style={inputStyle}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Max"
/>
</div>
<div>
<label style={labelStyle}>Nachname</label>
<input
style={inputStyle}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Mustermann"
/>
</div>
</div>
) : (
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Firmenname</label>
<input
style={inputStyle}
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Muster GmbH"
/>
</div>
)}
{/* E-Mail */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>E-Mail</label>
<input
type="email"
style={inputStyle}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="mail@example.com"
/>
</div>
{/* Telefon / Mobil */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Telefon</label>
<input
style={inputStyle}
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 ..."
/>
</div>
<div>
<label style={labelStyle}>Mobil</label>
<input
style={inputStyle}
value={mobile}
onChange={(e) => setMobile(e.target.value)}
placeholder="+49 ..."
/>
</div>
</div>
{/* Website */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Website</label>
<input
style={inputStyle}
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://..."
/>
</div>
{/* Adresse */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Straße</label>
<input
style={inputStyle}
value={street}
onChange={(e) => setStreet(e.target.value)}
/>
</div>
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>PLZ</label>
<input
style={inputStyle}
value={zip}
onChange={(e) => setZip(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>Stadt</label>
<input
style={inputStyle}
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Land</label>
<input
style={inputStyle}
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="DE"
maxLength={2}
/>
</div>
{/* Notizen */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* Tags */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Tags (kommasepariert)</label>
<input
style={inputStyle}
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="VIP, Neukunde, ..."
/>
</div>
{/* Buttons */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
type="button"
onClick={onClose}
disabled={mutation.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
type="submit"
disabled={mutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: mutation.isPending ? 'wait' : 'pointer',
opacity: mutation.isPending ? 0.7 : 1,
}}
>
{mutation.isPending
? 'Speichern...'
: isEditMode
? 'Speichern'
: 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,81 @@
/* ============================================================
ContactsPage Filterleiste & Paginierung
============================================================ */
.filterBar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
flex-wrap: wrap;
}
.searchInput {
flex: 1;
min-width: 200px;
max-width: 320px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
outline: none;
background: var(--color-bg-card);
color: var(--color-text);
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.filterSelect {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
background: var(--color-bg-card);
color: var(--color-text);
outline: none;
cursor: pointer;
}
.filterSelect:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.paginationButtons {
display: flex;
gap: 0.5rem;
}
.paginationBtn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s;
}
.paginationBtn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.paginationBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}

View file

@ -0,0 +1,418 @@
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>
);
}

View file

@ -0,0 +1,108 @@
/* ============================================================
DealDetailPage Layout & Komponenten
============================================================ */
.backLink {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1rem;
transition: color 0.15s;
}
.backLink:hover {
color: var(--color-primary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: 0.75rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.cardTitle {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--color-text);
}
.infoGrid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 0.5rem 1rem;
font-size: 0.875rem;
}
.infoLabel {
color: var(--color-text-muted);
font-weight: 500;
}
.infoValue {
color: var(--color-text);
word-break: break-word;
}
/* Stage Progress Bar */
.stageProgress {
display: flex;
gap: 2px;
margin-bottom: 1.5rem;
}
.stageStep {
flex: 1;
padding: 0.5rem 0.25rem;
text-align: center;
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-sm);
transition: all 0.15s;
color: var(--color-text-muted);
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.stageStepActive {
color: white;
border-color: transparent;
}
.notesText {
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: pre-wrap;
line-height: 1.5;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}

View file

@ -0,0 +1,307 @@
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>
);
}

View file

@ -0,0 +1,517 @@
import { useState, useEffect, useRef } from 'react';
import { Modal } from '../../components/Modal';
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
import { contactsApi } from '../api';
import type { Deal, DealStatus, Contact } from '../types';
interface DealFormModalProps {
isOpen: boolean;
onClose: () => void;
deal?: Deal | null;
onSuccess: () => void;
}
const STATUS_OPTIONS: { value: DealStatus; label: string }[] = [
{ value: 'OPEN', label: 'Offen' },
{ value: 'WON', label: 'Gewonnen' },
{ value: 'LOST', label: 'Verloren' },
];
const labelStyle: React.CSSProperties = {
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.25rem',
display: 'block',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
};
const rowStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
};
export function DealFormModal({
isOpen,
onClose,
deal,
onSuccess,
}: DealFormModalProps) {
const isEditMode = !!deal;
const createMutation = useCreateDeal();
const updateMutation = useUpdateDeal();
const mutation = isEditMode ? updateMutation : createMutation;
const { data: pipelinesData } = usePipelines();
const pipelines = pipelinesData?.data ?? [];
const [error, setError] = useState('');
const [title, setTitle] = useState('');
const [pipelineId, setPipelineId] = useState('');
const [stageId, setStageId] = useState('');
const [status, setStatus] = useState<DealStatus>('OPEN');
const [value, setValue] = useState('');
const [currency, setCurrency] = useState('EUR');
const [expectedCloseDate, setExpectedCloseDate] = useState('');
const [notes, setNotes] = useState('');
// Kontakt-Suche
const [contactSearch, setContactSearch] = useState('');
const [contactResults, setContactResults] = useState<Contact[]>([]);
const [selectedContact, setSelectedContact] = useState<{
id: string;
name: string;
} | null>(null);
const [showContactDropdown, setShowContactDropdown] = useState(false);
const contactRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Stages der gewaehlten Pipeline
const selectedPipeline = pipelines.find((p) => p.id === pipelineId);
const stages = selectedPipeline?.stages
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
: [];
// Click-Outside für Kontakt-Dropdown
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
contactRef.current &&
!contactRef.current.contains(e.target as Node)
) {
setShowContactDropdown(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Kontakt suchen (debounced)
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
if (!contactSearch || contactSearch.length < 2) {
setContactResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await contactsApi.list({
search: contactSearch,
pageSize: 8,
});
setContactResults(res.data);
setShowContactDropdown(true);
} catch {
setContactResults([]);
}
}, 300);
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, [contactSearch]);
useEffect(() => {
if (isOpen) {
setError('');
if (deal) {
setTitle(deal.title);
setPipelineId(deal.pipelineId);
setStageId(deal.stageId);
setStatus(deal.status);
setValue(deal.value ? String(parseFloat(deal.value)) : '');
setCurrency(deal.currency ?? 'EUR');
setExpectedCloseDate(
deal.expectedCloseDate
? deal.expectedCloseDate.slice(0, 10)
: '',
);
setNotes(deal.notes ?? '');
if (deal.contact) {
const { id, firstName, lastName, companyName } = deal.contact;
const name =
companyName ||
[firstName, lastName].filter(Boolean).join(' ') ||
'Kontakt';
setSelectedContact({ id, name });
setContactSearch(name);
} else {
setSelectedContact(null);
setContactSearch('');
}
} else {
setTitle('');
setPipelineId(pipelines.find((p) => p.isDefault)?.id ?? pipelines[0]?.id ?? '');
setStageId('');
setStatus('OPEN');
setValue('');
setCurrency('EUR');
setExpectedCloseDate('');
setNotes('');
setSelectedContact(null);
setContactSearch('');
}
setContactResults([]);
setShowContactDropdown(false);
}
}, [isOpen, deal, pipelines]);
// Wenn Pipeline wechselt, erste Stage auswaehlen
useEffect(() => {
if (!isEditMode && stages.length > 0 && !stages.find((s) => s.id === stageId)) {
setStageId(stages[0].id);
}
}, [pipelineId, stages, stageId, isEditMode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!title.trim()) {
setError('Titel ist ein Pflichtfeld');
return;
}
if (!pipelineId) {
setError('Pipeline auswählen');
return;
}
if (!stageId) {
setError('Stage auswählen');
return;
}
const payload = {
title: title.trim(),
pipelineId,
stageId,
status,
...(selectedContact ? { contactId: selectedContact.id } : {}),
...(value ? { value: parseFloat(value) } : {}),
currency,
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
...(notes ? { notes } : {}),
};
if (isEditMode && deal) {
updateMutation.mutate(
{ id: deal.id, data: payload },
{
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
} else {
createMutation.mutate(payload, {
onSuccess: () => onSuccess(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
setError(msg);
},
});
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? 'Deal bearbeiten' : 'Neuer Deal'}
maxWidth="600px"
>
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
padding: '0.75rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{error}
</div>
)}
{/* Titel */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Titel *</label>
<input
style={inputStyle}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal-Titel"
required
/>
</div>
{/* Pipeline + Stage */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Pipeline *</label>
<select
value={pipelineId}
onChange={(e) => setPipelineId(e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="">Pipeline wählen</option>
{pipelines
.filter((p) => p.isActive)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? ' (Standard)' : ''}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Stage *</label>
<select
value={stageId}
onChange={(e) => setStageId(e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
disabled={stages.length === 0}
>
<option value="">Stage wählen</option>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
</div>
{/* Kontakt-Suche */}
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={contactRef}>
<label style={labelStyle}>Kontakt</label>
<input
style={inputStyle}
value={contactSearch}
onChange={(e) => {
setContactSearch(e.target.value);
if (selectedContact) setSelectedContact(null);
}}
onFocus={() => {
if (contactResults.length > 0) setShowContactDropdown(true);
}}
placeholder="Kontakt suchen..."
/>
{selectedContact && (
<button
type="button"
onClick={() => {
setSelectedContact(null);
setContactSearch('');
}}
style={{
position: 'absolute',
right: 8,
top: 30,
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '1rem',
}}
>
×
</button>
)}
{showContactDropdown && contactResults.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-md)',
zIndex: 10,
maxHeight: 200,
overflowY: 'auto',
}}
>
{contactResults.map((c) => {
const name =
c.type === 'ORGANIZATION'
? c.companyName
: [c.firstName, c.lastName].filter(Boolean).join(' ');
return (
<div
key={c.id}
onClick={() => {
setSelectedContact({ id: c.id, name: name ?? '' });
setContactSearch(name ?? '');
setShowContactDropdown(false);
}}
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontSize: '0.875rem',
borderBottom: '1px solid var(--color-border)',
}}
onMouseEnter={(e) =>
((e.target as HTMLDivElement).style.background =
'var(--color-bg)')
}
onMouseLeave={(e) =>
((e.target as HTMLDivElement).style.background =
'transparent')
}
>
{name}
{c.email && (
<span
style={{
color: 'var(--color-text-muted)',
marginLeft: '0.5rem',
fontSize: '0.8125rem',
}}
>
{c.email}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* Wert + Währung */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Wert</label>
<input
type="number"
step="0.01"
min="0"
style={inputStyle}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.00"
/>
</div>
<div>
<label style={labelStyle}>Währung</label>
<input
style={inputStyle}
value={currency}
onChange={(e) => setCurrency(e.target.value)}
maxLength={3}
placeholder="EUR"
/>
</div>
</div>
{/* Status + Erw. Abschluss */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as DealStatus)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Erw. Abschluss</label>
<input
type="date"
style={inputStyle}
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
/>
</div>
</div>
{/* Notizen */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* Buttons */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
type="button"
onClick={onClose}
disabled={mutation.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
type="submit"
disabled={mutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: mutation.isPending ? 'wait' : 'pointer',
opacity: mutation.isPending ? 0.7 : 1,
}}
>
{mutation.isPending
? 'Speichern...'
: isEditMode
? 'Speichern'
: 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,81 @@
/* ============================================================
DealsPage Filterleiste & Paginierung
============================================================ */
.filterBar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
flex-wrap: wrap;
}
.searchInput {
flex: 1;
min-width: 180px;
max-width: 280px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
outline: none;
background: var(--color-bg-card);
color: var(--color-text);
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.filterSelect {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
background: var(--color-bg-card);
color: var(--color-text);
outline: none;
cursor: pointer;
}
.filterSelect:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.paginationButtons {
display: flex;
gap: 0.5rem;
}
.paginationBtn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s;
}
.paginationBtn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.paginationBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}

View file

@ -0,0 +1,472 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/Modal';
import { useDeals, useDeleteDeal, usePipelines } from '../hooks';
import { DealFormModal } from './DealFormModal';
import type { Deal, DealStatus, DealsQueryParams } from '../types';
import styles from './DealsPage.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 thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
};
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
function dealContactName(deal: Deal): string {
if (!deal.contact) return '—';
const { firstName, lastName, companyName } = deal.contact;
if (companyName) return companyName;
return [firstName, lastName].filter(Boolean).join(' ') || '—';
}
export function DealsPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<DealStatus | ''>('');
const [pipelineFilter, setPipelineFilter] = useState('');
const [stageFilter, setStageFilter] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [deletingDeal, setDeletingDeal] = useState<Deal | null>(null);
const { data: pipelinesData } = usePipelines();
const pipelines = pipelinesData?.data ?? [];
// Stages der gewaehlten Pipeline
const selectedPipeline = pipelines.find((p) => p.id === pipelineFilter);
const stages = selectedPipeline?.stages ?? [];
// Reset stageFilter wenn Pipeline wechselt
useEffect(() => {
setStageFilter('');
setPage(1);
}, [pipelineFilter]);
// Debounced search
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput]);
const params: DealsQueryParams = {
page,
pageSize: 25,
...(search ? { search } : {}),
...(statusFilter ? { status: statusFilter } : {}),
...(pipelineFilter ? { pipelineId: pipelineFilter } : {}),
...(stageFilter ? { stageId: stageFilter } : {}),
sort: 'createdAt',
order: 'desc',
};
const { data, isLoading, error } = useDeals(params);
const deleteMutation = useDeleteDeal();
if (isLoading) return <p>Laden...</p>;
if (error)
return (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Deals
</p>
);
const deals = 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 }}>Deals</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
{pagination?.total ?? 0} Deals 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 Deal
</button>
</div>
</div>
{/* Filterleiste */}
<div className={styles.filterBar}>
<input
type="search"
placeholder="Suche nach Titel..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className={styles.searchInput}
/>
<select
value={pipelineFilter}
onChange={(e) => setPipelineFilter(e.target.value)}
className={styles.filterSelect}
>
<option value="">Alle Pipelines</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{stages.length > 0 && (
<select
value={stageFilter}
onChange={(e) => {
setStageFilter(e.target.value);
setPage(1);
}}
className={styles.filterSelect}
>
<option value="">Alle Stufen</option>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
)}
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as DealStatus | '');
setPage(1);
}}
className={styles.filterSelect}
>
<option value="">Alle Status</option>
<option value="OPEN">Offen</option>
<option value="WON">Gewonnen</option>
<option value="LOST">Verloren</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}>Titel</th>
<th style={thStyle}>Kontakt</th>
<th style={thStyle}>Pipeline</th>
<th style={thStyle}>Stage</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Wert</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Aktionen</th>
</tr>
</thead>
<tbody>
{deals.length === 0 && (
<tr>
<td
colSpan={7}
style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
}}
>
Keine Deals gefunden
</td>
</tr>
)}
{deals.map((deal) => {
const statusStyle = STATUS_COLORS[deal.status];
return (
<tr
key={deal.id}
style={{
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
onClick={() => navigate(`/crm/deals/${deal.id}`)}
>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{deal.title}
</td>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dealContactName(deal)}
</td>
<td
style={{
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{deal.pipeline?.name ?? '—'}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
{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.75rem 1rem',
textAlign: 'right',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{deal.value
? currencyFormatter.format(parseFloat(deal.value))
: '—'}
</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: statusStyle.bg,
color: statusStyle.color,
}}
>
{STATUS_LABELS[deal.status]}
</span>
</td>
<td
style={{ padding: '0.75rem 1rem' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => setEditingDeal(deal)}
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={() => setDeletingDeal(deal)}
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 Deal anlegen */}
<DealFormModal
isOpen={isCreateOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => setCreateOpen(false)}
/>
{/* Modal: Deal bearbeiten */}
<DealFormModal
isOpen={!!editingDeal}
onClose={() => setEditingDeal(null)}
deal={editingDeal}
onSuccess={() => setEditingDeal(null)}
/>
{/* Modal: Deal löschen */}
<Modal
isOpen={!!deletingDeal}
onClose={() => setDeletingDeal(null)}
title="Deal löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '1.5rem',
}}
>
Soll der Deal <strong>{deletingDeal?.title}</strong> wirklich gelöscht
werden?
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
onClick={() => setDeletingDeal(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={() =>
deletingDeal &&
deleteMutation.mutate(deletingDeal.id, {
onSuccess: () => setDeletingDeal(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>
);
}

View file

@ -0,0 +1,286 @@
// ============================================================
// CRM React Query Hooks
// ============================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contactsApi, dealsApi, pipelinesApi, activitiesApi } from './api';
import type {
ContactsQueryParams,
DealsQueryParams,
ActivitiesQueryParams,
CreateContactPayload,
UpdateContactPayload,
CreateDealPayload,
UpdateDealPayload,
CreatePipelinePayload,
UpdatePipelinePayload,
CreateStagePayload,
CreateActivityPayload,
UpdateActivityPayload,
} from './types';
// --- Query Key Factory ---
export const crmKeys = {
contacts: {
all: ['crm', 'contacts'] as const,
list: (params: ContactsQueryParams) =>
['crm', 'contacts', 'list', params] as const,
detail: (id: string) => ['crm', 'contacts', 'detail', id] as const,
},
deals: {
all: ['crm', 'deals'] as const,
list: (params: DealsQueryParams) =>
['crm', 'deals', 'list', params] as const,
detail: (id: string) => ['crm', 'deals', 'detail', id] as const,
},
pipelines: {
all: ['crm', 'pipelines'] as const,
list: () => ['crm', 'pipelines', 'list'] as const,
detail: (id: string) => ['crm', 'pipelines', 'detail', id] as const,
},
activities: {
all: ['crm', 'activities'] as const,
list: (params: ActivitiesQueryParams) =>
['crm', 'activities', 'list', params] as const,
detail: (id: string) => ['crm', 'activities', 'detail', id] as const,
},
};
// ============================================================
// Contacts
// ============================================================
export function useContacts(params: ContactsQueryParams) {
return useQuery({
queryKey: crmKeys.contacts.list(params),
queryFn: () => contactsApi.list(params),
});
}
export function useContact(id: string) {
return useQuery({
queryKey: crmKeys.contacts.detail(id),
queryFn: () => contactsApi.getById(id),
enabled: !!id,
});
}
export function useCreateContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateContactPayload) => contactsApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
export function useUpdateContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateContactPayload }) =>
contactsApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
export function useDeleteContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => contactsApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
// ============================================================
// Deals
// ============================================================
export function useDeals(params: DealsQueryParams) {
return useQuery({
queryKey: crmKeys.deals.list(params),
queryFn: () => dealsApi.list(params),
});
}
export function useDeal(id: string) {
return useQuery({
queryKey: crmKeys.deals.detail(id),
queryFn: () => dealsApi.getById(id),
enabled: !!id,
});
}
export function useCreateDeal() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateDealPayload) => dealsApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
export function useUpdateDeal() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateDealPayload }) =>
dealsApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
export function useDeleteDeal() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => dealsApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
// ============================================================
// Pipelines
// ============================================================
export function usePipelines() {
return useQuery({
queryKey: crmKeys.pipelines.list(),
queryFn: () => pipelinesApi.list(),
staleTime: 10 * 60 * 1000, // Pipelines aendern sich selten
});
}
export function usePipeline(id: string) {
return useQuery({
queryKey: crmKeys.pipelines.detail(id),
queryFn: () => pipelinesApi.getById(id),
enabled: !!id,
});
}
export function useCreatePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreatePipelinePayload) => pipelinesApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
export function useUpdatePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: UpdatePipelinePayload;
}) => pipelinesApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
export function useDeletePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => pipelinesApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
export function useAddStage() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
pipelineId,
data,
}: {
pipelineId: string;
data: CreateStagePayload;
}) => pipelinesApi.addStage(pipelineId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
export function useRemoveStage() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
pipelineId,
stageId,
}: {
pipelineId: string;
stageId: string;
}) => pipelinesApi.removeStage(pipelineId, stageId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.pipelines.all });
},
});
}
// ============================================================
// Activities
// ============================================================
export function useActivities(params: ActivitiesQueryParams) {
return useQuery({
queryKey: crmKeys.activities.list(params),
queryFn: () => activitiesApi.list(params),
});
}
export function useCreateActivity() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateActivityPayload) => activitiesApi.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.activities.all });
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
export function useUpdateActivity() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: UpdateActivityPayload;
}) => activitiesApi.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.activities.all });
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
export function useDeleteActivity() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => activitiesApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.activities.all });
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}

View file

@ -0,0 +1,123 @@
/* ============================================================
PipelinesPage Pipeline Cards & Stage Management
============================================================ */
.pipelineCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: 1rem;
overflow: hidden;
}
.pipelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
cursor: pointer;
transition: background 0.15s;
}
.pipelineHeader:hover {
background: var(--color-bg);
}
.pipelineHeaderLeft {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pipelineName {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.stageList {
border-top: 1px solid var(--color-border);
padding: 0.75rem 1.25rem;
}
.stageItem {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.stageItem:last-child {
border-bottom: none;
}
.stageColor {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid var(--color-border);
}
.stageName {
flex: 1;
font-size: 0.875rem;
color: var(--color-text);
}
.stageOrder {
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 20px;
text-align: center;
}
.addStageForm {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 0;
border-top: 1px solid var(--color-border);
margin-top: 0.5rem;
}
.addStageInput {
flex: 1;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
outline: none;
background: var(--color-bg);
color: var(--color-text);
}
.addStageInput:focus {
border-color: var(--color-primary);
}
.colorInput {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
}
.newPipelineForm {
background: var(--color-bg-card);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1rem;
}
.newPipelineRow {
display: flex;
gap: 0.75rem;
align-items: end;
}

View file

@ -0,0 +1,484 @@
import { useState } from 'react';
import {
usePipelines,
useCreatePipeline,
useDeletePipeline,
useAddStage,
useRemoveStage,
} from '../hooks';
import { Modal } from '../../components/Modal';
import type { Pipeline } from '../types';
import styles from './PipelinesPage.module.css';
function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isOpen, setIsOpen] = useState(true);
const [newStageName, setNewStageName] = useState('');
const [newStageColor, setNewStageColor] = useState('#6B7280');
const [isDeleteOpen, setDeleteOpen] = useState(false);
const addStageMutation = useAddStage();
const removeStageMutation = useRemoveStage();
const deletePipelineMutation = useDeletePipeline();
const stages = [...pipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder);
const handleAddStage = () => {
if (!newStageName.trim()) return;
addStageMutation.mutate(
{
pipelineId: pipeline.id,
data: {
name: newStageName.trim(),
sortOrder: stages.length,
color: newStageColor,
},
},
{
onSuccess: () => {
setNewStageName('');
setNewStageColor('#6B7280');
},
},
);
};
return (
<div className={styles.pipelineCard}>
<div
className={styles.pipelineHeader}
onClick={() => setIsOpen((p) => !p)}
>
<div className={styles.pipelineHeaderLeft}>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="var(--color-text-muted)"
strokeWidth="2"
style={{
transform: isOpen ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform 0.2s',
}}
>
<path d="M4 2l4 4-4 4" />
</svg>
<span className={styles.pipelineName}>{pipeline.name}</span>
{pipeline.isDefault && (
<span
style={{
display: 'inline-block',
padding: '0.0625rem 0.375rem',
borderRadius: '9999px',
fontSize: '0.6875rem',
fontWeight: 500,
background: '#dbeafe',
color: '#1e40af',
}}
>
Standard
</span>
)}
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
}}
>
{stages.length} Stufen
</span>
</div>
<div
style={{ display: 'flex', gap: '0.5rem' }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setDeleteOpen(true)}
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--color-error)',
}}
>
Löschen
</button>
</div>
</div>
{isOpen && (
<div className={styles.stageList}>
{stages.length === 0 && (
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
padding: '0.5rem 0',
}}
>
Keine Stufen vorhanden
</p>
)}
{stages.map((stage) => (
<div key={stage.id} className={styles.stageItem}>
<span className={styles.stageOrder}>{stage.sortOrder + 1}</span>
<span
className={styles.stageColor}
style={{ background: stage.color }}
/>
<span className={styles.stageName}>{stage.name}</span>
<button
onClick={() =>
removeStageMutation.mutate({
pipelineId: pipeline.id,
stageId: stage.id,
})
}
disabled={removeStageMutation.isPending}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: '1rem',
padding: '0 0.25rem',
}}
title="Stufe entfernen"
>
×
</button>
</div>
))}
{/* Neue Stufe hinzufügen */}
<div className={styles.addStageForm}>
<input
className={styles.addStageInput}
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
placeholder="Neue Stufe..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddStage();
}
}}
/>
<input
type="color"
className={styles.colorInput}
value={newStageColor}
onChange={(e) => setNewStageColor(e.target.value)}
title="Farbe"
/>
<button
onClick={handleAddStage}
disabled={
!newStageName.trim() || addStageMutation.isPending
}
style={{
padding: '0.375rem 0.75rem',
fontSize: '0.8125rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor:
!newStageName.trim() || addStageMutation.isPending
? 'not-allowed'
: 'pointer',
opacity:
!newStageName.trim() || addStageMutation.isPending
? 0.5
: 1,
whiteSpace: 'nowrap',
}}
>
Hinzufügen
</button>
</div>
</div>
)}
{/* Löschen-Modal */}
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
title="Pipeline löschen"
maxWidth="420px"
>
<p
style={{
fontSize: '0.9375rem',
color: 'var(--color-text)',
marginBottom: '0.5rem',
}}
>
Soll die Pipeline <strong>{pipeline.name}</strong> wirklich gelöscht
werden?
</p>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '1.5rem',
}}
>
Alle Stufen und zugehörige Deals werden ebenfalls gelöscht.
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}
>
<button
onClick={() => setDeleteOpen(false)}
disabled={deletePipelineMutation.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={() =>
deletePipelineMutation.mutate(pipeline.id, {
onSuccess: () => setDeleteOpen(false),
})
}
disabled={deletePipelineMutation.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-error)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: deletePipelineMutation.isPending ? 'wait' : 'pointer',
opacity: deletePipelineMutation.isPending ? 0.7 : 1,
}}
>
{deletePipelineMutation.isPending
? 'Löschen...'
: 'Endgültig löschen'}
</button>
</div>
</Modal>
</div>
);
}
export function PipelinesPage() {
const { data, isLoading, error } = usePipelines();
const createMutation = useCreatePipeline();
const [showNewForm, setShowNewForm] = useState(false);
const [newName, setNewName] = useState('');
const [newIsDefault, setNewIsDefault] = useState(false);
if (isLoading) return <p>Laden...</p>;
if (error)
return (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Pipelines
</p>
);
const pipelines = data?.data ?? [];
const handleCreate = () => {
if (!newName.trim()) return;
createMutation.mutate(
{
name: newName.trim(),
isDefault: newIsDefault,
},
{
onSuccess: () => {
setNewName('');
setNewIsDefault(false);
setShowNewForm(false);
},
},
);
};
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Pipelines</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}
>
{pipelines.length} Pipelines
</span>
<button
onClick={() => setShowNewForm(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',
}}
>
+ Neue Pipeline
</button>
</div>
</div>
{/* Neue Pipeline Form */}
{showNewForm && (
<div className={styles.newPipelineForm}>
<div className={styles.newPipelineRow}>
<div style={{ flex: 1 }}>
<label
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
display: 'block',
marginBottom: '0.25rem',
}}
>
Pipeline-Name
</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z.B. Vertrieb"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
}
}}
style={{
width: '100%',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
outline: 'none',
boxSizing: 'border-box',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
/>
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
fontSize: '0.875rem',
color: 'var(--color-text)',
cursor: 'pointer',
paddingBottom: '0.375rem',
}}
>
<input
type="checkbox"
checked={newIsDefault}
onChange={(e) => setNewIsDefault(e.target.checked)}
/>
Standard
</label>
<button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
style={{
padding: '0.625rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor:
!newName.trim() || createMutation.isPending
? 'not-allowed'
: 'pointer',
opacity:
!newName.trim() || createMutation.isPending ? 0.5 : 1,
whiteSpace: 'nowrap',
}}
>
{createMutation.isPending ? 'Erstellen...' : 'Erstellen'}
</button>
<button
onClick={() => {
setShowNewForm(false);
setNewName('');
setNewIsDefault(false);
}}
style={{
padding: '0.625rem 0.75rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
</div>
</div>
)}
{/* Pipeline-Liste */}
{pipelines.length === 0 && !showNewForm && (
<div
style={{
padding: '3rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
}}
>
<p style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>
Noch keine Pipelines vorhanden
</p>
<p style={{ fontSize: '0.875rem' }}>
Erstelle eine Pipeline, um Deals zu verwalten.
</p>
</div>
)}
{pipelines.map((pipeline) => (
<PipelineCard key={pipeline.id} pipeline={pipeline} />
))}
</div>
);
}

View file

@ -0,0 +1,232 @@
// ============================================================
// CRM Zentrale TypeScript-Interfaces
// ============================================================
// --- Enums ---
export type ContactType = 'PERSON' | 'ORGANIZATION';
export type DealStatus = 'OPEN' | 'WON' | 'LOST';
export type ActivityType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK';
// --- Contact ---
export interface Contact {
id: string;
tenantId: string;
type: ContactType;
firstName: string | null;
lastName: string | null;
companyName: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
website: string | null;
street: string | null;
zip: string | null;
city: string | null;
state: string | null;
country: string;
notes: string | null;
tags: string[];
isActive: boolean;
createdBy: string;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
activities?: Activity[];
}
export interface CreateContactPayload {
type?: ContactType;
firstName?: string;
lastName?: string;
companyName?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
street?: string;
zip?: string;
city?: string;
state?: string;
country?: string;
notes?: string;
tags?: string[];
isActive?: boolean;
}
export type UpdateContactPayload = Partial<CreateContactPayload>;
// --- Activity ---
export interface Activity {
id: string;
tenantId: string;
contactId: string;
type: ActivityType;
subject: string;
description: string | null;
scheduledAt: string | null;
completedAt: string | null;
createdBy: string;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
contact?: {
id: string;
firstName: string | null;
lastName: string | null;
companyName: string | null;
};
}
export interface CreateActivityPayload {
contactId: string;
type: ActivityType;
subject: string;
description?: string;
scheduledAt?: string;
completedAt?: string;
}
export type UpdateActivityPayload = Partial<Omit<CreateActivityPayload, 'contactId'>>;
// --- Pipeline + Stage ---
export interface PipelineStage {
id: string;
pipelineId: string;
name: string;
sortOrder: number;
color: string;
createdAt: string;
updatedAt: string;
}
export interface Pipeline {
id: string;
tenantId: string;
name: string;
isDefault: boolean;
isActive: boolean;
createdBy: string;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
stages: PipelineStage[];
}
export interface CreatePipelinePayload {
name: string;
isDefault?: boolean;
stages?: { name: string; sortOrder?: number; color?: string }[];
}
export interface UpdatePipelinePayload {
name?: string;
isDefault?: boolean;
isActive?: boolean;
}
export interface CreateStagePayload {
name: string;
sortOrder?: number;
color?: string;
}
// --- Deal ---
export interface Deal {
id: string;
tenantId: string;
pipelineId: string;
stageId: string;
contactId: string | null;
title: string;
value: string | null; // Decimal kommt als String vom Backend
currency: string;
status: DealStatus;
expectedCloseDate: string | null;
closedAt: string | null;
notes: string | null;
createdBy: string;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
pipeline?: { id: string; name: string };
stage?: { id: string; name: string; color: string };
contact?: {
id: string;
firstName: string | null;
lastName: string | null;
companyName: string | null;
} | null;
}
export interface CreateDealPayload {
pipelineId: string;
stageId: string;
contactId?: string;
title: string;
value?: number;
currency?: string;
status?: DealStatus;
expectedCloseDate?: string;
notes?: string;
}
export type UpdateDealPayload = Partial<CreateDealPayload>;
// --- API Response Wrapper ---
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
success: boolean;
data: T[];
pagination: PaginationMeta;
meta: { timestamp: string };
}
export interface SingleResponse<T> {
success: boolean;
data: T;
meta: { timestamp: string };
}
// --- Query Params ---
export interface ContactsQueryParams {
page?: number;
pageSize?: number;
search?: string;
type?: ContactType;
sort?: string;
order?: 'asc' | 'desc';
}
export interface DealsQueryParams {
page?: number;
pageSize?: number;
pipelineId?: string;
stageId?: string;
contactId?: string;
status?: DealStatus;
search?: string;
sort?: string;
order?: 'asc' | 'desc';
}
export interface ActivitiesQueryParams {
page?: number;
pageSize?: number;
contactId?: string;
type?: ActivityType;
sort?: string;
order?: 'asc' | 'desc';
}

View file

@ -11,6 +11,11 @@ import { AdminSsoPage } from '../admin/AdminSsoPage';
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
import { DealsPage } from '../crm/deals/DealsPage';
import { DealDetailPage } from '../crm/deals/DealDetailPage';
import { PipelinesPage } from '../crm/pipelines/PipelinesPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@ -48,6 +53,12 @@ export function App() {
>
<Route index element={<DashboardPage />} />
<Route path="profile" element={<ProfilePage />} />
{/* CRM-Bereich */}
<Route path="crm/contacts" element={<ContactsPage />} />
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
<Route path="crm/deals" element={<DealsPage />} />
<Route path="crm/deals/:id" element={<DealDetailPage />} />
<Route path="crm/pipelines" element={<PipelinesPage />} />
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="users" replace />} />

View file

@ -112,6 +112,7 @@ export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { mode, setMode } = useTheme();
const [crmOpen, setCrmOpen] = useState(true);
const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem('sidebar-collapsed') === 'true';
@ -233,6 +234,101 @@ export function AppLayout() {
{!collapsed && 'Dashboard'}
</NavLink>
{/* CRM-Bereich (aufklappbar) */}
{!collapsed ? (
<button
className={styles.navSectionToggle}
onClick={() => setCrmOpen((p) => !p)}
>
<span>CRM</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${crmOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
) : (
<div className={styles.navDivider} />
)}
{(crmOpen || collapsed) && (
<>
<NavLink
to="/crm/contacts"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Kontakte"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.761 2.686-5 6-5s6 2.239 6 5" />
</svg>
{!collapsed && 'Kontakte'}
</NavLink>
<NavLink
to="/crm/deals"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Deals"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="5" width="14" height="9" rx="1" />
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
<path d="M1 9h14" />
</svg>
{!collapsed && 'Deals'}
</NavLink>
<NavLink
to="/crm/pipelines"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Pipelines"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="2" width="4" height="12" rx="0.5" />
<rect x="6" y="2" width="4" height="8" rx="0.5" />
<rect x="11" y="2" width="4" height="10" rx="0.5" />
</svg>
{!collapsed && 'Pipelines'}
</NavLink>
</>
)}
{/* Externe Links (aufklappbar) */}
{externalLinks && externalLinks.length > 0 && (
<>

View file

@ -13,6 +13,10 @@ export default defineConfig({
port: 8080,
host: true,
proxy: {
'/api/v1/crm': {
target: 'http://localhost:3100',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,