mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26:40 +02:00
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:
parent
c8c4cea5fa
commit
fdab2d5bcb
10 changed files with 1569 additions and 3 deletions
|
|
@ -54,6 +54,14 @@ import type {
|
||||||
UpdateCustomFieldDefPayload,
|
UpdateCustomFieldDefPayload,
|
||||||
CustomFieldValue,
|
CustomFieldValue,
|
||||||
SetCustomFieldValuesPayload,
|
SetCustomFieldValuesPayload,
|
||||||
|
ForecastResponse,
|
||||||
|
ForecastPeriod,
|
||||||
|
ImportPreviewResponse,
|
||||||
|
ImportEntityType,
|
||||||
|
ImportExecuteRequest,
|
||||||
|
ImportExecuteResponse,
|
||||||
|
EnrichmentResponse,
|
||||||
|
EnrichmentConfig,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -116,6 +124,18 @@ export const dealsApi = {
|
||||||
.then((r) => r.data),
|
.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 ---
|
// --- Pipelines ---
|
||||||
|
|
||||||
export const pipelinesApi = {
|
export const pipelinesApi = {
|
||||||
|
|
@ -590,3 +610,54 @@ export const customFieldsApi = {
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.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),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCompany,
|
useCompany,
|
||||||
useDeleteCompany,
|
useDeleteCompany,
|
||||||
|
useUpdateCompany,
|
||||||
useCompanyVouchers,
|
useCompanyVouchers,
|
||||||
useRefreshCompanyVouchers,
|
useRefreshCompanyVouchers,
|
||||||
useLinkLexwareCompany,
|
useLinkLexwareCompany,
|
||||||
useUnlinkLexwareCompany,
|
useUnlinkLexwareCompany,
|
||||||
useSyncFromLexware,
|
useSyncFromLexware,
|
||||||
usePushToLexware,
|
usePushToLexware,
|
||||||
|
useEnrichCompany,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import { useCrmSettings } from '../settings/CrmSettingsContext';
|
import { useCrmSettings } from '../settings/CrmSettingsContext';
|
||||||
import { CompanyFormModal } from './CompanyFormModal';
|
import { CompanyFormModal } from './CompanyFormModal';
|
||||||
|
|
@ -18,7 +20,7 @@ import { ContractsCard } from './ContractsCard';
|
||||||
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
|
import { LexwareSearchModal } from '../lexware/LexwareSearchModal';
|
||||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
import { Modal } from '../../components/Modal';
|
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 { VOUCHER_TYPE_LABELS } from '../types';
|
||||||
import styles from './CompanyDetailPage.module.css';
|
import styles from './CompanyDetailPage.module.css';
|
||||||
|
|
||||||
|
|
@ -99,6 +101,8 @@ export function CompanyDetailPage() {
|
||||||
const [isEditOpen, setEditOpen] = useState(false);
|
const [isEditOpen, setEditOpen] = useState(false);
|
||||||
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [isLexwareSearchOpen, setLexwareSearchOpen] = useState(false);
|
const [isLexwareSearchOpen, setLexwareSearchOpen] = useState(false);
|
||||||
|
const [isEnrichOpen, setEnrichOpen] = useState(false);
|
||||||
|
const [enrichSuggestions, setEnrichSuggestions] = useState<EnrichmentSuggestion[]>([]);
|
||||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('ALL');
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('ALL');
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
|
||||||
|
|
@ -108,6 +112,8 @@ export function CompanyDetailPage() {
|
||||||
const syncFromLexware = useSyncFromLexware();
|
const syncFromLexware = useSyncFromLexware();
|
||||||
const pushToLexware = usePushToLexware();
|
const pushToLexware = usePushToLexware();
|
||||||
const refreshVouchers = useRefreshCompanyVouchers();
|
const refreshVouchers = useRefreshCompanyVouchers();
|
||||||
|
const enrichCompany = useEnrichCompany();
|
||||||
|
const updateCompany = useUpdateCompany();
|
||||||
|
|
||||||
// ---- Lexware Vouchers ----
|
// ---- Lexware Vouchers ----
|
||||||
const companyData = data?.data;
|
const companyData = data?.data;
|
||||||
|
|
@ -187,6 +193,29 @@ export function CompanyDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<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
|
<button
|
||||||
onClick={() => setEditOpen(true)}
|
onClick={() => setEditOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -564,6 +593,100 @@ export function CompanyDetailPage() {
|
||||||
isLinking={linkCompany.isPending}
|
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 "Übernehmen", 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
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
isOpen={isDeleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
|
|
||||||
331
packages/frontend/src/crm/forecast/ForecastPage.tsx
Normal file
331
packages/frontend/src/crm/forecast/ForecastPage.tsx
Normal 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' }}>—</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
contactsApi,
|
contactsApi,
|
||||||
dealsApi,
|
dealsApi,
|
||||||
|
forecastApi,
|
||||||
pipelinesApi,
|
pipelinesApi,
|
||||||
activitiesApi,
|
activitiesApi,
|
||||||
companiesApi,
|
companiesApi,
|
||||||
|
|
@ -19,6 +20,8 @@ import {
|
||||||
tradeEventsApi,
|
tradeEventsApi,
|
||||||
ownersApi,
|
ownersApi,
|
||||||
customFieldsApi,
|
customFieldsApi,
|
||||||
|
importApi,
|
||||||
|
enrichmentApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
|
|
@ -53,6 +56,9 @@ import type {
|
||||||
CreateCustomFieldDefPayload,
|
CreateCustomFieldDefPayload,
|
||||||
UpdateCustomFieldDefPayload,
|
UpdateCustomFieldDefPayload,
|
||||||
SetCustomFieldValuesPayload,
|
SetCustomFieldValuesPayload,
|
||||||
|
ForecastPeriod,
|
||||||
|
ImportEntityType,
|
||||||
|
ImportExecuteRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -71,6 +77,8 @@ export const crmKeys = {
|
||||||
list: (params: DealsQueryParams) =>
|
list: (params: DealsQueryParams) =>
|
||||||
['crm', 'deals', 'list', params] as const,
|
['crm', 'deals', 'list', params] as const,
|
||||||
detail: (id: string) => ['crm', 'deals', 'detail', id] as const,
|
detail: (id: string) => ['crm', 'deals', 'detail', id] as const,
|
||||||
|
forecast: (pipelineId?: string, period?: ForecastPeriod) =>
|
||||||
|
['crm', 'deals', 'forecast', pipelineId, period] as const,
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
all: ['crm', 'pipelines'] as const,
|
all: ['crm', 'pipelines'] as const,
|
||||||
|
|
@ -136,6 +144,9 @@ export const crmKeys = {
|
||||||
vouchersDeal: (dealId: string) =>
|
vouchersDeal: (dealId: string) =>
|
||||||
['crm', 'lexware', 'vouchers', 'deal', dealId] as const,
|
['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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
584
packages/frontend/src/crm/import/ImportPage.tsx
Normal file
584
packages/frontend/src/crm/import/ImportPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,14 +25,19 @@ function StageRow({
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(stage.name);
|
const [editName, setEditName] = useState(stage.name);
|
||||||
const [editColor, setEditColor] = useState(stage.color);
|
const [editColor, setEditColor] = useState(stage.color);
|
||||||
|
const [editProbability, setEditProbability] = useState(
|
||||||
|
Math.round((stage.probability ?? 0) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
const updateStageMutation = useUpdateStage();
|
const updateStageMutation = useUpdateStage();
|
||||||
const removeStageMutation = useRemoveStage();
|
const removeStageMutation = useRemoveStage();
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const changes: Record<string, string> = {};
|
const changes: Record<string, string | number> = {};
|
||||||
if (editName.trim() !== stage.name) changes.name = editName.trim();
|
if (editName.trim() !== stage.name) changes.name = editName.trim();
|
||||||
if (editColor !== stage.color) changes.color = editColor;
|
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) {
|
if (Object.keys(changes).length === 0) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
@ -48,6 +53,7 @@ function StageRow({
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setEditName(stage.name);
|
setEditName(stage.name);
|
||||||
setEditColor(stage.color);
|
setEditColor(stage.color);
|
||||||
|
setEditProbability(Math.round((stage.probability ?? 0) * 100));
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,6 +81,32 @@ function StageRow({
|
||||||
}}
|
}}
|
||||||
autoFocus
|
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}>
|
<div className={styles.stageEditActions}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.stageEditBtn} ${styles.stageEditSave}`}
|
className={`${styles.stageEditBtn} ${styles.stageEditSave}`}
|
||||||
|
|
@ -110,6 +142,20 @@ function StageRow({
|
||||||
>
|
>
|
||||||
{stage.name}
|
{stage.name}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -165,6 +211,7 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const [newStageName, setNewStageName] = useState('');
|
const [newStageName, setNewStageName] = useState('');
|
||||||
const [newStageColor, setNewStageColor] = useState('#6B7280');
|
const [newStageColor, setNewStageColor] = useState('#6B7280');
|
||||||
|
const [newStageProbability, setNewStageProbability] = useState(0);
|
||||||
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const addStageMutation = useAddStage();
|
const addStageMutation = useAddStage();
|
||||||
|
|
@ -181,12 +228,14 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
|
||||||
name: newStageName.trim(),
|
name: newStageName.trim(),
|
||||||
sortOrder: stages.length,
|
sortOrder: stages.length,
|
||||||
color: newStageColor,
|
color: newStageColor,
|
||||||
|
probability: newStageProbability / 100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewStageName('');
|
setNewStageName('');
|
||||||
setNewStageColor('#6B7280');
|
setNewStageColor('#6B7280');
|
||||||
|
setNewStageProbability(0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -301,6 +350,32 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
|
||||||
onChange={(e) => setNewStageColor(e.target.value)}
|
onChange={(e) => setNewStageColor(e.target.value)}
|
||||||
title="Farbe"
|
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
|
<button
|
||||||
onClick={handleAddStage}
|
onClick={handleAddStage}
|
||||||
disabled={
|
disabled={
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
useCreateCustomFieldDef,
|
useCreateCustomFieldDef,
|
||||||
useUpdateCustomFieldDef,
|
useUpdateCustomFieldDef,
|
||||||
useDeleteCustomFieldDef,
|
useDeleteCustomFieldDef,
|
||||||
|
useEnrichmentConfig,
|
||||||
|
useSetEnrichmentConfig,
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import type {
|
import type {
|
||||||
Industry,
|
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
|
// Page Component
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'settings';
|
type SettingsTab = 'module' | 'customfields' | 'lexware' | 'integrations' | 'settings';
|
||||||
|
|
||||||
export function CrmSettingsPage() {
|
export function CrmSettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
@ -1282,6 +1428,25 @@ export function CrmSettingsPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Lexoffice Sync
|
Lexoffice Sync
|
||||||
</button>
|
</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
|
<button
|
||||||
className={`${styles.settingsTab} ${activeTab === 'settings' ? styles.settingsTabActive : ''}`}
|
className={`${styles.settingsTab} ${activeTab === 'settings' ? styles.settingsTabActive : ''}`}
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => setActiveTab('settings')}
|
||||||
|
|
@ -1440,6 +1605,11 @@ export function CrmSettingsPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Integrationen */}
|
||||||
|
{activeTab === 'integrations' && (
|
||||||
|
<NorthDataConfig />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab: Weitere Einstellungen */}
|
{/* Tab: Weitere Einstellungen */}
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ export interface PipelineStage {
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
probability: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -380,12 +381,14 @@ export interface UpdateStagePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
probability?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateStagePayload {
|
export interface CreateStagePayload {
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
probability?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deal ---
|
// --- Deal ---
|
||||||
|
|
@ -819,3 +822,86 @@ export interface SetCustomFieldValuesPayload {
|
||||||
value: string | number | boolean | string[] | null;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { CompanyDetailPage } from '../crm/companies/CompanyDetailPage';
|
||||||
import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettingsContext';
|
import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettingsContext';
|
||||||
import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
|
import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
|
||||||
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
|
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
|
||||||
|
import { ForecastPage } from '../crm/forecast/ForecastPage';
|
||||||
|
import { ImportPage } from '../crm/import/ImportPage';
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
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" element={<CrmModuleGuard module="deals"><DealsPage /></CrmModuleGuard>} />
|
||||||
<Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></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/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/settings" element={<CrmSettingsPage />} />
|
||||||
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} />
|
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} />
|
||||||
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
|
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,52 @@ export function AppLayout() {
|
||||||
{!collapsed && 'Pipelines'}
|
{!collapsed && 'Pipelines'}
|
||||||
</NavLink>
|
</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) */}
|
{/* CRM Einstellungen (nur Admins) */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue