mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
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:
parent
fdab2d5bcb
commit
ec9f3ea364
6 changed files with 1073 additions and 651 deletions
32
Summarize.md
32
Summarize.md
|
|
@ -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
|
||||||
|
|
|
||||||
97
packages/frontend/src/components/Drawer.module.css
Normal file
97
packages/frontend/src/components/Drawer.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/frontend/src/components/Drawer.tsx
Normal file
69
packages/frontend/src/components/Drawer.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 & 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]} ·{' '}
|
{ACTIVITY_TYPE_LABELS[act.type]} ·{' '}
|
||||||
{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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue