diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index c5d9270..250ea2a 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -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), +}; diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx index ab2e558..6e94e3c 100644 --- a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx @@ -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([]); const [sourceFilter, setSourceFilter] = useState('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() { />
+ +
+ + setDeleteOpen(false)} diff --git a/packages/frontend/src/crm/forecast/ForecastPage.tsx b/packages/frontend/src/crm/forecast/ForecastPage.tsx new file mode 100644 index 0000000..2239672 --- /dev/null +++ b/packages/frontend/src/crm/forecast/ForecastPage.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { useForecast, usePipelines } from '../hooks'; +import type { ForecastPeriod } from '../types'; + +const periodLabels: Record = { + 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(''); + const [period, setPeriod] = useState('quarter'); + + const { data: pipelinesData } = usePipelines(); + const { data, isLoading, error } = useForecast( + pipelineId || undefined, + period, + ); + + const pipelines = pipelinesData?.data ?? []; + const forecast = data?.data; + + return ( +
+ {/* Header */} +
+

Prognose

+
+ + {/* Filter */} +
+
+ + +
+ +
+ +
+ {(Object.keys(periodLabels) as ForecastPeriod[]).map((p) => ( + + ))} +
+
+
+ + {/* Content */} + {isLoading &&

Laden...

} + {error && ( +

+ Fehler beim Laden der Prognose +

+ )} + + {forecast && ( + <> + {/* Summary cards */} +
+ + + + +
+ + {/* Table */} +
+ + + + + + + + + + + + {forecast.stages.map((stage) => ( + + + + + + + + ))} + {forecast.stages.length === 0 && ( + + + + )} + + {forecast.stages.length > 0 && ( + + + + + + + + + + )} +
Stufe + Wahrscheinlichkeit + Vorgänge + Gesamtwert + + Gewichteter Wert +
{stage.stageName} + {Math.round(stage.probability * 100)}% + + {stage.dealCount} + + {formatCurrency(stage.totalValue)} + + {formatCurrency(stage.weightedValue)} +
+ Keine offenen Vorgänge in diesem Zeitraum +
Gesamt + {forecast.totals.dealCount} + + {formatCurrency(forecast.totals.totalValue)} + + {formatCurrency(forecast.totals.weightedValue)} +
+
+ + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index a92665e..7e45d94 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -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() }); + }, + }); +} diff --git a/packages/frontend/src/crm/import/ImportPage.tsx b/packages/frontend/src/crm/import/ImportPage.tsx new file mode 100644 index 0000000..e28df6b --- /dev/null +++ b/packages/frontend/src/crm/import/ImportPage.tsx @@ -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 = { + PERSON: 'Kontakte', + COMPANY: 'Unternehmen', + DEAL: 'Vorgänge', +}; + +/** CRM-Felder pro Entity-Typ (Datei-Spalte → CRM-Feld) */ +const crmFieldsByEntity: Record = { + 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 = { + SKIP: 'Überspringen', + UPDATE: 'Aktualisieren', + MARK: 'Markieren', +}; + +type Step = 'upload' | 'mapping' | 'result'; + +export function ImportPage() { + const [step, setStep] = useState('upload'); + const [entityType, setEntityType] = useState('PERSON'); + const [preview, setPreview] = useState(null); + const [mapping, setMapping] = useState>({}); + const [duplicateStrategy, setDuplicateStrategy] = + useState('SKIP'); + const [skipFirstRow, setSkipFirstRow] = useState(true); + const [result, setResult] = useState(null); + + const fileRef = useRef(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 = {}; + 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 ( +
+

+ Daten importieren +

+ + {/* Step indicator */} +
+ + + + + +
+ + {/* Step 1: Upload */} + {step === 'upload' && ( +
+
+ +
+ {(Object.keys(entityTypeLabels) as ImportEntityType[]).map((t) => ( + + ))} +
+
+ +
+ + +

+ Max. 10 MB, max. 5.000 Zeilen +

+
+ + + + {previewMutation.isError && ( +

+ Fehler beim Laden der Vorschau. Bitte Dateiformat prüfen. +

+ )} +
+ )} + + {/* Step 2: Mapping */} + {step === 'mapping' && preview && ( +
+

+ {preview.totalRows} Zeilen erkannt ({preview.format.toUpperCase()}). + Ordne die Spalten den CRM-Feldern zu. +

+ + {/* Column mapping */} +
+ + + + + + + + + + {preview.columns.map((col) => ( + + + + + + ))} + +
Datei-SpalteCRM-FeldVorschau
{col} + + + {preview.rows[0]?.[col] ?? '—'} +
+
+ + {/* Options */} +
+
+ + +
+ + +
+ +
+ + +
+ + {executeMutation.isError && ( +

+ Fehler beim Import. Bitte versuche es erneut. +

+ )} +
+ )} + + {/* Step 3: Result */} + {step === 'result' && result && ( +
+

0 ? 'var(--color-warning, #d97706)' : 'var(--color-success, #16a34a)', + }} + > + {result.errors > 0 ? 'Import mit Warnungen abgeschlossen' : 'Import erfolgreich'} +

+ +
+ + + + +
+ + {result.errorDetails.length > 0 && ( +
+

+ Fehlerdetails +

+
+ {result.errorDetails.map((err, i) => ( +
+ Zeile {err.row}: {err.field} — {err.message} +
+ ))} +
+
+ )} + + +
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 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 ( + + {label} + + ); +} + +function StepDivider() { + return ( + + ); +} + +function ResultCard({ + label, + value, + color, +}: { + label: string; + value: number; + color: string; +}) { + return ( +
+
{value}
+
+ {label} +
+
+ ); +} diff --git a/packages/frontend/src/crm/pipelines/PipelinesPage.tsx b/packages/frontend/src/crm/pipelines/PipelinesPage.tsx index 9750a93..8f1a80d 100644 --- a/packages/frontend/src/crm/pipelines/PipelinesPage.tsx +++ b/packages/frontend/src/crm/pipelines/PipelinesPage.tsx @@ -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 = {}; + const changes: Record = {}; 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 /> + 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 (%)" + /> + %
+ +
+ ) : ( + + )} + + )} + + ); +} + // ============================================================ // 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() { Lexoffice Sync +