feat(crm): UI/UX Redesign – Kontakt Eingabemaske & Detailansicht

- Neue Drawer-Komponente (right-side slide-in, sticky footer, Portal)
- ContactFormModal: Modal → Drawer, Tab 1 Allgemein / Tab 2 Details & Adresse
- Checkbox "Adresse vom Unternehmen übernehmen" (blendet Adressfelder aus)
- Typ-Feld entfernt (Kontakte immer PERSON)
- ContactDetailPage: Subtitle "Position @ Unternehmen" im Header
- Kontaktdaten-Card: 2 Sub-Spalten, kein Firma-Duplikat, Mobil tel:-Link, LinkedIn-Icon
- Neue Card "Notizen & Tags" (Badges + Notizen-Text + Custom Fields)
- Aktivitäten-Spalte auf minmax(380px, 40%) verbreitert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 20:20:30 +01:00
parent fdab2d5bcb
commit ec9f3ea364
6 changed files with 1073 additions and 651 deletions

View file

@ -1,11 +1,41 @@
# INSIGHT MVP - Aenderungsprotokoll # INSIGHT MVP - Aenderungsprotokoll
## Stand: 2026-03-08 ## Stand: 2026-03-12
### Aktueller Sprint: Sprint 1 (Alpha) ### Aktueller Sprint: Sprint 1 (Alpha)
--- ---
### Aenderungen 2026-03-12: CRM UI/UX Redesign Kontakt-Entitaet
#### Drawer-Komponente (neu)
- `packages/frontend/src/components/Drawer.tsx` Wiederverwendbares Right-Side-Drawer-Pattern mit Portal, ESC-Key, Backdrop-Click, optionalem Footer
- `packages/frontend/src/components/Drawer.module.css` Animiertes Slide-In von rechts, sticky Header + Footer, scrollbarer Body
#### ContactFormModal Redesign (Modal → Drawer + Tabs)
- Gewechselt von `<Modal>` (zentriert) zu `<Drawer>` (von rechts, 540px)
- Zwei-Tab-Struktur:
- Tab "Allgemein": Vorname, Nachname, Unternehmen (Autocomplete), Position, Abteilung, E-Mail, Telefon, Mobil, LinkedIn
- Tab "Details & Adresse": Geburtsdatum, Quelle, Status, Website, Adresse, Notizen, Tags, Custom Fields
- "Adresse vom Unternehmen uebernehmen" Checkbox: wird angezeigt wenn Unternehmen verlinkt ist; bei aktivierter Checkbox werden Adressfelder ausgeblendet und die Adresse wird aus dem verlinkten Unternehmen uebernommen
- "Typ"-Feld entfernt (Kontakte sind immer Personen; ORGANIZATION-Typ bleibt intern erhalten)
- Sticky Footer (Abbrechen / Anlegen|Speichern) via HTML5 `form` Attribut
- Adress-Duplikate bereinigt
#### ContactDetailPage Redesign
- **Header**: Subtitle "Position @ Unternehmen" unter dem Namen (z.B. "Geschaeftsfuehrerin @ team neusta SE"), Status-Dot inline
- **Kontaktdaten-Card**: Zwei Sub-Spalten (Kommunikation links, Kontext rechts), Adresse als volle Breite darunter
- Entfernt: Vorname/Nachname (im Header), Firma-Duplikat (nur noch "Unternehmen" als Link)
- Hinzugefuegt: Mobil als `tel:`-Link, LinkedIn mit Icon, Geburtsdatum, Quelle, Abteilung
- **Neue Card "Notizen & Tags"**: Tags als Badges, Notizen-Text, Custom Fields
- **Layout**: Rechte Spalte (Aktivitaeten) auf `minmax(380px, 40%)` verbreitert (min. 1/3 fuer kuenftigen O365-Feed)
- **CSS**: `.infoColumns` (2-spaltig), `.addressRow`, `.subtitle`, `.nameRow` neu
#### TypeScript
- `npx tsc --noEmit` in packages/frontend: 0 Fehler
---
### Aenderungen in dieser Session ### Aenderungen in dieser Session
#### 1. Projektinitialisierung & Infrastruktur-Definition #### 1. Projektinitialisierung & Infrastruktur-Definition

View file

@ -0,0 +1,97 @@
/* ============================================================
Drawer Right-side slide-in panel
============================================================ */
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.4);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
max-width: 100vw;
background: var(--color-bg-card, #fff);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
animation: slideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
/* ---- Header ---- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
flex-shrink: 0;
background: var(--color-bg-card, #fff);
}
.title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 1.5rem;
line-height: 1;
color: var(--color-text-muted, #9ca3af);
background: none;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.closeButton:hover {
background: var(--color-bg, #f3f4f6);
color: var(--color-text, #111827);
}
/* ---- Body (scrollable) ---- */
.body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
overscroll-behavior: contain;
}
/* ---- Footer (sticky) ---- */
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
flex-shrink: 0;
background: var(--color-bg-card, #fff);
}
/* ---- Responsive ---- */
@media (max-width: 600px) {
.panel {
width: 100% !important;
}
}

View file

@ -0,0 +1,69 @@
import { useEffect, useCallback, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import styles from './Drawer.module.css';
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
width?: string;
}
export function Drawer({
isOpen,
onClose,
title,
children,
footer,
width = '520px',
}: DrawerProps) {
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return createPortal(
<div className={styles.overlay} onClick={onClose}>
<div
className={styles.panel}
style={{ width }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div>
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
</div>,
document.body,
);
}

View file

@ -2,6 +2,7 @@
ContactDetailPage Layout & Komponenten ContactDetailPage Layout & Komponenten
============================================================ */ ============================================================ */
/* ---- Navigation ---- */
.backLink { .backLink {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -17,9 +18,10 @@
color: var(--color-primary); color: var(--color-primary);
} }
/* ---- Header ---- */
.header { .header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
gap: 1rem; gap: 1rem;
@ -28,8 +30,15 @@
.headerLeft { .headerLeft {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
}
.nameRow {
display: flex;
align-items: center;
gap: 0.625rem;
} }
.name { .name {
@ -39,19 +48,27 @@
color: var(--color-text); color: var(--color-text);
} }
.subtitle {
font-size: 0.9375rem;
color: var(--color-text-muted);
margin: 0.2rem 0 0;
}
/* ---- Main layout ---- */
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 1fr 360px; grid-template-columns: 1fr minmax(380px, 40%);
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
@media (max-width: 900px) { @media (max-width: 960px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ---- Card ---- */
.card { .card {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@ -67,16 +84,34 @@
color: var(--color-text); color: var(--color-text);
} }
/* ---- Two-column info layout inside a card ---- */
.infoColumns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 2rem;
align-items: start;
}
@media (max-width: 640px) {
.infoColumns {
grid-template-columns: 1fr;
gap: 0;
}
}
/* ---- Info grid (label | value pairs) ---- */
.infoGrid { .infoGrid {
display: grid; display: grid;
grid-template-columns: 110px 1fr; grid-template-columns: 100px 1fr;
gap: 0.5rem 1rem; gap: 0.5rem 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
align-content: start;
} }
.infoLabel { .infoLabel {
color: var(--color-text-muted); color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
padding-top: 0.0625rem; /* optical alignment */
} }
.infoValue { .infoValue {
@ -84,7 +119,14 @@
word-break: break-word; word-break: break-word;
} }
/* Tags */ /* ---- Address row (full-width below infoColumns) ---- */
.addressRow {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
/* ---- Tags ---- */
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -101,7 +143,7 @@
font-weight: 500; font-weight: 500;
} }
/* Timeline */ /* ---- Timeline ---- */
.timeline { .timeline {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -155,16 +197,11 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
/* Notes */ /* ---- Notes ---- */
.notesSection {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.notesText { .notesText {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.5; line-height: 1.5;
margin: 0;
} }

View file

@ -6,20 +6,10 @@ import { ActivityFormModal } from '../activities/ActivityFormModal';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { LexwareSection } from '../lexware/LexwareSection'; import { LexwareSection } from '../lexware/LexwareSection';
import { CustomFieldsDisplay } from '../CustomFieldsDisplay'; import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
import type { Contact, Activity, ActivityType, ContactType } from '../types'; import type { Contact, Activity, ActivityType } from '../types';
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types'; import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
import styles from './ContactDetailPage.module.css'; 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> = { const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
NOTE: 'Notiz', NOTE: 'Notiz',
CALL: 'Anruf', CALL: 'Anruf',
@ -73,6 +63,8 @@ function activityIcon(type: ActivityType): React.ReactNode {
<rect x="1" y="1" width="14" height="14" rx="2" /> <rect x="1" y="1" width="14" height="14" rx="2" />
</svg> </svg>
); );
default:
return null;
} }
} }
@ -96,6 +88,21 @@ const currencyFormatter = new Intl.NumberFormat('de-DE', {
currency: 'EUR', currency: 'EUR',
}); });
// LinkedIn icon SVG
function LinkedInIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="currentColor"
style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }}
>
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854V1.146zm4.943 12.248V6.169H2.542v7.225h2.401zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248-.822 0-1.359.54-1.359 1.248 0 .694.521 1.248 1.327 1.248h.016zm4.908 8.212V9.359c0-.216.016-.432.08-.586.175-.431.573-.878 1.242-.878.877 0 1.228.67 1.228 1.652v3.847h2.4V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016a5.54 5.54 0 0 1 .016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225h2.4z" />
</svg>
);
}
export function ContactDetailPage() { export function ContactDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -107,7 +114,7 @@ export function ContactDetailPage() {
const [isActivityOpen, setActivityOpen] = useState(false); const [isActivityOpen, setActivityOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false); const [isDeleteOpen, setDeleteOpen] = useState(false);
if (isLoading) return <p>Laden...</p>; if (isLoading) return <p>Laden</p>;
if (error || !data) if (error || !data)
return ( return (
<p style={{ color: 'var(--color-error)' }}> <p style={{ color: 'var(--color-error)' }}>
@ -119,9 +126,14 @@ export function ContactDetailPage() {
const activities: Activity[] = contact.activities ?? []; const activities: Activity[] = contact.activities ?? [];
const deals = dealsData?.data ?? []; const deals = dealsData?.data ?? [];
// Subtitle: "Position @ Unternehmen"
const subtitle = [contact.position, contact.company?.name]
.filter(Boolean)
.join(' @ ');
return ( return (
<div> <div>
{/* Zurück */} {/* Back link */}
<Link to="/crm/contacts" className={styles.backLink}> <Link to="/crm/contacts" className={styles.backLink}>
<svg <svg
width="14" width="14"
@ -136,23 +148,12 @@ export function ContactDetailPage() {
Zurück zu Kontakte Zurück zu Kontakte
</Link> </Link>
{/* Header */} {/* ── Header ── */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<div>
<div className={styles.nameRow}>
<h1 className={styles.name}>{contactDisplayName(contact)}</h1> <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 <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
@ -162,11 +163,17 @@ export function ContactDetailPage() {
background: contact.isActive background: contact.isActive
? 'var(--color-success)' ? 'var(--color-success)'
: 'var(--color-error)', : 'var(--color-error)',
flexShrink: 0,
}} }}
title={contact.isActive ? 'Aktiv' : 'Inaktiv'} title={contact.isActive ? 'Aktiv' : 'Inaktiv'}
/> />
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> {subtitle && (
<p className={styles.subtitle}>{subtitle}</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button <button
onClick={() => setEditOpen(true)} onClick={() => setEditOpen(true)}
style={{ style={{
@ -198,58 +205,18 @@ export function ContactDetailPage() {
</div> </div>
</div> </div>
{/* 2-Spalten Layout */} {/* ── Two-column layout ── */}
<div className={styles.layout}> <div className={styles.layout}>
{/* Links: Info + Deals */} {/* ── Left column ── */}
<div> <div>
{/* Info Card */} {/* Contact data card */}
<div className={styles.card}> <div className={styles.card}>
<h2 className={styles.cardTitle}>Kontaktdaten</h2> <h2 className={styles.cardTitle}>Kontaktdaten</h2>
{/* Two sub-columns */}
<div className={styles.infoColumns}>
{/* Left: communication */}
<div className={styles.infoGrid}> <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.company && (
<>
<span className={styles.infoLabel}>Unternehmen</span>
<span className={styles.infoValue}>
<Link
to={`/crm/companies/${contact.company.id}`}
style={{ color: 'var(--color-primary)' }}
>
{contact.company.name}
</Link>
{contact.company.industry && (
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.5rem', fontSize: '0.8125rem' }}>
({contact.company.industry})
</span>
)}
</span>
</>
)}
{contact.position && (
<>
<span className={styles.infoLabel}>Position</span>
<span className={styles.infoValue}>{contact.position}</span>
</>
)}
{contact.email && ( {contact.email && (
<> <>
<span className={styles.infoLabel}>E-Mail</span> <span className={styles.infoLabel}>E-Mail</span>
@ -272,7 +239,33 @@ export function ContactDetailPage() {
{contact.mobile && ( {contact.mobile && (
<> <>
<span className={styles.infoLabel}>Mobil</span> <span className={styles.infoLabel}>Mobil</span>
<span className={styles.infoValue}>{contact.mobile}</span> <span className={styles.infoValue}>
<a
href={`tel:${contact.mobile}`}
style={{ color: 'var(--color-primary)' }}
>
{contact.mobile}
</a>
</span>
</>
)}
{contact.linkedinUrl && (
<>
<span className={styles.infoLabel}>LinkedIn</span>
<span className={styles.infoValue}>
<a
href={contact.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}
>
<LinkedInIcon />
{contact.linkedinUrl.replace(
/^https?:\/\/(www\.)?linkedin\.com\/in\//,
'',
)}
</a>
</span>
</> </>
)} )}
{contact.website && ( {contact.website && (
@ -290,30 +283,53 @@ export function ContactDetailPage() {
</span> </span>
</> </>
)} )}
{contact.linkedinUrl && ( </div>
{/* Right: context */}
<div className={styles.infoGrid}>
{contact.company && (
<> <>
<span className={styles.infoLabel}>LinkedIn</span> <span className={styles.infoLabel}>Unternehmen</span>
<span className={styles.infoValue}> <span className={styles.infoValue}>
<a <Link
href={contact.linkedinUrl} to={`/crm/companies/${contact.company.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }} style={{ color: 'var(--color-primary)' }}
> >
{contact.linkedinUrl.replace(/^https?:\/\/(www\.)?linkedin\.com\/in\//, '')} {contact.company.name}
</a> </Link>
{contact.company.industry && (
<span
style={{
color: 'var(--color-text-muted)',
marginLeft: '0.5rem',
fontSize: '0.8125rem',
}}
>
({contact.company.industry})
</span>
)}
</span>
</>
)}
{contact.position && (
<>
<span className={styles.infoLabel}>Position</span>
<span className={styles.infoValue}>
{contact.position}
</span> </span>
</> </>
)} )}
{contact.department && ( {contact.department && (
<> <>
<span className={styles.infoLabel}>Abteilung</span> <span className={styles.infoLabel}>Abteilung</span>
<span className={styles.infoValue}>{contact.department}</span> <span className={styles.infoValue}>
{contact.department}
</span>
</> </>
)} )}
{contact.birthday && ( {contact.birthday && (
<> <>
<span className={styles.infoLabel}>Geburtstag</span> <span className={styles.infoLabel}>Geburtsdatum</span>
<span className={styles.infoValue}> <span className={styles.infoValue}>
{new Date(contact.birthday).toLocaleDateString('de-DE')} {new Date(contact.birthday).toLocaleDateString('de-DE')}
</span> </span>
@ -330,36 +346,57 @@ export function ContactDetailPage() {
{contact.status && contact.status !== 'ACTIVE' && ( {contact.status && contact.status !== 'ACTIVE' && (
<> <>
<span className={styles.infoLabel}>Status</span> <span className={styles.infoLabel}>Status</span>
<span className={styles.infoValue} style={{ <span
color: contact.status === 'BLOCKED' ? '#991b1b' : 'var(--color-text-muted)', className={styles.infoValue}
}}> style={{
color:
contact.status === 'BLOCKED'
? '#991b1b'
: 'var(--color-text-muted)',
}}
>
{ENTITY_STATUS_LABELS[contact.status] ?? contact.status} {ENTITY_STATUS_LABELS[contact.status] ?? contact.status}
</span> </span>
</> </>
)} )}
</div>
</div>
{/* Address — full-width below sub-columns */}
{(contact.street || contact.zip || contact.city) && ( {(contact.street || contact.zip || contact.city) && (
<> <div className={styles.addressRow}>
<div className={styles.infoGrid}>
<span className={styles.infoLabel}>Adresse</span> <span className={styles.infoLabel}>Adresse</span>
<span className={styles.infoValue}> <span className={styles.infoValue}>
{contact.street && <>{contact.street}<br /></>} {contact.street && (
<>
{contact.street}
<br />
</>
)}
{contact.zip} {contact.city} {contact.zip} {contact.city}
{contact.country && contact.country !== 'DE' && ( {contact.country && contact.country !== 'DE' && (
<>, {contact.country}</> <>, {contact.country}</>
)} )}
</span> </span>
</> </div>
</div>
)} )}
</div> </div>
{/* Tags */} {/* Notizen & Tags card */}
{((contact.tags && contact.tags.length > 0) ||
contact.notes ||
(contact.customFields && contact.customFields.length > 0)) && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<h2 className={styles.cardTitle}>Notizen &amp; Tags</h2>
{contact.tags && contact.tags.length > 0 && ( {contact.tags && contact.tags.length > 0 && (
<div style={{ marginTop: '1rem' }}> <div
<span style={{
className={styles.infoLabel} marginBottom: contact.notes ? '1rem' : 0,
style={{ display: 'block', marginBottom: '0.375rem' }} }}
> >
Tags
</span>
<div className={styles.tags}> <div className={styles.tags}>
{contact.tags.map((tag) => ( {contact.tags.map((tag) => (
<span key={tag} className={styles.tag}> <span key={tag} className={styles.tag}>
@ -370,26 +407,24 @@ export function ContactDetailPage() {
</div> </div>
)} )}
{/* Notizen */}
{contact.notes && ( {contact.notes && (
<div className={styles.notesSection}> <p
<span className={styles.notesText}
className={styles.infoLabel} style={{ marginTop: contact.tags?.length ? '0.75rem' : 0 }}
style={{ display: 'block', marginBottom: '0.375rem' }}
> >
Notizen {contact.notes}
</span> </p>
<p className={styles.notesText}>{contact.notes}</p>
</div>
)} )}
{/* Custom Fields */}
{contact.customFields && contact.customFields.length > 0 && ( {contact.customFields && contact.customFields.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<CustomFieldsDisplay fields={contact.customFields} /> <CustomFieldsDisplay fields={contact.customFields} />
</div>
)} )}
</div> </div>
)}
{/* Verknüpfte Vorgänge */} {/* Linked deals */}
{deals.length > 0 && ( {deals.length > 0 && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}> <div className={styles.card} style={{ marginTop: '1.5rem' }}>
<h2 className={styles.cardTitle}> <h2 className={styles.cardTitle}>
@ -397,11 +432,7 @@ export function ContactDetailPage() {
</h2> </h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr <tr style={{ borderBottom: '1px solid var(--color-border)' }}>
style={{
borderBottom: '1px solid var(--color-border)',
}}
>
<th <th
style={{ style={{
padding: '0.5rem 0', padding: '0.5rem 0',
@ -448,10 +479,7 @@ export function ContactDetailPage() {
onClick={() => navigate(`/crm/deals/${deal.id}`)} onClick={() => navigate(`/crm/deals/${deal.id}`)}
> >
<td <td
style={{ style={{ padding: '0.5rem 0', fontSize: '0.875rem' }}
padding: '0.5rem 0',
fontSize: '0.875rem',
}}
> >
{deal.title} {deal.title}
</td> </td>
@ -508,7 +536,7 @@ export function ContactDetailPage() {
</div> </div>
</div> </div>
{/* Rechts: Aktivitäten-Timeline */} {/* ── Right column: Activities ── */}
<div className={styles.card}> <div className={styles.card}>
<div <div
style={{ style={{
@ -555,7 +583,9 @@ export function ContactDetailPage() {
{activityIcon(act.type)} {activityIcon(act.type)}
</div> </div>
<div className={styles.timelineContent}> <div className={styles.timelineContent}>
<div className={styles.timelineSubject}>{act.subject}</div> <div className={styles.timelineSubject}>
{act.subject}
</div>
<div className={styles.timelineMeta}> <div className={styles.timelineMeta}>
{ACTIVITY_TYPE_LABELS[act.type]} &middot;{' '} {ACTIVITY_TYPE_LABELS[act.type]} &middot;{' '}
{formatDate(act.createdAt)} {formatDate(act.createdAt)}
@ -573,7 +603,7 @@ export function ContactDetailPage() {
</div> </div>
</div> </div>
{/* Modals */} {/* ── Modals ── */}
<ContactFormModal <ContactFormModal
isOpen={isEditOpen} isOpen={isEditOpen}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
@ -588,7 +618,7 @@ export function ContactDetailPage() {
onSuccess={() => setActivityOpen(false)} onSuccess={() => setActivityOpen(false)}
/> />
{/* Löschen-Modal */} {/* Delete confirmation */}
<Modal <Modal
isOpen={isDeleteOpen} isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)} onClose={() => setDeleteOpen(false)}
@ -656,7 +686,7 @@ export function ContactDetailPage() {
opacity: deleteMutation.isPending ? 0.7 : 1, opacity: deleteMutation.isPending ? 0.7 : 1,
}} }}
> >
{deleteMutation.isPending ? 'Löschen...' : 'Endgültig löschen'} {deleteMutation.isPending ? 'Löschen' : 'Endgültig löschen'}
</button> </button>
</div> </div>
</Modal> </Modal>

View file

@ -1,9 +1,16 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Modal } from '../../components/Modal'; import { Drawer } from '../../components/Drawer';
import { useCreateContact, useUpdateContact, useSetCustomFieldValues } from '../hooks'; import { useCreateContact, useUpdateContact, useSetCustomFieldValues } from '../hooks';
import { companiesApi } from '../api'; import { companiesApi } from '../api';
import { CustomFieldsForm } from '../CustomFieldsForm'; import { CustomFieldsForm } from '../CustomFieldsForm';
import type { Contact, ContactType, ContactSource, EntityStatus, Company, CustomFieldValue } from '../types'; import type {
Contact,
ContactType,
ContactSource,
EntityStatus,
Company,
CustomFieldValue,
} from '../types';
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types'; import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
interface ContactFormModalProps { interface ContactFormModalProps {
@ -13,6 +20,10 @@ interface ContactFormModalProps {
onSuccess: () => void; onSuccess: () => void;
} }
type FormTab = 'general' | 'details';
const FORM_ID = 'contact-form-drawer';
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
fontSize: '0.875rem', fontSize: '0.875rem',
fontWeight: 500, fontWeight: 500,
@ -39,6 +50,18 @@ const rowStyle: React.CSSProperties = {
gap: '0.75rem', gap: '0.75rem',
}; };
const sectionLabelStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--color-text-muted)',
margin: '1.5rem 0 0.75rem',
paddingBottom: '0.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'block',
};
export function ContactFormModal({ export function ContactFormModal({
isOpen, isOpen,
onClose, onClose,
@ -52,7 +75,11 @@ export function ContactFormModal({
const mutation = isEditMode ? updateMutation : createMutation; const mutation = isEditMode ? updateMutation : createMutation;
const [error, setError] = useState(''); const [error, setError] = useState('');
const customFieldValuesRef = useRef<Record<string, string | number | boolean | string[] | null>>({}); const [activeTab, setActiveTab] = useState<FormTab>('general');
const customFieldValuesRef = useRef<
Record<string, string | number | boolean | string[] | null>
>({});
const customFields: CustomFieldValue[] = contact?.customFields ?? []; const customFields: CustomFieldValue[] = contact?.customFields ?? [];
const handleCustomFieldsChange = useCallback( const handleCustomFieldsChange = useCallback(
(values: Record<string, string | number | boolean | string[] | null>) => { (values: Record<string, string | number | boolean | string[] | null>) => {
@ -60,6 +87,9 @@ export function ContactFormModal({
}, },
[], [],
); );
// type is preserved internally for existing ORGANIZATION contacts,
// but new contacts always default to PERSON (no UI selector)
const [type, setType] = useState<ContactType>('PERSON'); const [type, setType] = useState<ContactType>('PERSON');
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
@ -80,8 +110,10 @@ export function ContactFormModal({
const [source, setSource] = useState<ContactSource | ''>(''); const [source, setSource] = useState<ContactSource | ''>('');
const [department, setDepartment] = useState(''); const [department, setDepartment] = useState('');
const [status, setStatus] = useState<EntityStatus>('ACTIVE'); const [status, setStatus] = useState<EntityStatus>('ACTIVE');
// When a company is linked, the address can be inherited from it
const [useCompanyAddress, setUseCompanyAddress] = useState(false);
// Unternehmen-Suche // Company autocomplete
const [companySearch, setCompanySearch] = useState(''); const [companySearch, setCompanySearch] = useState('');
const [companyResults, setCompanyResults] = useState<Company[]>([]); const [companyResults, setCompanyResults] = useState<Company[]>([]);
const [selectedCompany, setSelectedCompany] = useState<{ const [selectedCompany, setSelectedCompany] = useState<{
@ -92,7 +124,7 @@ export function ContactFormModal({
const companyRef = useRef<HTMLDivElement>(null); const companyRef = useRef<HTMLDivElement>(null);
const companySearchTimeout = useRef<ReturnType<typeof setTimeout>>(); const companySearchTimeout = useRef<ReturnType<typeof setTimeout>>();
// Click-Outside für Unternehmen-Dropdown // Click-outside for company dropdown
useEffect(() => { useEffect(() => {
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
if ( if (
@ -106,7 +138,7 @@ export function ContactFormModal({
return () => document.removeEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick);
}, []); }, []);
// Unternehmen suchen (debounced) // Company search (debounced)
useEffect(() => { useEffect(() => {
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current); if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
if (!companySearch || companySearch.length < 2) { if (!companySearch || companySearch.length < 2) {
@ -130,9 +162,11 @@ export function ContactFormModal({
}; };
}, [companySearch]); }, [companySearch]);
// Initialise form when drawer opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setError(''); setError('');
setActiveTab('general');
if (contact) { if (contact) {
setType(contact.type); setType(contact.type);
setFirstName(contact.firstName ?? ''); setFirstName(contact.firstName ?? '');
@ -150,16 +184,24 @@ export function ContactFormModal({
setTagsInput((contact.tags ?? []).join(', ')); setTagsInput((contact.tags ?? []).join(', '));
setPosition(contact.position ?? ''); setPosition(contact.position ?? '');
setLinkedinUrl(contact.linkedinUrl ?? ''); setLinkedinUrl(contact.linkedinUrl ?? '');
setBirthday(contact.birthday ? contact.birthday.split('T')[0] : ''); setBirthday(
contact.birthday ? contact.birthday.split('T')[0] : '',
);
setSource((contact.source as ContactSource) ?? ''); setSource((contact.source as ContactSource) ?? '');
setDepartment(contact.department ?? ''); setDepartment(contact.department ?? '');
setStatus(contact.status ?? 'ACTIVE'); setStatus(contact.status ?? 'ACTIVE');
if (contact.company) { if (contact.company) {
setSelectedCompany({ id: contact.company.id, name: contact.company.name }); setSelectedCompany({
id: contact.company.id,
name: contact.company.name,
});
setCompanySearch(contact.company.name); setCompanySearch(contact.company.name);
// Default: use company address when contact has no own address
setUseCompanyAddress(!contact.street);
} else { } else {
setSelectedCompany(null); setSelectedCompany(null);
setCompanySearch(''); setCompanySearch('');
setUseCompanyAddress(false);
} }
} else { } else {
setType('PERSON'); setType('PERSON');
@ -184,6 +226,7 @@ export function ContactFormModal({
setStatus('ACTIVE'); setStatus('ACTIVE');
setSelectedCompany(null); setSelectedCompany(null);
setCompanySearch(''); setCompanySearch('');
setUseCompanyAddress(true); // default: inherit from company once one is selected
} }
setCompanyResults([]); setCompanyResults([]);
setShowCompanyDropdown(false); setShowCompanyDropdown(false);
@ -199,6 +242,17 @@ export function ContactFormModal({
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
// Only include address when not inheriting from company
const addressPayload =
!selectedCompany || !useCompanyAddress
? {
...(street ? { street } : {}),
...(zip ? { zip } : {}),
...(city ? { city } : {}),
country,
}
: {};
const payload = { const payload = {
type, type,
...(type === 'PERSON' ? { firstName, lastName } : { companyName }), ...(type === 'PERSON' ? { firstName, lastName } : { companyName }),
@ -211,10 +265,7 @@ export function ContactFormModal({
...(source ? { source } : {}), ...(source ? { source } : {}),
...(department ? { department } : {}), ...(department ? { department } : {}),
status, status,
...(street ? { street } : {}), ...addressPayload,
...(zip ? { zip } : {}),
...(city ? { city } : {}),
country,
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
...(tags.length > 0 ? { tags } : {}), ...(tags.length > 0 ? { tags } : {}),
...(selectedCompany ? { companyId: selectedCompany.id } : {}), ...(selectedCompany ? { companyId: selectedCompany.id } : {}),
@ -228,7 +279,10 @@ export function ContactFormModal({
setCustomFieldValues.mutate({ setCustomFieldValues.mutate({
entityId, entityId,
data: { data: {
values: entries.map(([fieldDefId, value]) => ({ fieldDefId, value })), values: entries.map(([fieldDefId, value]) => ({
fieldDefId,
value,
})),
}, },
}); });
}; };
@ -243,8 +297,11 @@ export function ContactFormModal({
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const msg = const msg =
(err as { response?: { data?: { error?: { message?: string } } } }) (
?.response?.data?.error?.message ?? 'Fehler beim Speichern'; err as {
response?: { data?: { error?: { message?: string } } };
}
)?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg); setError(msg);
}, },
}, },
@ -257,22 +314,86 @@ export function ContactFormModal({
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const msg = const msg =
(err as { response?: { data?: { error?: { message?: string } } } }) (
?.response?.data?.error?.message ?? 'Fehler beim Anlegen'; err as {
response?: { data?: { error?: { message?: string } } };
}
)?.response?.data?.error?.message ?? 'Fehler beim Anlegen';
setError(msg); setError(msg);
}, },
}); });
} }
}; };
const tabBtnStyle = (tab: FormTab): React.CSSProperties => ({
padding: '0.5rem 1.25rem',
background: 'none',
border: 'none',
borderBottom: `2px solid ${activeTab === tab ? 'var(--color-primary)' : 'transparent'}`,
cursor: 'pointer',
fontSize: '0.9375rem',
fontWeight: activeTab === tab ? 600 : 400,
color:
activeTab === tab
? 'var(--color-primary)'
: 'var(--color-text-secondary)',
marginBottom: '-1px',
transition: 'color 0.15s, border-color 0.15s',
whiteSpace: 'nowrap',
});
const drawerFooter = (
<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"
form={FORM_ID}
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>
);
return ( return (
<Modal <Drawer
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={isEditMode ? 'Kontakt bearbeiten' : 'Neuer Kontakt'} title={isEditMode ? 'Kontakt bearbeiten' : 'Neuer Kontakt'}
maxWidth="640px" width="540px"
footer={drawerFooter}
> >
<form onSubmit={handleSubmit}> {/* Error banner */}
{error && ( {error && (
<div <div
style={{ style={{
@ -289,33 +410,88 @@ export function ContactFormModal({
</div> </div>
)} )}
{/* Typ */} {/* Tab navigation */}
<div style={{ marginBottom: '1rem' }}> <div
<label style={labelStyle}>Typ</label> style={{
<select display: 'flex',
value={type} borderBottom: '1px solid var(--color-border)',
onChange={(e) => setType(e.target.value as ContactType)} marginBottom: '1.5rem',
style={{ ...inputStyle, cursor: 'pointer' }} }}
> >
<option value="PERSON">Person</option> <button
<option value="ORGANIZATION">Organisation</option> type="button"
</select> style={tabBtnStyle('general')}
onClick={() => setActiveTab('general')}
>
Allgemein
</button>
<button
type="button"
style={tabBtnStyle('details')}
onClick={() => setActiveTab('details')}
>
Details &amp; Adresse
</button>
</div> </div>
{/* Unternehmen-Suche */} <form id={FORM_ID} onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={companyRef}> {/* ── TAB 1: Allgemein ───────────────────────────────────── */}
{activeTab === 'general' && (
<>
{/* 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>
)}
{/* Company autocomplete */}
<div
style={{ marginBottom: '1rem', position: 'relative' }}
ref={companyRef}
>
<label style={labelStyle}>Unternehmen</label> <label style={labelStyle}>Unternehmen</label>
<input <input
style={inputStyle} style={inputStyle}
value={companySearch} value={companySearch}
onChange={(e) => { onChange={(e) => {
setCompanySearch(e.target.value); setCompanySearch(e.target.value);
if (selectedCompany) setSelectedCompany(null); if (selectedCompany) {
setSelectedCompany(null);
setUseCompanyAddress(false);
}
}} }}
onFocus={() => { onFocus={() => {
if (companyResults.length > 0) setShowCompanyDropdown(true); if (companyResults.length > 0) setShowCompanyDropdown(true);
}} }}
placeholder="Unternehmen suchen..." placeholder="Unternehmen suchen"
/> />
{selectedCompany && ( {selectedCompany && (
<button <button
@ -323,6 +499,7 @@ export function ContactFormModal({
onClick={() => { onClick={() => {
setSelectedCompany(null); setSelectedCompany(null);
setCompanySearch(''); setCompanySearch('');
setUseCompanyAddress(false);
}} }}
style={{ style={{
position: 'absolute', position: 'absolute',
@ -361,6 +538,7 @@ export function ContactFormModal({
setSelectedCompany({ id: comp.id, name: comp.name }); setSelectedCompany({ id: comp.id, name: comp.name });
setCompanySearch(comp.name); setCompanySearch(comp.name);
setShowCompanyDropdown(false); setShowCompanyDropdown(false);
setUseCompanyAddress(true); // default: inherit address
}} }}
style={{ style={{
padding: '0.5rem 0.75rem', padding: '0.5rem 0.75rem',
@ -369,12 +547,16 @@ export function ContactFormModal({
borderBottom: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)',
}} }}
onMouseEnter={(e) => onMouseEnter={(e) =>
((e.target as HTMLDivElement).style.background = (
'var(--color-bg)') (e.currentTarget as HTMLDivElement).style.background =
'var(--color-bg)'
)
} }
onMouseLeave={(e) => onMouseLeave={(e) =>
((e.target as HTMLDivElement).style.background = (
'transparent') (e.currentTarget as HTMLDivElement).style.background =
'transparent'
)
} }
> >
{comp.name} {comp.name}
@ -395,7 +577,7 @@ export function ContactFormModal({
)} )}
</div> </div>
{/* Position + Abteilung (nur bei Person) */} {/* Position + Abteilung (PERSON only) */}
{type === 'PERSON' && ( {type === 'PERSON' && (
<div style={{ ...rowStyle, marginBottom: '1rem' }}> <div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div> <div>
@ -404,7 +586,7 @@ export function ContactFormModal({
style={inputStyle} style={inputStyle}
value={position} value={position}
onChange={(e) => setPosition(e.target.value)} onChange={(e) => setPosition(e.target.value)}
placeholder="z.B. Geschaeftsfuehrer" placeholder="z.B. Geschäftsführer"
/> />
</div> </div>
<div> <div>
@ -413,46 +595,12 @@ export function ContactFormModal({
style={inputStyle} style={inputStyle}
value={department} value={department}
onChange={(e) => setDepartment(e.target.value)} onChange={(e) => setDepartment(e.target.value)}
placeholder="z.B. Einkauf, Vertrieb" placeholder="z.B. Einkauf"
/> />
</div> </div>
</div> </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 */} {/* E-Mail */}
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>E-Mail</label> <label style={labelStyle}>E-Mail</label>
@ -465,7 +613,7 @@ export function ContactFormModal({
/> />
</div> </div>
{/* Telefon / Mobil */} {/* Telefon + Mobil */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}> <div style={{ ...rowStyle, marginBottom: '1rem' }}>
<div> <div>
<label style={labelStyle}>Telefon</label> <label style={labelStyle}>Telefon</label>
@ -473,7 +621,7 @@ export function ContactFormModal({
style={inputStyle} style={inputStyle}
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
placeholder="+49 ..." placeholder="+49 …"
/> />
</div> </div>
<div> <div>
@ -482,35 +630,29 @@ export function ContactFormModal({
style={inputStyle} style={inputStyle}
value={mobile} value={mobile}
onChange={(e) => setMobile(e.target.value)} onChange={(e) => setMobile(e.target.value)}
placeholder="+49 ..." placeholder="+49 …"
/> />
</div> </div>
</div> </div>
{/* Website + LinkedIn */} {/* LinkedIn */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<div>
<label style={labelStyle}>Website</label>
<input
style={inputStyle}
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://..."
/>
</div>
<div>
<label style={labelStyle}>LinkedIn</label> <label style={labelStyle}>LinkedIn</label>
<input <input
style={inputStyle} style={inputStyle}
value={linkedinUrl} value={linkedinUrl}
onChange={(e) => setLinkedinUrl(e.target.value)} onChange={(e) => setLinkedinUrl(e.target.value)}
placeholder="https://linkedin.com/in/..." placeholder="https://linkedin.com/in/…"
/> />
</div> </div>
</div> </>
)}
{/* Geburtsdatum + Quelle + Status */} {/* ── TAB 2: Details & Adresse ──────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem', marginBottom: '1rem' }}> {activeTab === 'details' && (
<>
{/* Geburtsdatum + Quelle */}
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
{type === 'PERSON' && ( {type === 'PERSON' && (
<div> <div>
<label style={labelStyle}>Geburtsdatum</label> <label style={labelStyle}>Geburtsdatum</label>
@ -531,11 +673,16 @@ export function ContactFormModal({
> >
<option value="">-- Keine --</option> <option value="">-- Keine --</option>
{Object.entries(CONTACT_SOURCE_LABELS).map(([val, label]) => ( {Object.entries(CONTACT_SOURCE_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option> <option key={val} value={val}>
{label}
</option>
))} ))}
</select> </select>
</div> </div>
<div> </div>
{/* Status */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Status</label> <label style={labelStyle}>Status</label>
<select <select
style={{ ...inputStyle, cursor: 'pointer' }} style={{ ...inputStyle, cursor: 'pointer' }}
@ -543,13 +690,67 @@ export function ContactFormModal({
onChange={(e) => setStatus(e.target.value as EntityStatus)} onChange={(e) => setStatus(e.target.value as EntityStatus)}
> >
{Object.entries(ENTITY_STATUS_LABELS).map(([val, label]) => ( {Object.entries(ENTITY_STATUS_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option> <option key={val} value={val}>
{label}
</option>
))} ))}
</select> </select>
</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> </div>
{/* Adresse */} {/* ── Adresse ── */}
<span style={sectionLabelStyle}>Adresse</span>
{/* "Adresse vom Unternehmen übernehmen" */}
{selectedCompany && (
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
fontSize: '0.875rem',
color: 'var(--color-text)',
}}
>
<input
type="checkbox"
checked={useCompanyAddress}
onChange={(e) => setUseCompanyAddress(e.target.checked)}
style={{ width: 15, height: 15, cursor: 'pointer' }}
/>
Adresse vom Unternehmen übernehmen
</label>
{useCompanyAddress && (
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
marginTop: '0.375rem',
marginLeft: '1.5rem',
marginBottom: 0,
}}
>
Die Adresse wird aus {selectedCompany.name}" übernommen.
</p>
)}
</div>
)}
{/* Address fields (hidden when using company address) */}
{(!selectedCompany || !useCompanyAddress) && (
<>
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Straße</label> <label style={labelStyle}>Straße</label>
<input <input
@ -586,8 +787,12 @@ export function ContactFormModal({
maxLength={2} maxLength={2}
/> />
</div> </div>
</>
)}
{/* ── Notizen & Tags ── */}
<span style={sectionLabelStyle}>Notizen &amp; Tags</span>
{/* Notizen */}
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Notizen</label> <label style={labelStyle}>Notizen</label>
<textarea <textarea
@ -597,6 +802,16 @@ export function ContactFormModal({
/> />
</div> </div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Tags (kommasepariert)</label>
<input
style={inputStyle}
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="VIP, Neukunde, …"
/>
</div>
{/* Custom Fields */} {/* Custom Fields */}
{customFields.length > 0 && ( {customFields.length > 0 && (
<CustomFieldsForm <CustomFieldsForm
@ -604,65 +819,9 @@ export function ContactFormModal({
onChange={handleCustomFieldsChange} onChange={handleCustomFieldsChange}
/> />
)} )}
</>
{/* 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> </form>
</Modal> </Drawer>
); );
} }