mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
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:
parent
f65b9fb930
commit
c739dce161
19 changed files with 4792 additions and 0 deletions
291
packages/frontend/src/crm/activities/ActivityFormModal.tsx
Normal file
291
packages/frontend/src/crm/activities/ActivityFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
packages/frontend/src/crm/api.ts
Normal file
159
packages/frontend/src/crm/api.ts
Normal 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),
|
||||||
|
};
|
||||||
170
packages/frontend/src/crm/contacts/ContactDetailPage.module.css
Normal file
170
packages/frontend/src/crm/contacts/ContactDetailPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
575
packages/frontend/src/crm/contacts/ContactDetailPage.tsx
Normal file
575
packages/frontend/src/crm/contacts/ContactDetailPage.tsx
Normal 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]} ·{' '}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
packages/frontend/src/crm/contacts/ContactFormModal.tsx
Normal file
377
packages/frontend/src/crm/contacts/ContactFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
packages/frontend/src/crm/contacts/ContactsPage.module.css
Normal file
81
packages/frontend/src/crm/contacts/ContactsPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
418
packages/frontend/src/crm/contacts/ContactsPage.tsx
Normal file
418
packages/frontend/src/crm/contacts/ContactsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
packages/frontend/src/crm/deals/DealDetailPage.module.css
Normal file
108
packages/frontend/src/crm/deals/DealDetailPage.module.css
Normal 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);
|
||||||
|
}
|
||||||
307
packages/frontend/src/crm/deals/DealDetailPage.tsx
Normal file
307
packages/frontend/src/crm/deals/DealDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
517
packages/frontend/src/crm/deals/DealFormModal.tsx
Normal file
517
packages/frontend/src/crm/deals/DealFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
packages/frontend/src/crm/deals/DealsPage.module.css
Normal file
81
packages/frontend/src/crm/deals/DealsPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
472
packages/frontend/src/crm/deals/DealsPage.tsx
Normal file
472
packages/frontend/src/crm/deals/DealsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
packages/frontend/src/crm/hooks.ts
Normal file
286
packages/frontend/src/crm/hooks.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
123
packages/frontend/src/crm/pipelines/PipelinesPage.module.css
Normal file
123
packages/frontend/src/crm/pipelines/PipelinesPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
484
packages/frontend/src/crm/pipelines/PipelinesPage.tsx
Normal file
484
packages/frontend/src/crm/pipelines/PipelinesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
packages/frontend/src/crm/types.ts
Normal file
232
packages/frontend/src/crm/types.ts
Normal 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';
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,11 @@ import { AdminSsoPage } from '../admin/AdminSsoPage';
|
||||||
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
|
||||||
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||||
import { ProfilePage } from '../profile/ProfilePage';
|
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 }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
@ -48,6 +53,12 @@ export function App() {
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<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) */}
|
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
|
||||||
<Route path="admin" element={<AdminLayout />}>
|
<Route path="admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="users" replace />} />
|
<Route index element={<Navigate to="users" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export function AppLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mode, setMode } = useTheme();
|
const { mode, setMode } = useTheme();
|
||||||
|
const [crmOpen, setCrmOpen] = useState(true);
|
||||||
const [appsOpen, setAppsOpen] = useState(true);
|
const [appsOpen, setAppsOpen] = useState(true);
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||||
|
|
@ -233,6 +234,101 @@ export function AppLayout() {
|
||||||
{!collapsed && 'Dashboard'}
|
{!collapsed && 'Dashboard'}
|
||||||
</NavLink>
|
</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) */}
|
{/* Externe Links (aufklappbar) */}
|
||||||
{externalLinks && externalLinks.length > 0 && (
|
{externalLinks && externalLinks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ export default defineConfig({
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: true,
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/api/v1/crm': {
|
||||||
|
target: 'http://localhost:3100',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue