mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +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 { AdminCustomizePage } from '../admin/AdminCustomizePage';
|
||||
import { ProfilePage } from '../profile/ProfilePage';
|
||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||
import { DealsPage } from '../crm/deals/DealsPage';
|
||||
import { DealDetailPage } from '../crm/deals/DealDetailPage';
|
||||
import { PipelinesPage } from '../crm/pipelines/PipelinesPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
|
@ -48,6 +53,12 @@ export function App() {
|
|||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
{/* CRM-Bereich */}
|
||||
<Route path="crm/contacts" element={<ContactsPage />} />
|
||||
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
|
||||
<Route path="crm/deals" element={<DealsPage />} />
|
||||
<Route path="crm/deals/:id" element={<DealDetailPage />} />
|
||||
<Route path="crm/pipelines" element={<PipelinesPage />} />
|
||||
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
|
||||
<Route path="admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="users" replace />} />
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export function AppLayout() {
|
|||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { mode, setMode } = useTheme();
|
||||
const [crmOpen, setCrmOpen] = useState(true);
|
||||
const [appsOpen, setAppsOpen] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
|
|
@ -233,6 +234,101 @@ export function AppLayout() {
|
|||
{!collapsed && 'Dashboard'}
|
||||
</NavLink>
|
||||
|
||||
{/* CRM-Bereich (aufklappbar) */}
|
||||
{!collapsed ? (
|
||||
<button
|
||||
className={styles.navSectionToggle}
|
||||
onClick={() => setCrmOpen((p) => !p)}
|
||||
>
|
||||
<span>CRM</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className={`${styles.chevron} ${crmOpen ? styles.chevronOpen : ''}`}
|
||||
>
|
||||
<path d="M3 4.5l3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div className={styles.navDivider} />
|
||||
)}
|
||||
{(crmOpen || collapsed) && (
|
||||
<>
|
||||
<NavLink
|
||||
to="/crm/contacts"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
title="Kontakte"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="5" r="3" />
|
||||
<path d="M2 14c0-2.761 2.686-5 6-5s6 2.239 6 5" />
|
||||
</svg>
|
||||
{!collapsed && 'Kontakte'}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/crm/deals"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
title="Deals"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="5" width="14" height="9" rx="1" />
|
||||
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
|
||||
<path d="M1 9h14" />
|
||||
</svg>
|
||||
{!collapsed && 'Deals'}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/crm/pipelines"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
title="Pipelines"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="2" width="4" height="12" rx="0.5" />
|
||||
<rect x="6" y="2" width="4" height="8" rx="0.5" />
|
||||
<rect x="11" y="2" width="4" height="10" rx="0.5" />
|
||||
</svg>
|
||||
{!collapsed && 'Pipelines'}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Externe Links (aufklappbar) */}
|
||||
{externalLinks && externalLinks.length > 0 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ export default defineConfig({
|
|||
port: 8080,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api/v1/crm': {
|
||||
target: 'http://localhost:3100',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue