feat(crm): Phase 2.2-2.4 frontend — Forecast, Import, Enrichment

Phase 2.3 Forecast:
- probability field on PipelineStage (types, edit UI, add-stage form)
- ForecastPage with pipeline filter, period selector, summary cards, table
- forecastApi + useForecast hook
- /crm/forecast route + "Prognose" nav link

Phase 2.2 CSV/Excel Import:
- 3-step wizard ImportPage (Upload → Mapping → Result)
- Entity type selection, auto-mapping, duplicate strategy, preview table
- importApi (preview + execute) + useImportPreview/useImportExecute hooks
- /crm/import route + "Import" nav link

Phase 2.4 Data Enrichment:
- "Anreichern" button on CompanyDetailPage with suggestions modal
- Per-field accept with PATCH update
- enrichmentApi (enrich, getConfig, setConfig) + hooks
- NorthDataConfig in CRM Settings "Integrationen" tab
- API-Key management UI

All changes pass TypeScript strict mode (npx tsc --noEmit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 19:37:54 +01:00
parent c8c4cea5fa
commit fdab2d5bcb
10 changed files with 1569 additions and 3 deletions

View file

@ -54,6 +54,14 @@ import type {
UpdateCustomFieldDefPayload,
CustomFieldValue,
SetCustomFieldValuesPayload,
ForecastResponse,
ForecastPeriod,
ImportPreviewResponse,
ImportEntityType,
ImportExecuteRequest,
ImportExecuteResponse,
EnrichmentResponse,
EnrichmentConfig,
PaginatedResponse,
SingleResponse,
} from './types';
@ -116,6 +124,18 @@ export const dealsApi = {
.then((r) => r.data),
};
// --- Forecast ---
export const forecastApi = {
get: (pipelineId?: string, period?: ForecastPeriod) =>
api
.get<{ success: boolean; data: ForecastResponse; meta: { timestamp: string } }>(
'/crm/deals/forecast',
{ params: { pipelineId, period } },
)
.then((r) => r.data),
};
// --- Pipelines ---
export const pipelinesApi = {
@ -590,3 +610,54 @@ export const customFieldsApi = {
)
.then((r) => r.data),
};
// --- Import ---
export const importApi = {
preview: (file: File, entityType: ImportEntityType) => {
const form = new FormData();
form.append('file', file);
form.append('entityType', entityType);
return api
.post<{ success: boolean; data: ImportPreviewResponse; meta: { timestamp: string } }>(
'/crm/import/preview',
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
.then((r) => r.data);
},
execute: (data: ImportExecuteRequest) =>
api
.post<{ success: boolean; data: ImportExecuteResponse; meta: { timestamp: string } }>(
'/crm/import/execute',
data,
)
.then((r) => r.data),
};
// --- Enrichment ---
export const enrichmentApi = {
enrich: (companyId: string) =>
api
.post<{ success: boolean; data: EnrichmentResponse; meta: { timestamp: string } }>(
`/crm/companies/${companyId}/enrich`,
)
.then((r) => r.data),
getConfig: () =>
api
.get<{ success: boolean; data: EnrichmentConfig; meta: { timestamp: string } }>(
'/crm/settings/integrations/north-data',
)
.then((r) => r.data),
setConfig: (apiKey: string) =>
api
.put<{ success: boolean; data: EnrichmentConfig; meta: { timestamp: string } }>(
'/crm/settings/integrations/north-data',
{ apiKey },
)
.then((r) => r.data),
};

View file

@ -3,12 +3,14 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import {
useCompany,
useDeleteCompany,
useUpdateCompany,
useCompanyVouchers,
useRefreshCompanyVouchers,
useLinkLexwareCompany,
useUnlinkLexwareCompany,
useSyncFromLexware,
usePushToLexware,
useEnrichCompany,
} from '../hooks';
import { useCrmSettings } from '../settings/CrmSettingsContext';
import { CompanyFormModal } from './CompanyFormModal';
@ -18,7 +20,7 @@ import { ContractsCard } from './ContractsCard';
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
import { Modal } from '../../components/Modal';
import type { DealStatus, LexwareVoucher, Deal } from '../types';
import type { DealStatus, LexwareVoucher, Deal, EnrichmentSuggestion } from '../types';
import { VOUCHER_TYPE_LABELS } from '../types';
import styles from './CompanyDetailPage.module.css';
@ -99,6 +101,8 @@ export function CompanyDetailPage() {
const [isEditOpen, setEditOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [isLexwareSearchOpen, setLexwareSearchOpen] = useState(false);
const [isEnrichOpen, setEnrichOpen] = useState(false);
const [enrichSuggestions, setEnrichSuggestions] = useState<EnrichmentSuggestion[]>([]);
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('ALL');
const [typeFilter, setTypeFilter] = useState('');
@ -108,6 +112,8 @@ export function CompanyDetailPage() {
const syncFromLexware = useSyncFromLexware();
const pushToLexware = usePushToLexware();
const refreshVouchers = useRefreshCompanyVouchers();
const enrichCompany = useEnrichCompany();
const updateCompany = useUpdateCompany();
// ---- Lexware Vouchers ----
const companyData = data?.data;
@ -187,6 +193,29 @@ export function CompanyDetailPage() {
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => {
enrichCompany.mutate(company.id, {
onSuccess: (res) => {
setEnrichSuggestions(res.data.suggestions);
setEnrichOpen(true);
},
});
}}
disabled={enrichCompany.isPending}
style={{
padding: '0.375rem 0.75rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid #a5b4fc',
borderRadius: 'var(--radius-sm)',
cursor: enrichCompany.isPending ? 'wait' : 'pointer',
color: '#4f46e5',
opacity: enrichCompany.isPending ? 0.6 : 1,
}}
>
{enrichCompany.isPending ? 'Anreichern...' : 'Anreichern'}
</button>
<button
onClick={() => setEditOpen(true)}
style={{
@ -564,6 +593,100 @@ export function CompanyDetailPage() {
isLinking={linkCompany.isPending}
/>
{/* Enrichment Suggestions Modal */}
<Modal
isOpen={isEnrichOpen}
onClose={() => setEnrichOpen(false)}
title="Datenanreicherung — Vorschläge"
maxWidth="600px"
>
{enrichSuggestions.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
Keine neuen Vorschläge vorhanden.
</p>
) : (
<div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
Folgende Daten wurden gefunden. Klicke auf &quot;Übernehmen&quot;, um einzelne Vorschläge zu übernehmen.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 600 }}>Feld</th>
<th style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 600 }}>Aktuell</th>
<th style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 600 }}>Vorschlag</th>
<th style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 600 }}>Quelle</th>
<th style={{ padding: '0.5rem', width: '80px' }}></th>
</tr>
</thead>
<tbody>
{enrichSuggestions.map((s, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={{ padding: '0.5rem', fontWeight: 500 }}>{s.field}</td>
<td style={{ padding: '0.5rem', color: 'var(--color-text-muted)' }}>
{s.current ?? '—'}
</td>
<td style={{ padding: '0.5rem', color: '#4f46e5', fontWeight: 500 }}>
{s.suggested ?? '—'}
</td>
<td style={{ padding: '0.5rem', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
{s.source}
</td>
<td style={{ padding: '0.5rem' }}>
{s.suggested && (
<button
onClick={() => {
updateCompany.mutate(
{ id: company.id, data: { [s.field]: s.suggested } },
{
onSuccess: () => {
setEnrichSuggestions((prev) =>
prev.filter((_, idx) => idx !== i),
);
},
},
);
}}
disabled={updateCompany.isPending}
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
background: '#4f46e5',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
Übernehmen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button
onClick={() => setEnrichOpen(false)}
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)',
}}
>
Schließen
</button>
</div>
</Modal>
<Modal
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}

View file

@ -0,0 +1,331 @@
import { useState } from 'react';
import { useForecast, usePipelines } from '../hooks';
import type { ForecastPeriod } from '../types';
const periodLabels: Record<ForecastPeriod, string> = {
month: 'Monat',
quarter: 'Quartal',
year: 'Jahr',
};
function formatCurrency(value: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
export function ForecastPage() {
const [pipelineId, setPipelineId] = useState<string>('');
const [period, setPeriod] = useState<ForecastPeriod>('quarter');
const { data: pipelinesData } = usePipelines();
const { data, isLoading, error } = useForecast(
pipelineId || undefined,
period,
);
const pipelines = pipelinesData?.data ?? [];
const forecast = data?.data;
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Prognose</h1>
</div>
{/* Filter */}
<div
style={{
display: 'flex',
gap: '1rem',
marginBottom: '1.5rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div>
<label
style={{
fontSize: '0.8125rem',
fontWeight: 500,
color: 'var(--color-text-muted)',
display: 'block',
marginBottom: '0.25rem',
}}
>
Pipeline
</label>
<select
value={pipelineId}
onChange={(e) => setPipelineId(e.target.value)}
style={{
padding: '0.5rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
minWidth: '12rem',
}}
>
<option value="">Alle Pipelines</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<label
style={{
fontSize: '0.8125rem',
fontWeight: 500,
color: 'var(--color-text-muted)',
display: 'block',
marginBottom: '0.25rem',
}}
>
Zeitraum
</label>
<div style={{ display: 'flex', gap: '0.25rem' }}>
{(Object.keys(periodLabels) as ForecastPeriod[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
style={{
padding: '0.5rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
background:
period === p ? 'var(--color-primary)' : 'var(--color-bg)',
color: period === p ? 'white' : 'var(--color-text)',
fontWeight: period === p ? 600 : 400,
}}
>
{periodLabels[p]}
</button>
))}
</div>
</div>
</div>
{/* Content */}
{isLoading && <p>Laden...</p>}
{error && (
<p style={{ color: 'var(--color-error)' }}>
Fehler beim Laden der Prognose
</p>
)}
{forecast && (
<>
{/* Summary cards */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
marginBottom: '1.5rem',
}}
>
<SummaryCard
label="Offene Vorgänge"
value={String(forecast.totals.dealCount)}
/>
<SummaryCard
label="Gesamtwert"
value={formatCurrency(forecast.totals.totalValue)}
/>
<SummaryCard
label="Gewichteter Wert"
value={formatCurrency(forecast.totals.weightedValue)}
highlight
/>
<SummaryCard
label="Zeitraum"
value={forecast.period}
/>
</div>
{/* Table */}
<div
style={{
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}
>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem',
}}
>
<thead>
<tr
style={{
background: 'var(--color-bg)',
borderBottom: '1px solid var(--color-border)',
}}
>
<th style={thStyle}>Stufe</th>
<th style={{ ...thStyle, textAlign: 'right' }}>
Wahrscheinlichkeit
</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Vorgänge</th>
<th style={{ ...thStyle, textAlign: 'right' }}>
Gesamtwert
</th>
<th style={{ ...thStyle, textAlign: 'right' }}>
Gewichteter Wert
</th>
</tr>
</thead>
<tbody>
{forecast.stages.map((stage) => (
<tr
key={stage.stageId}
style={{
borderBottom: '1px solid var(--color-border)',
}}
>
<td style={tdStyle}>{stage.stageName}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{Math.round(stage.probability * 100)}%
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{stage.dealCount}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{formatCurrency(stage.totalValue)}
</td>
<td
style={{
...tdStyle,
textAlign: 'right',
fontWeight: 600,
}}
>
{formatCurrency(stage.weightedValue)}
</td>
</tr>
))}
{forecast.stages.length === 0 && (
<tr>
<td
colSpan={5}
style={{
...tdStyle,
textAlign: 'center',
color: 'var(--color-text-muted)',
}}
>
Keine offenen Vorgänge in diesem Zeitraum
</td>
</tr>
)}
</tbody>
{forecast.stages.length > 0 && (
<tfoot>
<tr
style={{
background: 'var(--color-bg)',
fontWeight: 600,
}}
>
<td style={tdStyle}>Gesamt</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>&mdash;</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{forecast.totals.dealCount}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{formatCurrency(forecast.totals.totalValue)}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{formatCurrency(forecast.totals.weightedValue)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
</>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: 600,
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
color: 'var(--color-text)',
};
function SummaryCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
style={{
background: highlight ? 'var(--color-primary)' : 'var(--color-bg-card)',
border: highlight ? 'none' : '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '1rem 1.25rem',
}}
>
<div
style={{
fontSize: '0.8125rem',
fontWeight: 500,
color: highlight ? 'rgba(255,255,255,0.8)' : 'var(--color-text-muted)',
marginBottom: '0.25rem',
}}
>
{label}
</div>
<div
style={{
fontSize: '1.25rem',
fontWeight: 700,
color: highlight ? 'white' : 'var(--color-text)',
}}
>
{value}
</div>
</div>
);
}

View file

@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
contactsApi,
dealsApi,
forecastApi,
pipelinesApi,
activitiesApi,
companiesApi,
@ -19,6 +20,8 @@ import {
tradeEventsApi,
ownersApi,
customFieldsApi,
importApi,
enrichmentApi,
} from './api';
import type {
ContactsQueryParams,
@ -53,6 +56,9 @@ import type {
CreateCustomFieldDefPayload,
UpdateCustomFieldDefPayload,
SetCustomFieldValuesPayload,
ForecastPeriod,
ImportEntityType,
ImportExecuteRequest,
} from './types';
// --- Query Key Factory ---
@ -71,6 +77,8 @@ export const crmKeys = {
list: (params: DealsQueryParams) =>
['crm', 'deals', 'list', params] as const,
detail: (id: string) => ['crm', 'deals', 'detail', id] as const,
forecast: (pipelineId?: string, period?: ForecastPeriod) =>
['crm', 'deals', 'forecast', pipelineId, period] as const,
},
pipelines: {
all: ['crm', 'pipelines'] as const,
@ -136,6 +144,9 @@ export const crmKeys = {
vouchersDeal: (dealId: string) =>
['crm', 'lexware', 'vouchers', 'deal', dealId] as const,
},
enrichment: {
config: () => ['crm', 'enrichment', 'config'] as const,
},
};
// ============================================================
@ -1096,3 +1107,68 @@ export function useSetCustomFieldValues() {
},
});
}
// ============================================================
// Forecast
// ============================================================
export function useForecast(pipelineId?: string, period?: ForecastPeriod) {
return useQuery({
queryKey: crmKeys.deals.forecast(pipelineId, period),
queryFn: () => forecastApi.get(pipelineId, period),
});
}
// ============================================================
// Import
// ============================================================
export function useImportPreview() {
return useMutation({
mutationFn: ({ file, entityType }: { file: File; entityType: ImportEntityType }) =>
importApi.preview(file, entityType),
});
}
export function useImportExecute() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ImportExecuteRequest) => importApi.execute(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
// ============================================================
// Enrichment
// ============================================================
export function useEnrichCompany() {
const qc = useQueryClient();
return useMutation({
mutationFn: (companyId: string) => enrichmentApi.enrich(companyId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
},
});
}
export function useEnrichmentConfig() {
return useQuery({
queryKey: crmKeys.enrichment.config(),
queryFn: () => enrichmentApi.getConfig(),
});
}
export function useSetEnrichmentConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (apiKey: string) => enrichmentApi.setConfig(apiKey),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.enrichment.config() });
},
});
}

View file

@ -0,0 +1,584 @@
import { useState, useRef } from 'react';
import { useImportPreview, useImportExecute } from '../hooks';
import type {
ImportEntityType,
ImportDuplicateStrategy,
ImportPreviewResponse,
ImportExecuteResponse,
} from '../types';
const entityTypeLabels: Record<ImportEntityType, string> = {
PERSON: 'Kontakte',
COMPANY: 'Unternehmen',
DEAL: 'Vorgänge',
};
/** CRM-Felder pro Entity-Typ (Datei-Spalte → CRM-Feld) */
const crmFieldsByEntity: Record<ImportEntityType, { value: string; label: string }[]> = {
PERSON: [
{ value: 'firstName', label: 'Vorname' },
{ value: 'lastName', label: 'Nachname' },
{ value: 'email', label: 'E-Mail' },
{ value: 'phone', label: 'Telefon' },
{ value: 'companyName', label: 'Firma' },
{ value: 'position', label: 'Position' },
{ value: 'street', label: 'Straße' },
{ value: 'city', label: 'Stadt' },
{ value: 'zipCode', label: 'PLZ' },
{ value: 'country', label: 'Land' },
{ value: 'notes', label: 'Notizen' },
],
COMPANY: [
{ value: 'name', label: 'Name' },
{ value: 'street', label: 'Straße' },
{ value: 'city', label: 'Stadt' },
{ value: 'zipCode', label: 'PLZ' },
{ value: 'country', label: 'Land' },
{ value: 'websiteUrl', label: 'Website' },
{ value: 'industry', label: 'Branche' },
{ value: 'vatId', label: 'USt-IdNr.' },
{ value: 'notes', label: 'Notizen' },
],
DEAL: [
{ value: 'title', label: 'Titel' },
{ value: 'value', label: 'Wert' },
{ value: 'currency', label: 'Währung' },
{ value: 'expectedCloseDate', label: 'Erw. Abschluss' },
{ value: 'notes', label: 'Notizen' },
],
};
const duplicateLabels: Record<ImportDuplicateStrategy, string> = {
SKIP: 'Überspringen',
UPDATE: 'Aktualisieren',
MARK: 'Markieren',
};
type Step = 'upload' | 'mapping' | 'result';
export function ImportPage() {
const [step, setStep] = useState<Step>('upload');
const [entityType, setEntityType] = useState<ImportEntityType>('PERSON');
const [preview, setPreview] = useState<ImportPreviewResponse | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [duplicateStrategy, setDuplicateStrategy] =
useState<ImportDuplicateStrategy>('SKIP');
const [skipFirstRow, setSkipFirstRow] = useState(true);
const [result, setResult] = useState<ImportExecuteResponse | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const previewMutation = useImportPreview();
const executeMutation = useImportExecute();
const handleUpload = () => {
const file = fileRef.current?.files?.[0];
if (!file) return;
previewMutation.mutate(
{ file, entityType },
{
onSuccess: (res) => {
setPreview(res.data);
// Auto-map columns with matching names
const autoMap: Record<string, string> = {};
const fields = crmFieldsByEntity[entityType];
for (const col of res.data.columns) {
const match = fields.find(
(f) => f.value.toLowerCase() === col.toLowerCase() ||
f.label.toLowerCase() === col.toLowerCase(),
);
if (match) autoMap[col] = match.value;
}
setMapping(autoMap);
setStep('mapping');
},
},
);
};
const handleExecute = () => {
if (!preview) return;
executeMutation.mutate(
{
importId: preview.importId,
entityType,
mapping,
duplicateStrategy,
options: { skipFirstRow },
},
{
onSuccess: (res) => {
setResult(res.data);
setStep('result');
},
},
);
};
const handleReset = () => {
setStep('upload');
setPreview(null);
setMapping({});
setResult(null);
if (fileRef.current) fileRef.current.value = '';
};
return (
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem' }}>
Daten importieren
</h1>
{/* Step indicator */}
<div
style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '2rem',
alignItems: 'center',
}}
>
<StepBadge label="1. Hochladen" active={step === 'upload'} done={step !== 'upload'} />
<StepDivider />
<StepBadge label="2. Zuordnung" active={step === 'mapping'} done={step === 'result'} />
<StepDivider />
<StepBadge label="3. Ergebnis" active={step === 'result'} done={false} />
</div>
{/* Step 1: Upload */}
{step === 'upload' && (
<div
style={{
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '2rem',
}}
>
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Typ</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{(Object.keys(entityTypeLabels) as ImportEntityType[]).map((t) => (
<button
key={t}
onClick={() => setEntityType(t)}
style={{
padding: '0.5rem 1rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
background: entityType === t ? 'var(--color-primary)' : 'var(--color-bg)',
color: entityType === t ? 'white' : 'var(--color-text)',
fontWeight: entityType === t ? 600 : 400,
}}
>
{entityTypeLabels[t]}
</button>
))}
</div>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Datei (CSV, XLSX oder vCard)</label>
<input
ref={fileRef}
type="file"
accept=".csv,.xlsx,.xls,.vcf"
style={{
display: 'block',
padding: '0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
width: '100%',
boxSizing: 'border-box',
}}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
Max. 10 MB, max. 5.000 Zeilen
</p>
</div>
<button
onClick={handleUpload}
disabled={previewMutation.isPending}
style={primaryBtnStyle(previewMutation.isPending)}
>
{previewMutation.isPending ? 'Wird analysiert...' : 'Vorschau laden'}
</button>
{previewMutation.isError && (
<p style={{ color: 'var(--color-error)', marginTop: '0.75rem', fontSize: '0.875rem' }}>
Fehler beim Laden der Vorschau. Bitte Dateiformat prüfen.
</p>
)}
</div>
)}
{/* Step 2: Mapping */}
{step === 'mapping' && preview && (
<div
style={{
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '2rem',
}}
>
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
{preview.totalRows} Zeilen erkannt ({preview.format.toUpperCase()}).
Ordne die Spalten den CRM-Feldern zu.
</p>
{/* Column mapping */}
<div style={{ marginBottom: '1.5rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={thStyle}>Datei-Spalte</th>
<th style={thStyle}>CRM-Feld</th>
<th style={thStyle}>Vorschau</th>
</tr>
</thead>
<tbody>
{preview.columns.map((col) => (
<tr key={col} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={tdStyle}>{col}</td>
<td style={tdStyle}>
<select
value={mapping[col] ?? ''}
onChange={(e) =>
setMapping((prev) => ({
...prev,
[col]: e.target.value,
}))
}
style={{
padding: '0.375rem 0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
width: '100%',
}}
>
<option value=""> ignorieren </option>
{crmFieldsByEntity[entityType].map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</td>
<td
style={{
...tdStyle,
color: 'var(--color-text-muted)',
fontSize: '0.8125rem',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{preview.rows[0]?.[col] ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Options */}
<div
style={{
display: 'flex',
gap: '2rem',
alignItems: 'center',
marginBottom: '1.5rem',
flexWrap: 'wrap',
}}
>
<div>
<label style={labelStyle}>Duplikate</label>
<select
value={duplicateStrategy}
onChange={(e) =>
setDuplicateStrategy(e.target.value as ImportDuplicateStrategy)
}
style={{
padding: '0.5rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
>
{(Object.keys(duplicateLabels) as ImportDuplicateStrategy[]).map(
(s) => (
<option key={s} value={s}>
{duplicateLabels[s]}
</option>
),
)}
</select>
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
fontSize: '0.875rem',
color: 'var(--color-text)',
cursor: 'pointer',
paddingTop: '1.25rem',
}}
>
<input
type="checkbox"
checked={skipFirstRow}
onChange={(e) => setSkipFirstRow(e.target.checked)}
/>
Erste Zeile ist Kopfzeile
</label>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
onClick={handleExecute}
disabled={
executeMutation.isPending ||
Object.values(mapping).filter(Boolean).length === 0
}
style={primaryBtnStyle(
executeMutation.isPending ||
Object.values(mapping).filter(Boolean).length === 0,
)}
>
{executeMutation.isPending
? 'Wird importiert...'
: `${preview.totalRows} Einträge importieren`}
</button>
<button onClick={handleReset} style={secondaryBtnStyle}>
Abbrechen
</button>
</div>
{executeMutation.isError && (
<p style={{ color: 'var(--color-error)', marginTop: '0.75rem', fontSize: '0.875rem' }}>
Fehler beim Import. Bitte versuche es erneut.
</p>
)}
</div>
)}
{/* Step 3: Result */}
{step === 'result' && result && (
<div
style={{
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '2rem',
}}
>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
color:
result.errors > 0 ? 'var(--color-warning, #d97706)' : 'var(--color-success, #16a34a)',
}}
>
{result.errors > 0 ? 'Import mit Warnungen abgeschlossen' : 'Import erfolgreich'}
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '1rem',
marginBottom: '1.5rem',
}}
>
<ResultCard label="Erstellt" value={result.created} color="var(--color-success, #16a34a)" />
<ResultCard label="Aktualisiert" value={result.updated} color="var(--color-primary)" />
<ResultCard label="Übersprungen" value={result.skipped} color="var(--color-text-muted)" />
<ResultCard label="Fehler" value={result.errors} color="var(--color-error)" />
</div>
{result.errorDetails.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3
style={{
fontSize: '0.875rem',
fontWeight: 600,
marginBottom: '0.5rem',
}}
>
Fehlerdetails
</h3>
<div
style={{
maxHeight: '200px',
overflow: 'auto',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
}}
>
{result.errorDetails.map((err, i) => (
<div
key={i}
style={{
padding: '0.5rem 0.75rem',
borderBottom: '1px solid var(--color-border)',
color: 'var(--color-error)',
}}
>
Zeile {err.row}: {err.field} {err.message}
</div>
))}
</div>
</div>
)}
<button onClick={handleReset} style={primaryBtnStyle(false)}>
Neuer Import
</button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const labelStyle: React.CSSProperties = {
fontSize: '0.8125rem',
fontWeight: 500,
color: 'var(--color-text-muted)',
display: 'block',
marginBottom: '0.375rem',
};
const thStyle: React.CSSProperties = {
padding: '0.625rem 0.75rem',
textAlign: 'left',
fontWeight: 600,
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
};
const tdStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
color: 'var(--color-text)',
};
function primaryBtnStyle(disabled: boolean): React.CSSProperties {
return {
padding: '0.625rem 1.25rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
};
}
const secondaryBtnStyle: React.CSSProperties = {
padding: '0.625rem 1rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
};
function StepBadge({
label,
active,
done,
}: {
label: string;
active: boolean;
done: boolean;
}) {
return (
<span
style={{
padding: '0.375rem 0.75rem',
borderRadius: '9999px',
fontSize: '0.8125rem',
fontWeight: active ? 600 : 400,
background: active
? 'var(--color-primary)'
: done
? '#dbeafe'
: 'var(--color-bg)',
color: active ? 'white' : done ? '#1e40af' : 'var(--color-text-muted)',
border: active || done ? 'none' : '1px solid var(--color-border)',
}}
>
{label}
</span>
);
}
function StepDivider() {
return (
<span
style={{
width: '2rem',
height: '1px',
background: 'var(--color-border)',
display: 'inline-block',
}}
/>
);
}
function ResultCard({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div
style={{
textAlign: 'center',
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
}}
>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color }}>{value}</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
marginTop: '0.25rem',
}}
>
{label}
</div>
</div>
);
}

View file

@ -25,14 +25,19 @@ function StageRow({
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(stage.name);
const [editColor, setEditColor] = useState(stage.color);
const [editProbability, setEditProbability] = useState(
Math.round((stage.probability ?? 0) * 100),
);
const updateStageMutation = useUpdateStage();
const removeStageMutation = useRemoveStage();
const handleSave = () => {
const changes: Record<string, string> = {};
const changes: Record<string, string | number> = {};
if (editName.trim() !== stage.name) changes.name = editName.trim();
if (editColor !== stage.color) changes.color = editColor;
const newProb = editProbability / 100;
if (newProb !== (stage.probability ?? 0)) changes.probability = newProb;
if (Object.keys(changes).length === 0) {
setIsEditing(false);
@ -48,6 +53,7 @@ function StageRow({
const handleCancel = () => {
setEditName(stage.name);
setEditColor(stage.color);
setEditProbability(Math.round((stage.probability ?? 0) * 100));
setIsEditing(false);
};
@ -75,6 +81,32 @@ function StageRow({
}}
autoFocus
/>
<input
type="number"
min={0}
max={100}
value={editProbability}
onChange={(e) => setEditProbability(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
if (e.key === 'Escape') handleCancel();
}}
style={{
width: '4rem',
padding: '0.375rem 0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
textAlign: 'right',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
title="Wahrscheinlichkeit (%)"
/>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>%</span>
<div className={styles.stageEditActions}>
<button
className={`${styles.stageEditBtn} ${styles.stageEditSave}`}
@ -110,6 +142,20 @@ function StageRow({
>
{stage.name}
</span>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
background: 'var(--color-bg)',
padding: '0.125rem 0.375rem',
borderRadius: '9999px',
border: '1px solid var(--color-border)',
whiteSpace: 'nowrap',
}}
title="Wahrscheinlichkeit"
>
{Math.round((stage.probability ?? 0) * 100)}%
</span>
<button
onClick={() => setIsEditing(true)}
style={{
@ -165,6 +211,7 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
const [isOpen, setIsOpen] = useState(true);
const [newStageName, setNewStageName] = useState('');
const [newStageColor, setNewStageColor] = useState('#6B7280');
const [newStageProbability, setNewStageProbability] = useState(0);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const addStageMutation = useAddStage();
@ -181,12 +228,14 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
name: newStageName.trim(),
sortOrder: stages.length,
color: newStageColor,
probability: newStageProbability / 100,
},
},
{
onSuccess: () => {
setNewStageName('');
setNewStageColor('#6B7280');
setNewStageProbability(0);
},
},
);
@ -301,6 +350,32 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
onChange={(e) => setNewStageColor(e.target.value)}
title="Farbe"
/>
<input
type="number"
min={0}
max={100}
value={newStageProbability}
onChange={(e) => setNewStageProbability(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddStage();
}
}}
placeholder="0"
style={{
width: '3.5rem',
padding: '0.375rem 0.5rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
textAlign: 'right',
background: 'var(--color-bg)',
color: 'var(--color-text)',
}}
title="Wahrscheinlichkeit (%)"
/>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>%</span>
<button
onClick={handleAddStage}
disabled={

View file

@ -22,6 +22,8 @@ import {
useCreateCustomFieldDef,
useUpdateCustomFieldDef,
useDeleteCustomFieldDef,
useEnrichmentConfig,
useSetEnrichmentConfig,
} from '../hooks';
import type {
Industry,
@ -1176,11 +1178,155 @@ function CustomFieldsConfig() {
);
}
// ============================================================
// North Data Integration Config
// ============================================================
function NorthDataConfig() {
const { data, isLoading } = useEnrichmentConfig();
const setConfig = useSetEnrichmentConfig();
const [apiKey, setApiKey] = useState('');
const [editing, setEditing] = useState(false);
const config = data?.data;
return (
<div className={styles.card}>
<h2 className={styles.cardTitle}>North Data API</h2>
<p className={styles.cardDesc}>
Konfiguriere den API-Key für die Datenanreicherung über North Data. Damit
können Unternehmensdaten (Handelsregister, USt-IdNr., Branche etc.) automatisch
ergänzt werden.
</p>
{isLoading ? (
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>Laden...</p>
) : (
<div style={{ marginTop: '1rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.75rem',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: config?.configured ? '#059669' : 'var(--color-text-muted)',
display: 'inline-block',
}}
/>
<span style={{ fontSize: '0.875rem' }}>
{config?.configured ? 'API-Key konfiguriert' : 'Nicht konfiguriert'}
</span>
</div>
{editing ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label
style={{
fontSize: '0.8125rem',
fontWeight: 500,
color: 'var(--color-text-muted)',
display: 'block',
marginBottom: '0.25rem',
}}
>
API-Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="North Data API Key eingeben"
style={{
width: '100%',
padding: '0.5rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
background: 'var(--color-bg)',
color: 'var(--color-text)',
boxSizing: 'border-box',
}}
/>
</div>
<button
onClick={() => {
if (!apiKey.trim()) return;
setConfig.mutate(apiKey.trim(), {
onSuccess: () => {
setApiKey('');
setEditing(false);
},
});
}}
disabled={!apiKey.trim() || setConfig.isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: !apiKey.trim() || setConfig.isPending ? 'not-allowed' : 'pointer',
opacity: !apiKey.trim() || setConfig.isPending ? 0.5 : 1,
whiteSpace: 'nowrap',
}}
>
{setConfig.isPending ? 'Speichern...' : 'Speichern'}
</button>
<button
onClick={() => {
setEditing(false);
setApiKey('');
}}
style={{
padding: '0.5rem 0.75rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
cursor: 'pointer',
color: 'var(--color-text-secondary)',
}}
>
Abbrechen
</button>
</div>
) : (
<button
onClick={() => setEditing(true)}
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)',
}}
>
{config?.configured ? 'API-Key ändern' : 'API-Key eingeben'}
</button>
)}
</div>
)}
</div>
);
}
// ============================================================
// Page Component
// ============================================================
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'settings';
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'integrations' | 'settings';
export function CrmSettingsPage() {
const { user } = useAuth();
@ -1282,6 +1428,25 @@ export function CrmSettingsPage() {
</svg>
Lexoffice Sync
</button>
<button
className={`${styles.settingsTab} ${activeTab === 'integrations' ? styles.settingsTabActive : ''}`}
onClick={() => setActiveTab('integrations')}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 1v4M8 11v4M1 8h4M11 8h4" />
<circle cx="8" cy="8" r="2" />
</svg>
Integrationen
</button>
<button
className={`${styles.settingsTab} ${activeTab === 'settings' ? styles.settingsTabActive : ''}`}
onClick={() => setActiveTab('settings')}
@ -1440,6 +1605,11 @@ export function CrmSettingsPage() {
</>
)}
{/* Tab: Integrationen */}
{activeTab === 'integrations' && (
<NorthDataConfig />
)}
{/* Tab: Weitere Einstellungen */}
{activeTab === 'settings' && (
<>

View file

@ -347,6 +347,7 @@ export interface PipelineStage {
name: string;
sortOrder: number;
color: string;
probability: number;
createdAt: string;
updatedAt: string;
}
@ -380,12 +381,14 @@ export interface UpdateStagePayload {
name?: string;
sortOrder?: number;
color?: string;
probability?: number;
}
export interface CreateStagePayload {
name: string;
sortOrder?: number;
color?: string;
probability?: number;
}
// --- Deal ---
@ -819,3 +822,86 @@ export interface SetCustomFieldValuesPayload {
value: string | number | boolean | string[] | null;
}>;
}
// --- Forecast ---
export interface ForecastStage {
stageId: string;
stageName: string;
probability: number;
dealCount: number;
totalValue: number;
weightedValue: number;
}
export interface ForecastTotals {
dealCount: number;
totalValue: number;
weightedValue: number;
}
export type ForecastPeriod = 'month' | 'quarter' | 'year';
export interface ForecastResponse {
pipeline: string;
period: string;
stages: ForecastStage[];
totals: ForecastTotals;
}
// --- Import ---
export type ImportEntityType = 'PERSON' | 'COMPANY' | 'DEAL';
export type ImportDuplicateStrategy = 'SKIP' | 'UPDATE' | 'MARK';
export interface ImportPreviewResponse {
importId: string;
columns: string[];
rows: Record<string, string>[];
totalRows: number;
format: string;
}
export interface ImportExecuteRequest {
importId: string;
entityType: ImportEntityType;
mapping: Record<string, string>;
duplicateStrategy: ImportDuplicateStrategy;
options: {
skipFirstRow?: boolean;
};
}
export interface ImportErrorDetail {
row: number;
field: string;
message: string;
}
export interface ImportExecuteResponse {
created: number;
updated: number;
skipped: number;
errors: number;
errorDetails: ImportErrorDetail[];
}
// --- Enrichment ---
export interface EnrichmentSuggestion {
field: string;
current: string | null;
suggested: string | null;
source: string;
}
export interface EnrichmentResponse {
source: string;
suggestions: EnrichmentSuggestion[];
enrichedAt: string;
}
export interface EnrichmentConfig {
apiKey: string;
configured: boolean;
}

View file

@ -23,6 +23,8 @@ import { CompanyDetailPage } from '../crm/companies/CompanyDetailPage';
import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettingsContext';
import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
import { ForecastPage } from '../crm/forecast/ForecastPage';
import { ImportPage } from '../crm/import/ImportPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@ -70,6 +72,8 @@ export function App() {
<Route path="crm/deals" element={<CrmModuleGuard module="deals"><DealsPage /></CrmModuleGuard>} />
<Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></CrmModuleGuard>} />
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
<Route path="crm/import" element={<ImportPage />} />
<Route path="crm/settings" element={<CrmSettingsPage />} />
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} />
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}

View file

@ -367,6 +367,52 @@ export function AppLayout() {
{!collapsed && 'Pipelines'}
</NavLink>
)}
{isModuleEnabled('deals') && (
<NavLink
to="/crm/forecast"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Prognose"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="1 12 5 7 9 10 15 3" />
<polyline points="11 3 15 3 15 7" />
</svg>
{!collapsed && 'Prognose'}
</NavLink>
)}
<NavLink
to="/crm/import"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Import"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 2v8M5 7l3 3 3-3" />
<path d="M2 12h12" />
</svg>
{!collapsed && 'Import'}
</NavLink>
{/* CRM Einstellungen (nur Admins) */}
{isAdmin && (
<NavLink