feat(frontend): redesign CompanyDetailPage with tabbed layout

Replace 3-column grid with 4-tab layout:
- Tab 1 (Unternehmen): 2-column grid with master data + Lexware status (left), contacts + relationships (right)
- Tab 2 (Aktivitäten): ActivityFeed component at full width
- Tab 3 (Vorgänge): Combined CRM deals + Lexware vouchers table with source badges, filters, and color-coded Lexware rows
- Tab 4 (Verträge): ContractsCard placeholder at full width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 12:08:53 +01:00
parent dbbde6c4fa
commit 923e6bc127
2 changed files with 957 additions and 303 deletions

View file

@ -1,5 +1,5 @@
/* ============================================================
CompanyDetailPage 3-Spalten Layout & Komponenten
CompanyDetailPage Tabbed Layout
============================================================ */
.backLink {
@ -49,29 +49,63 @@
}
/* ============================================================
3-Spalten Grid
Tab Bar
============================================================ */
.layout {
.tabBar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab:hover {
color: var(--color-text);
}
.tabActive {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tabContent {
min-height: 200px;
}
/* ============================================================
Tab 1: Unternehmensdaten 2-Spalten Grid
============================================================ */
.overviewGrid {
display: grid;
grid-template-columns: 300px 1fr 360px;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1200px) {
.layout {
grid-template-columns: 1fr 360px;
}
.layout > :first-child {
grid-column: 1 / -1;
@media (max-width: 900px) {
.overviewGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr;
}
.rightStack {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ============================================================
@ -105,12 +139,12 @@
}
/* ============================================================
Info Grid (linke Spalte)
Info Grid (Stammdaten)
============================================================ */
.infoGrid {
display: grid;
grid-template-columns: 100px 1fr;
grid-template-columns: 120px 1fr;
gap: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
@ -158,7 +192,300 @@
}
/* ============================================================
Activity Feed (mittlere Spalte)
Lexware Verknuepfungs-Info (in Stammdaten-Card)
============================================================ */
.lexwareInfo {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.lexwareStatusRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.lexwareBadge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.lexwareBadgeLinked {
background: #d1fae5;
color: #065f46;
}
.lexwareBadgeNotLinked {
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
:global([data-theme='dark']) .lexwareBadgeLinked {
background: #064e3b;
color: #6ee7b7;
}
.lexwareActions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.lexwareActionBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.lexwareActionBtn:hover {
background: var(--color-bg);
color: var(--color-text);
}
.lexwareActionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lexwareActionBtnPrimary {
composes: lexwareActionBtn;
border-color: var(--color-primary);
color: var(--color-primary);
}
.lexwareActionBtnPrimary:hover {
background: var(--color-primary);
color: white;
}
.lexwareActionBtnDanger {
composes: lexwareActionBtn;
border-color: #fecaca;
color: var(--color-error);
}
.lexwareActionBtnDanger:hover {
background: #fef2f2;
color: #dc2626;
}
:global([data-theme='dark']) .lexwareActionBtnDanger:hover {
background: #450a0a;
color: #fca5a5;
}
/* ============================================================
Compact Table (Kontakte in Stammdaten-Tab)
============================================================ */
.compactTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.compactTable th {
text-align: left;
padding: 0.375rem 0;
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
font-weight: 500;
}
.compactTable td {
padding: 0.375rem 0;
border-bottom: 1px solid var(--color-border);
}
.compactTable tr:last-child td {
border-bottom: none;
}
.compactTable tr[data-clickable='true'] {
cursor: pointer;
}
.compactTable tr[data-clickable='true']:hover td {
background: var(--color-bg);
}
/* ============================================================
Tab 3: Vorgaenge Kombinierte Deals + Vouchers Tabelle
============================================================ */
.vorgaengeHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterRow {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.filterSelect {
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
background: var(--color-bg-card);
color: var(--color-text);
outline: none;
cursor: pointer;
}
.filterSelect:focus {
border-color: var(--color-primary);
}
.refreshBtn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.refreshBtn:hover {
background: var(--color-bg);
color: var(--color-text);
}
.refreshBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vorgaengeTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.vorgaengeTable th {
text-align: left;
padding: 0.625rem 0.75rem;
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
font-weight: 500;
background: var(--color-bg);
}
.vorgaengeTable td {
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.vorgaengeTable tbody tr {
cursor: pointer;
transition: background 0.12s;
}
.vorgaengeTable tbody tr:hover {
background: var(--color-bg);
}
.vorgaengeTableCard {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
overflow: hidden;
}
/* Lexware-Zeilen farblich hervorheben */
.lexwareRow {
background: rgba(99, 102, 241, 0.03);
border-left: 3px solid rgba(99, 102, 241, 0.4);
}
.lexwareRow:hover {
background: rgba(99, 102, 241, 0.07) !important;
}
:global([data-theme='dark']) .lexwareRow {
background: rgba(99, 102, 241, 0.06);
border-left-color: rgba(129, 140, 248, 0.4);
}
:global([data-theme='dark']) .lexwareRow:hover {
background: rgba(99, 102, 241, 0.12) !important;
}
/* Quell-Badges */
.sourceBadge {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.sourceBadgeCrm {
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.sourceBadgeLexware {
background: #e0e7ff;
color: #4338ca;
}
:global([data-theme='dark']) .sourceBadgeLexware {
background: #312e81;
color: #a5b4fc;
}
/* Leerer Zustand in Tabelle */
.emptyRow {
text-align: center;
padding: 2rem 1rem;
color: var(--color-text-muted);
font-size: 0.875rem;
font-style: italic;
}
/* ============================================================
Activity Feed von ActivityFeed.tsx genutzt
============================================================ */
.feedContainer {
@ -344,7 +671,7 @@
}
/* ============================================================
Relationships Card (rechte Spalte)
Relationships Card
============================================================ */
.relationItem {
@ -449,40 +776,3 @@
background: var(--color-primary);
color: white;
}
/* ============================================================
Compact Table (rechte Spalte)
============================================================ */
.compactTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.compactTable th {
text-align: left;
padding: 0.375rem 0;
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
font-weight: 500;
}
.compactTable td {
padding: 0.375rem 0;
border-bottom: 1px solid var(--color-border);
}
.compactTable tr:last-child td {
border-bottom: none;
}
.compactTable tr[data-clickable='true'] {
cursor: pointer;
}
.compactTable tr[data-clickable='true']:hover td {
background: var(--color-bg);
}

View file

@ -1,19 +1,59 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCompany, useDeleteCompany } from '../hooks';
import {
useCompany,
useDeleteCompany,
useCompanyVouchers,
useRefreshCompanyVouchers,
useLinkLexwareCompany,
useUnlinkLexwareCompany,
useSyncFromLexware,
usePushToLexware,
} from '../hooks';
import { useCrmSettings } from '../settings/CrmSettingsContext';
import { CompanyFormModal } from './CompanyFormModal';
import { ActivityFeed } from './ActivityFeed';
import { CompanyRelationshipsCard } from './CompanyRelationshipsCard';
import { ContractsCard } from './ContractsCard';
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
import { Modal } from '../../components/Modal';
import { LexwareSection } from '../lexware/LexwareSection';
import type { DealStatus } from '../types';
import type { DealStatus, LexwareVoucher, Deal } from '../types';
import { VOUCHER_TYPE_LABELS } from '../types';
import styles from './CompanyDetailPage.module.css';
// ============================================================
// Constants
// Types
// ============================================================
type DetailTab = 'company' | 'activities' | 'deals' | 'contracts';
type SourceFilter = 'ALL' | 'CRM' | 'LEXWARE';
interface UnifiedItem {
id: string;
source: 'CRM' | 'LEXWARE';
title: string;
type: string;
date: string | null;
status: string;
statusColor?: { bg: string; color: string };
amount: number | null;
currency: string;
link: string;
openExternal?: boolean;
stageColor?: string;
}
// ============================================================
// Constants & Helpers
// ============================================================
const TAB_LABELS: Record<DetailTab, string> = {
company: 'Unternehmen',
activities: 'Aktivitäten',
deals: 'Vorgänge',
contracts: 'Verträge',
};
const STATUS_COLORS: Record<DealStatus, { bg: string; color: string }> = {
OPEN: { bg: '#dbeafe', color: '#1e40af' },
WON: { bg: '#d1fae5', color: '#065f46' },
@ -46,12 +86,38 @@ function formatDate(iso: string): string {
export function CompanyDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isModuleEnabled } = useCrmSettings();
const lexwareEnabled = isModuleEnabled('lexware');
// ---- Core data ----
const { data, isLoading, error } = useCompany(id!);
const deleteMutation = useDeleteCompany();
// ---- UI State ----
const [activeTab, setActiveTab] = useState<DetailTab>('company');
const [isEditOpen, setEditOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [isLexwareSearchOpen, setLexwareSearchOpen] = useState(false);
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('ALL');
const [typeFilter, setTypeFilter] = useState('');
// ---- Lexware Mutations (must be called before conditional returns) ----
const linkCompany = useLinkLexwareCompany();
const unlinkCompany = useUnlinkLexwareCompany();
const syncFromLexware = useSyncFromLexware();
const pushToLexware = usePushToLexware();
const refreshVouchers = useRefreshCompanyVouchers();
// ---- Lexware Vouchers ----
const companyData = data?.data;
const isLinked = !!companyData?.lexwareContactId;
const vouchersQuery = useCompanyVouchers(
id!,
isLinked && lexwareEnabled ? { pageSize: 100 } : undefined,
);
// ---- Loading / Error ----
if (isLoading) return <p>Laden...</p>;
if (error || !data)
return (
@ -63,11 +129,20 @@ export function CompanyDetailPage() {
const company = data.data;
const contacts = company.contacts ?? [];
const deals = company.deals ?? [];
const vouchers: LexwareVoucher[] = vouchersQuery.data?.data ?? [];
// Industry badge color
// Industry badge
const industryColor = company.industryRef?.color ?? '#6366f1';
const industryName = company.industryRef?.name ?? company.industry;
// Lexware mutation state
const isAnyLexwareMutating =
linkCompany.isPending ||
unlinkCompany.isPending ||
syncFromLexware.isPending ||
pushToLexware.isPending ||
refreshVouchers.isPending;
return (
<div>
{/* Zurück */}
@ -142,259 +217,276 @@ export function CompanyDetailPage() {
</div>
</div>
{/* 3-Spalten Layout */}
<div className={styles.layout}>
{/* ======== Linke Spalte: Stammdaten ======== */}
<div>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Unternehmensdaten</h2>
<div className={styles.infoGrid}>
{company.accountType && (
<>
<span className={styles.infoLabel}>Kontotyp</span>
<span className={styles.infoValue}>{company.accountType.name}</span>
</>
)}
{company.ownerName && (
<>
<span className={styles.infoLabel}>Zuständig</span>
<span className={styles.infoValue}>{company.ownerName}</span>
</>
)}
{company.email && (
<>
<span className={styles.infoLabel}>E-Mail</span>
<span className={styles.infoValue}>
<a
href={`mailto:${company.email}`}
style={{ color: 'var(--color-primary)' }}
>
{company.email}
</a>
</span>
</>
)}
{company.phone && (
<>
<span className={styles.infoLabel}>Telefon</span>
<span className={styles.infoValue}>{company.phone}</span>
</>
)}
{company.website && (
<>
<span className={styles.infoLabel}>Website</span>
<span className={styles.infoValue}>
<a
href={company.website}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}
>
{company.website}
</a>
</span>
</>
)}
{(company.street || company.zip || company.city) && (
<>
<span className={styles.infoLabel}>Adresse</span>
<span className={styles.infoValue}>
{company.street && <>{company.street}<br /></>}
{company.zip} {company.city}
{company.country && company.country !== 'DE' && (
<>, {company.country}</>
)}
</span>
</>
)}
<span className={styles.infoLabel}>Erstellt</span>
<span className={styles.infoValue}>
{formatDate(company.createdAt)}
</span>
</div>
{/* ======== Tab Bar ======== */}
<div className={styles.tabBar}>
{(Object.keys(TAB_LABELS) as DetailTab[]).map((tab) => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{TAB_LABELS[tab]}
</button>
))}
</div>
{/* Tags */}
{company.tags && company.tags.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Tags
</span>
<div className={styles.tags}>
{company.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</div>
)}
{/* Notizen */}
{company.notes && (
<div className={styles.notesSection}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Notizen
</span>
<p className={styles.notesText}>{company.notes}</p>
</div>
)}
</div>
</div>
{/* ======== Mittlere Spalte: Activity Feed ======== */}
<div>
<ActivityFeed companyId={company.id} />
</div>
{/* ======== Rechte Spalte: Relations ======== */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Kontakte */}
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Kontakte ({company._count?.contacts ?? contacts.length})
</h2>
</div>
{contacts.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Kontakte vorhanden
</p>
) : (
<table className={styles.compactTable}>
<thead>
<tr>
<th>Name</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{contacts.map((c) => {
const name = [c.firstName, c.lastName]
.filter(Boolean)
.join(' ') || '—';
return (
<tr
key={c.id}
data-clickable="true"
onClick={() => navigate(`/crm/contacts/${c.id}`)}
>
<td style={{ fontWeight: 500 }}>{name}</td>
<td style={{ color: 'var(--color-text-secondary)' }}>
{c.position ?? '—'}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Vorgänge */}
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Vorgänge ({company._count?.deals ?? deals.length})
</h2>
</div>
{deals.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Vorgänge vorhanden
</p>
) : (
<table className={styles.compactTable}>
<thead>
<tr>
<th>Titel</th>
<th>Stufe</th>
<th style={{ textAlign: 'right' }}>Wert</th>
</tr>
</thead>
<tbody>
{deals.map((deal) => (
<tr
key={deal.id}
data-clickable="true"
onClick={() => navigate(`/crm/deals/${deal.id}`)}
>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<span style={{ fontWeight: 500 }}>{deal.title}</span>
<span
style={{
display: 'inline-block',
padding: '0 0.25rem',
borderRadius: '9999px',
fontSize: '0.625rem',
fontWeight: 500,
background: STATUS_COLORS[deal.status].bg,
color: STATUS_COLORS[deal.status].color,
}}
>
{STATUS_LABELS[deal.status]}
</span>
</div>
</td>
<td style={{ minWidth: 80 }}>
{deal.stage && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: deal.stage.color,
display: 'inline-block',
}}
/>
{deal.stage.name}
</span>
{/* ======== Tab Content ======== */}
<div className={styles.tabContent}>
{/* ---- Tab 1: Unternehmensdaten ---- */}
{activeTab === 'company' && (
<div className={styles.overviewGrid}>
{/* Left: Stammdaten */}
<div>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Unternehmensdaten</h2>
<div className={styles.infoGrid}>
{company.accountType && (
<>
<span className={styles.infoLabel}>Kontotyp</span>
<span className={styles.infoValue}>{company.accountType.name}</span>
</>
)}
{company.ownerName && (
<>
<span className={styles.infoLabel}>Zuständig</span>
<span className={styles.infoValue}>{company.ownerName}</span>
</>
)}
{company.email && (
<>
<span className={styles.infoLabel}>E-Mail</span>
<span className={styles.infoValue}>
<a
href={`mailto:${company.email}`}
style={{ color: 'var(--color-primary)' }}
>
{company.email}
</a>
</span>
</>
)}
{company.phone && (
<>
<span className={styles.infoLabel}>Telefon</span>
<span className={styles.infoValue}>{company.phone}</span>
</>
)}
{company.website && (
<>
<span className={styles.infoLabel}>Website</span>
<span className={styles.infoValue}>
<a
href={company.website}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}
>
{company.website}
</a>
</span>
</>
)}
{(company.street || company.zip || company.city) && (
<>
<span className={styles.infoLabel}>Adresse</span>
<span className={styles.infoValue}>
{company.street && <>{company.street}<br /></>}
{company.zip} {company.city}
{company.country && company.country !== 'DE' && (
<>, {company.country}</>
)}
</td>
<td style={{ textAlign: 'right', fontWeight: 500, whiteSpace: 'nowrap' }}>
{deal.value
? currencyFormatter.format(parseFloat(deal.value))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</span>
</>
)}
<span className={styles.infoLabel}>Erstellt</span>
<span className={styles.infoValue}>
{formatDate(company.createdAt)}
</span>
</div>
{/* Tags */}
{company.tags && company.tags.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Tags
</span>
<div className={styles.tags}>
{company.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</div>
)}
{/* Notizen */}
{company.notes && (
<div className={styles.notesSection}>
<span
className={styles.infoLabel}
style={{ display: 'block', marginBottom: '0.375rem' }}
>
Notizen
</span>
<p className={styles.notesText}>{company.notes}</p>
</div>
)}
{/* Lexware Verknüpfung */}
{lexwareEnabled && (
<div className={styles.lexwareInfo}>
<div className={styles.lexwareStatusRow}>
<span
className={`${styles.lexwareBadge} ${
isLinked ? styles.lexwareBadgeLinked : styles.lexwareBadgeNotLinked
}`}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: isLinked ? '#059669' : 'var(--color-text-muted)',
display: 'inline-block',
}}
/>
LX {isLinked ? 'Verknüpft' : 'Nicht verknüpft'}
</span>
{isLinked && company.lexwareSyncedAt && (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
Sync: {formatDate(company.lexwareSyncedAt)}
</span>
)}
</div>
<div className={styles.lexwareActions}>
{!isLinked ? (
<button
className={styles.lexwareActionBtnPrimary}
onClick={() => setLexwareSearchOpen(true)}
disabled={isAnyLexwareMutating}
>
Lexware Kontakt suchen
</button>
) : (
<>
<button
className={styles.lexwareActionBtn}
onClick={() => syncFromLexware.mutate({ entityType: 'company', entityId: company.id })}
disabled={isAnyLexwareMutating}
>
{syncFromLexware.isPending ? 'Sync...' : 'Sync'}
</button>
<button
className={styles.lexwareActionBtn}
onClick={() => pushToLexware.mutate({ entityType: 'company', entityId: company.id })}
disabled={isAnyLexwareMutating}
>
{pushToLexware.isPending ? 'Push...' : 'Push'}
</button>
<button
className={styles.lexwareActionBtnDanger}
onClick={() => unlinkCompany.mutate(company.id)}
disabled={isAnyLexwareMutating}
>
{unlinkCompany.isPending ? 'Trennen...' : 'Trennen'}
</button>
</>
)}
</div>
</div>
)}
</div>
</div>
{/* Right: Kontakte + Beziehungen */}
<div className={styles.rightStack}>
{/* Kontakte */}
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Kontakte ({company._count?.contacts ?? contacts.length})
</h2>
</div>
{contacts.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Kontakte vorhanden
</p>
) : (
<table className={styles.compactTable}>
<thead>
<tr>
<th>Name</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{contacts.map((c) => {
const cName = [c.firstName, c.lastName]
.filter(Boolean)
.join(' ') || '—';
return (
<tr
key={c.id}
data-clickable="true"
onClick={() => navigate(`/crm/contacts/${c.id}`)}
>
<td style={{ fontWeight: 500 }}>{cName}</td>
<td style={{ color: 'var(--color-text-secondary)' }}>
{c.position ?? '—'}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Beziehungen */}
<CompanyRelationshipsCard companyId={company.id} />
</div>
</div>
)}
{/* Beziehungen */}
<CompanyRelationshipsCard companyId={company.id} />
{/* ---- Tab 2: Aktivitäten ---- */}
{activeTab === 'activities' && (
<div style={{ maxWidth: 800 }}>
<ActivityFeed companyId={company.id} />
</div>
)}
{/* Verträge (Platzhalter) */}
{/* ---- Tab 3: Vorgänge ---- */}
{activeTab === 'deals' && (
<VorgaengeTab
deals={deals}
vouchers={vouchers}
isLoadingVouchers={vouchersQuery.isLoading}
isLinked={isLinked}
lexwareEnabled={lexwareEnabled}
companyId={company.id}
refreshVouchers={refreshVouchers}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
navigate={navigate}
/>
)}
{/* ---- Tab 4: Verträge ---- */}
{activeTab === 'contracts' && (
<ContractsCard
companyId={company.id}
contractCount={company._count?.contracts ?? 0}
/>
{/* Lexware Office */}
<LexwareSection
entityType="company"
entityId={company.id}
lexwareContactId={company.lexwareContactId ?? null}
lexwareSyncedAt={company.lexwareSyncedAt ?? null}
/>
</div>
)}
</div>
{/* Modals */}
{/* ======== Modals ======== */}
<CompanyFormModal
isOpen={isEditOpen}
onClose={() => setEditOpen(false)}
@ -402,6 +494,18 @@ export function CompanyDetailPage() {
onSuccess={() => setEditOpen(false)}
/>
<LexwareSearchModal
isOpen={isLexwareSearchOpen}
onClose={() => setLexwareSearchOpen(false)}
onLink={(lexwareId) => {
linkCompany.mutate(
{ lexwareContactId: lexwareId, companyId: company.id },
{ onSuccess: () => setLexwareSearchOpen(false) },
);
}}
isLinking={linkCompany.isPending}
/>
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@ -475,3 +579,263 @@ export function CompanyDetailPage() {
</div>
);
}
// ============================================================
// VorgaengeTab — CRM Deals + Lexware Belege kombiniert
// ============================================================
function VorgaengeTab({
deals,
vouchers,
isLoadingVouchers,
isLinked,
lexwareEnabled,
companyId,
refreshVouchers,
sourceFilter,
setSourceFilter,
typeFilter,
setTypeFilter,
navigate,
}: {
deals: Deal[];
vouchers: LexwareVoucher[];
isLoadingVouchers: boolean;
isLinked: boolean;
lexwareEnabled: boolean;
companyId: string;
refreshVouchers: ReturnType<typeof useRefreshCompanyVouchers>;
sourceFilter: SourceFilter;
setSourceFilter: (v: SourceFilter) => void;
typeFilter: string;
setTypeFilter: (v: string) => void;
navigate: ReturnType<typeof useNavigate>;
}) {
// Map CRM deals to unified format
const crmItems: UnifiedItem[] = useMemo(
() =>
deals.map((deal) => ({
id: `crm-${deal.id}`,
source: 'CRM' as const,
title: deal.title,
type: deal.stage?.name ?? deal.pipeline?.name ?? '—',
date: deal.createdAt,
status: STATUS_LABELS[deal.status] ?? deal.status,
statusColor: STATUS_COLORS[deal.status],
amount: deal.value ? parseFloat(deal.value) : null,
currency: deal.currency ?? 'EUR',
link: `/crm/deals/${deal.id}`,
stageColor: deal.stage?.color,
})),
[deals],
);
// Map Lexware vouchers to unified format
const lexwareItems: UnifiedItem[] = useMemo(
() =>
vouchers.map((v) => ({
id: `lx-${v.id}`,
source: 'LEXWARE' as const,
title: v.title || v.voucherNumber || '—',
type: VOUCHER_TYPE_LABELS[v.voucherType] ?? v.voucherType,
date: v.voucherDate,
status: v.voucherStatus ?? '—',
amount: v.totalGrossAmount ? parseFloat(v.totalGrossAmount) : null,
currency: v.currency ?? 'EUR',
link: v.lexwareDeepLink ?? '#',
openExternal: true,
})),
[vouchers],
);
// Merge + sort by date descending
const allItems = useMemo(() => {
let items = [...crmItems, ...lexwareItems];
// Source filter
if (sourceFilter === 'CRM') items = items.filter((i) => i.source === 'CRM');
if (sourceFilter === 'LEXWARE') items = items.filter((i) => i.source === 'LEXWARE');
// Type filter
if (typeFilter) items = items.filter((i) => i.type === typeFilter);
// Sort by date (newest first)
items.sort((a, b) => {
if (!a.date && !b.date) return 0;
if (!a.date) return 1;
if (!b.date) return -1;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
return items;
}, [crmItems, lexwareItems, sourceFilter, typeFilter]);
// Collect all unique types for filter dropdown
const allTypes = useMemo(() => {
const types = new Set<string>();
[...crmItems, ...lexwareItems].forEach((i) => {
if (i.type && i.type !== '—') types.add(i.type);
});
return Array.from(types).sort();
}, [crmItems, lexwareItems]);
return (
<div>
{/* Filter + Actions */}
<div className={styles.vorgaengeHeader}>
<div className={styles.filterRow}>
<select
className={styles.filterSelect}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value as SourceFilter)}
>
<option value="ALL">Alle Quellen</option>
<option value="CRM">Nur CRM</option>
<option value="LEXWARE">Nur Lexware</option>
</select>
<select
className={styles.filterSelect}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">Alle Typen</option>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{isLinked && lexwareEnabled && (
<button
className={styles.refreshBtn}
onClick={() => refreshVouchers.mutate(companyId)}
disabled={refreshVouchers.isPending}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 1v5h5M15 15v-5h-5" />
<path d="M13.5 6A6 6 0 003.3 3.3L1 6M2.5 10a6 6 0 0010.2 2.7L15 10" />
</svg>
{refreshVouchers.isPending ? 'Aktualisieren...' : 'Belege aktualisieren'}
</button>
)}
</div>
{/* Loading */}
{isLoadingVouchers && isLinked && (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem', marginBottom: '1rem' }}>
Lexware Belege werden geladen...
</p>
)}
{/* Tabelle */}
<div className={styles.vorgaengeTableCard}>
<table className={styles.vorgaengeTable}>
<thead>
<tr>
<th style={{ width: 60 }}>Quelle</th>
<th>Bezeichnung</th>
<th>Typ</th>
<th>Datum</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{allItems.length === 0 && (
<tr>
<td colSpan={6} className={styles.emptyRow}>
Keine Vorgänge oder Belege vorhanden
</td>
</tr>
)}
{allItems.map((item) => (
<tr
key={item.id}
className={item.source === 'LEXWARE' ? styles.lexwareRow : undefined}
onClick={() => {
if (item.openExternal) {
window.open(item.link, '_blank', 'noopener,noreferrer');
} else {
navigate(item.link);
}
}}
>
{/* Quelle */}
<td>
<span
className={`${styles.sourceBadge} ${
item.source === 'LEXWARE'
? styles.sourceBadgeLexware
: styles.sourceBadgeCrm
}`}
>
{item.source === 'LEXWARE' ? 'LX' : 'CRM'}
</span>
</td>
{/* Bezeichnung */}
<td style={{ fontWeight: 500, color: 'var(--color-text)' }}>
{item.title}
</td>
{/* Typ */}
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
{item.stageColor && (
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: item.stageColor,
display: 'inline-block',
}}
/>
)}
{item.type}
</span>
</td>
{/* Datum */}
<td>{item.date ? formatDate(item.date) : '—'}</td>
{/* Status */}
<td>
{item.statusColor ? (
<span
style={{
display: 'inline-block',
padding: '0 0.375rem',
borderRadius: '9999px',
fontSize: '0.6875rem',
fontWeight: 500,
background: item.statusColor.bg,
color: item.statusColor.color,
}}
>
{item.status}
</span>
) : (
<span style={{ fontSize: '0.8125rem' }}>{item.status}</span>
)}
</td>
{/* Betrag */}
<td style={{ textAlign: 'right', fontWeight: 500, whiteSpace: 'nowrap' }}>
{item.amount != null
? currencyFormatter.format(item.amount)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}