mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56: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,
|
||||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 "Ü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
|
||||
isOpen={isDeleteOpen}
|
||||
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 {
|
||||
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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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 [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={
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue