ui-nyla/src/pages/views/trustee/TrusteeExpenseImportView.tsx
2026-04-21 00:50:42 +02:00

721 lines
26 KiB
TypeScript

/**
* TrusteeExpenseImportView
*
* Setup page for automatic expense import from SharePoint PDFs.
* Allows users to connect their Microsoft account, select a SharePoint folder,
* and activate daily automation for expense extraction.
*
* Uses the consolidated workflow engine via /api/workflows/{instanceId}/.
* The routes accept any feature instanceId the user has access to (not limited
* to graphicalEditor instances).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useConnections } from '../../../hooks/useConnections';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
import { _buildExpenseImportGraph, _buildScheduledExpenseImportGraph } from './trusteePipelineGraph';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const DEFAULT_EXTRACTION_PROMPT = `Du bist ein Spezialist für die Extraktion von Spesendaten aus PDF-Dokumenten und deren buchhalterische Kontierung.
AUFGABE:
Extrahiere alle Speseneinträge aus dem bereitgestellten PDF-Dokument und gib sie im CSV-Format zurück.
WICHTIGE REGELN:
1. Pro MwSt-Prozentsatz einen separaten Datensatz erstellen
2. Alle Datensätze zusammen müssen den Gesamtbetrag des Dokuments ergeben
3. Der gesamte extrahierte Text des Dokuments muss im Feld "desc" erfasst werden
4. Feld "company" enthält den Lieferanten/Verkäufer der Buchung
5. Tags müssen aus dieser Liste gewählt werden: customer, meeting, license, subscription, fuel, food, material
- Mehrere zutreffende Tags mit Komma trennen
6. Buchhalterische Kontierung: Schlage Soll-/Haben-Kontonummern vor basierend auf Schweizer Kontenrahmen (KMU)
CSV-SPALTEN (in dieser Reihenfolge):
valuta,transactionDateTime,company,desc,tags,bookingCurrency,bookingAmount,originalCurrency,originalAmount,vatPercentage,vatAmount,debitAccountNumber,creditAccountNumber,taxCode,costCenter,bookingReference
DATENFORMAT:
- valuta: YYYY-MM-DD (Valutadatum)
- transactionDateTime: Unix-Timestamp in Sekunden (Transaktionszeitpunkt)
- company: Lieferant/Verkäufer Name
- desc: Vollständiger extrahierter Text des Dokuments
- tags: Komma-getrennte Tags aus der erlaubten Liste
- bookingCurrency: Währungscode (CHF, EUR, USD, GBP)
- bookingAmount: Buchungsbetrag als Dezimalzahl
- originalCurrency: Original-Währungscode
- originalAmount: Original-Betrag als Dezimalzahl
- vatPercentage: MwSt-Prozentsatz (z.B. 8.1 für 8.1%)
- vatAmount: MwSt-Betrag als Dezimalzahl
- debitAccountNumber: Soll-Konto (Aufwandkonto, z.B. 4200=Materialaufwand, 4400=Büromaterial, 6000=Mietaufwand, 6500=Reisespesen)
- creditAccountNumber: Haben-Konto (z.B. 1020=Durchlaufkonto, 1000=Kasse, 1100=Debitoren)
- taxCode: Steuercode falls erkennbar (z.B. VM77=Vorsteuer 7.7%, VM81=Vorsteuer 8.1%)
- costCenter: Kostenstelle falls erkennbar (leer lassen wenn unbekannt)
- bookingReference: Belegnummer/Rechnungsnummer vom Dokument
KONTIERUNGSREGELN (Schweizer Kontenrahmen KMU):
- Spesenbelege: Soll=Aufwandkonto (4xxx-6xxx), Haben=1020 (Durchlaufkonto)
- Materialkosten: Soll=4200, Haben=1020
- Büromaterial: Soll=4400, Haben=1020
- Reisespesen/Transport: Soll=6500, Haben=1020
- Verpflegung: Soll=6510, Haben=1020
- Lizenzen/Abos: Soll=6800, Haben=1020
- Treibstoff: Soll=6200, Haben=1020
- Wenn unsicher: debitAccountNumber und creditAccountNumber leer lassen
HINWEISE:
- Wenn nur ein MwSt-Satz vorhanden ist, einen Datensatz erstellen
- Wenn mehrere MwSt-Sätze vorhanden sind, separate Datensätze erstellen
- Bei fehlenden Informationen: leeres Feld oder Standardwert`;
const EXPENSE_IMPORT_LABEL = 'Trustee Expense Import';
const DAILY_CRON = '0 22 * * *';
interface SiteOption {
value: string;
label: string;
siteId: string;
siteName: string;
webUrl: string;
path: string;
}
interface FolderOption {
value: string;
label: string;
siteId: string;
folderName: string;
path: string;
}
interface Connection {
id: string;
type?: string;
authority: string;
status: string;
externalUsername?: string;
accountName?: string;
connectionReference?: string;
displayLabel?: string;
}
interface ExistingWorkflow {
id: string;
label: string;
active: boolean;
connectionReference: string;
sharepointFolder: string;
}
const _parseErrorDetail = (detail: any): string => {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail.map(e => e.msg || JSON.stringify(e)).join(', ');
}
if (typeof detail === 'object' && detail !== null) {
return detail.msg || detail.message || JSON.stringify(detail);
}
return String(detail);
};
function _extractWorkflowConfig(workflow: any): { connectionReference: string; sharepointFolder: string } {
const nodes = workflow?.graph?.nodes || [];
const extractNode = nodes.find((n: any) =>
n.type === 'trustee.extractFromFiles' || n._action === 'extractFromFiles'
);
return {
connectionReference: extractNode?.parameters?.connectionReference || '',
sharepointFolder: extractNode?.parameters?.sharepointFolder || '',
};
}
interface TrusteeExpenseImportViewProps {
embedded?: boolean;
}
export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> = ({ embedded = false }) => {
const { t } = useLanguage();
const { instanceId, mandateId } = useCurrentInstance();
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
const { showSuccess, showError } = useToast();
const [msftConnections, setMsftConnections] = useState<Connection[]>([]);
const [msftConnection, setMsftConnection] = useState<Connection | null>(null);
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
const [selectedSite, setSelectedSite] = useState<SiteOption | null>(null);
const [currentPath, setCurrentPath] = useState<string>('');
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [isLoadingSites, setIsLoadingSites] = useState(false);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [existingWorkflow, setExistingWorkflow] = useState<ExistingWorkflow | null>(null);
const [isLoadingWorkflow, setIsLoadingWorkflow] = useState(true);
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
const [isRunningNow, setIsRunningNow] = useState(false);
useEffect(() => {
const msftConns = connections.filter((c: Connection) =>
(c.type === 'msft' || c.authority === 'msft') && c.status === 'active'
);
setMsftConnections(msftConns);
if (msftConns.length === 1) {
setMsftConnection(msftConns[0]);
} else if (msftConns.length === 0) {
setMsftConnection(null);
}
}, [connections]);
useEffect(() => {
const _loadExistingWorkflow = async () => {
if (!instanceId) {
setIsLoadingWorkflow(false);
return;
}
setIsLoadingWorkflow(true);
try {
const response = await api.get(`/api/workflows/${instanceId}/workflows`);
const workflows = response.data?.workflows || response.data?.items || [];
const expenseWorkflow = workflows.find((wf: any) =>
wf.label === EXPENSE_IMPORT_LABEL
);
if (expenseWorkflow) {
const config = _extractWorkflowConfig(expenseWorkflow);
setExistingWorkflow({
id: expenseWorkflow.id,
label: expenseWorkflow.label,
active: expenseWorkflow.active ?? true,
connectionReference: config.connectionReference,
sharepointFolder: config.sharepointFolder,
});
if (config.sharepointFolder) {
setSelectedFolder(config.sharepointFolder);
}
}
} catch (err) {
console.error('Failed to load existing workflow:', err);
} finally {
setIsLoadingWorkflow(false);
}
};
_loadExistingWorkflow();
}, [instanceId]);
useEffect(() => {
if (!existingWorkflow || msftConnections.length === 0 || msftConnection) return;
const savedRef = existingWorkflow.connectionReference || '';
const matchingConn = msftConnections.find(c =>
c.connectionReference === savedRef ||
savedRef.includes(c.externalUsername || '') ||
savedRef.includes(c.id)
);
if (matchingConn) {
setMsftConnection(matchingConn);
} else if (msftConnections.length === 1) {
setMsftConnection(msftConnections[0]);
}
}, [existingWorkflow, msftConnections, msftConnection]);
const _getConnectionReference = useCallback((conn: Connection): string => {
return conn.connectionReference || `connection:${conn.authority}:${conn.externalUsername}`;
}, []);
const loadSiteOptions = useCallback(async () => {
if (!msftConnection) return;
setIsLoadingSites(true);
setError(null);
try {
const connectionRef = _getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: connectionRef });
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setSiteOptions(response.data || []);
} catch (err: any) {
console.error('Failed to load sites:', err);
setError(_parseErrorDetail(err.response?.data?.detail) || t('SharePoint-Websites konnten nicht geladen werden'));
setSiteOptions([]);
} finally {
setIsLoadingSites(false);
}
}, [msftConnection, _getConnectionReference, t]);
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
if (!msftConnection || !siteId) return;
setIsLoadingFolders(true);
setError(null);
try {
const connectionRef = _getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: connectionRef, siteId });
if (path) params.append('path', path);
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setFolderOptions(response.data || []);
} catch (err: any) {
console.error('Failed to load folders:', err);
setError(_parseErrorDetail(err.response?.data?.detail) || t('Ordner konnten nicht geladen werden'));
setFolderOptions([]);
} finally {
setIsLoadingFolders(false);
}
}, [msftConnection, _getConnectionReference, t]);
useEffect(() => {
if (msftConnection) {
loadSiteOptions();
}
}, [msftConnection, loadSiteOptions]);
useEffect(() => {
if (selectedSite) {
setCurrentPath('');
setSelectedFolder('');
loadFolderOptions(selectedSite.siteId, '');
}
}, [selectedSite, loadFolderOptions]);
const handleSiteChange = (siteId: string) => {
const site = siteOptions.find(s => s.siteId === siteId);
setSelectedSite(site || null);
};
const handleFolderNavigate = (folder: FolderOption) => {
const newPath = folder.path;
setCurrentPath(newPath);
loadFolderOptions(selectedSite!.siteId, newPath);
};
const handleFolderSelect = (folder: FolderOption) => {
const fullPath = `${selectedSite?.path || ''}/${folder.path}`;
setSelectedFolder(fullPath);
};
const handleGoUp = () => {
if (!currentPath) return;
const parts = currentPath.split('/');
parts.pop();
const parentPath = parts.join('/');
setCurrentPath(parentPath);
loadFolderOptions(selectedSite!.siteId, parentPath);
};
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
try {
await createMicrosoftConnectionAndAuth();
await fetchConnections();
} catch (err: any) {
console.error('Connection failed:', err);
setError(err.message || t('Microsoft-Verbindung fehlgeschlagen'));
} finally {
setIsConnecting(false);
}
};
const handleSave = async (activate: boolean = true) => {
if (!msftConnection) {
showError(t('Fehlende Verbindung'), t('Bitte wählen Sie zuerst eine Microsoft-Verbindung aus.'));
return;
}
if (!selectedFolder) {
showError(t('Fehlender Ordner'), t('Bitte wählen Sie zuerst einen SharePoint-Ordner aus.'));
return;
}
if (!instanceId || !mandateId) {
showError(t('Fehler'), t('Feature-Instanz nicht gefunden. Bitte Seite neu laden.'));
return;
}
setIsActivating(true);
setError(null);
setSuccessMessage(null);
try {
const connectionRef = _getConnectionReference(msftConnection);
const graph = _buildScheduledExpenseImportGraph(
instanceId,
connectionRef,
selectedFolder,
DEFAULT_EXTRACTION_PROMPT,
DAILY_CRON
);
const invocations = [{
id: 'schedule-daily',
kind: 'schedule',
triggerNodeId: 'trigger-schedule',
enabled: activate,
label: t('Täglich um 22:00 Uhr'),
config: { cron: DAILY_CRON },
}];
let response;
if (existingWorkflow) {
response = await api.put(
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
{
label: EXPENSE_IMPORT_LABEL,
graph,
active: activate,
invocations,
}
);
const msg = t('Ausgabenimport-Workflow aktualisiert und');
setSuccessMessage(msg);
showSuccess(t('Erfolg'), msg);
} else {
response = await api.post(
`/api/workflows/${instanceId}/workflows`,
{
label: EXPENSE_IMPORT_LABEL,
graph,
active: activate,
invocations,
}
);
const msg = t('Ausgabenimport-Workflow erstellt und aktiviert! Er wird täglich um 22:00 Uhr ausgeführt.');
setSuccessMessage(msg);
showSuccess(t('Erfolg'), msg);
}
const savedWorkflow = response.data;
const config = _extractWorkflowConfig(savedWorkflow);
setExistingWorkflow({
id: savedWorkflow.id,
label: savedWorkflow.label,
active: savedWorkflow.active ?? activate,
connectionReference: config.connectionReference,
sharepointFolder: config.sharepointFolder,
});
} catch (err: any) {
console.error('Save failed:', err);
const errorMsg = _parseErrorDetail(err.response?.data?.detail) || err.message || t('Workflow konnte nicht gespeichert werden');
setError(errorMsg);
showError(t('Fehler'), errorMsg);
} finally {
setIsActivating(false);
}
};
const handleRunNow = async () => {
if (!msftConnection || !selectedFolder || !instanceId) {
showError(t('Daten unvollständig'), t('Bitte zuerst Verbindung und Ordner wählen.'));
return;
}
setIsRunningNow(true);
setError(null);
try {
const connectionRef = _getConnectionReference(msftConnection);
const graph = _buildExpenseImportGraph(
instanceId,
connectionRef,
selectedFolder,
DEFAULT_EXTRACTION_PROMPT
);
await api.post(
`/api/workflows/${instanceId}/execute`,
{ graph }
);
showSuccess(t('Gestartet'), t('Workflow gestartet. Extrahieren → Verarbeiten → Sync wird einmal ausgeführt.'));
} catch (err: any) {
const msg = _parseErrorDetail(err.response?.data?.detail) || err.message || t('Workflow konnte nicht gestartet werden');
setError(msg);
showError(t('Fehler'), msg);
} finally {
setIsRunningNow(false);
}
};
const handleDeactivate = async () => {
if (!existingWorkflow || !instanceId) return;
setIsActivating(true);
setError(null);
try {
await api.put(
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
{ active: false }
);
setExistingWorkflow(prev => prev ? { ...prev, active: false } : null);
setSuccessMessage(t('Ausgabenimport-Workflow deaktiviert.'));
showSuccess(t('Deaktiviert'), t('Ausgabenimport-Workflow deaktiviert.'));
} catch (err: any) {
console.error('Deactivation failed:', err);
const errorMsg = _parseErrorDetail(err.response?.data?.detail) || err.message || t('Workflow konnte nicht deaktiviert werden');
setError(errorMsg);
showError(t('Fehler'), errorMsg);
} finally {
setIsActivating(false);
}
};
const content = (
<>
{!embedded && <h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>}
<p className={styles.sectionDescription}>
{t('Verbinden Sie Ihr Microsoft-Konto und wählen Sie einen SharePoint-Ordner mit Ausgaben-PDFs. Das System extrahiert automatisch täglich die Ausgabendaten und speichert sie als Positionen.')}
<span
className={styles.infoIcon}
onMouseEnter={() => setShowInfoTooltip(true)}
onMouseLeave={() => setShowInfoTooltip(false)}
onClick={() => setShowInfoTooltip(!showInfoTooltip)}
title={t('So funktioniert es')}
>
</span>
{showInfoTooltip && (
<span className={styles.infoTooltip}>
<strong>{t('So funktioniert es')}</strong>
<ul>
<li>{t('Platzieren Sie Ausgaben-PDF-Dokumente/Belege')}</li>
<li>{t('Das System läuft täglich um')}</li>
<li>{t('AI extrahiert Ausgabendaten (Datum)')}</li>
<li>{t('Jede Ausgabe wird gespeichert als')}</li>
<li>{t('Verarbeitete PDFs werden verschoben nach')}</li>
<li>{t('Fehlgeschlagene PDFs werden verschoben nach')}</li>
<li>{t('Maximal 50 PDFs werden verarbeitet')}</li>
</ul>
</span>
)}
</p>
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
{successMessage && (
<div className={styles.successMessage}>
{successMessage}
</div>
)}
{/* Current Status */}
{!isLoadingWorkflow && existingWorkflow && (
<div className={existingWorkflow.active ? styles.successMessage : styles.infoBox} style={{ marginBottom: '1rem' }}>
<strong>{t('Aktueller Status')}</strong> {existingWorkflow.active ? t('Aktiv') : t('Inaktiv')}
{existingWorkflow.sharepointFolder && (
<><br />{t('Ordner:')} {existingWorkflow.sharepointFolder}</>
)}
</div>
)}
{/* Step 1: Microsoft Connection */}
<div className={styles.setupStep}>
<div className={styles.stepNumber}>1</div>
<div className={styles.stepContent}>
<h4>{t('Microsoft-Verbindung')}</h4>
{msftConnections.length === 0 ? (
<button
className={styles.primaryButton}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? t('Verbindung wird hergestellt...') : t('Microsoft-Konto verbinden')}
</button>
) : msftConnections.length === 1 ? (
<div className={styles.connectionStatus}>
<span className={styles.connectedIcon}></span>
<span className={styles.connectedText}>
{t('Verbunden als')} <strong>{msftConnections[0].accountName || t('Microsoft-Konto')}</strong>
</span>
</div>
) : (
<>
<select
className={styles.folderSelect}
value={msftConnection?.id || ''}
onChange={(e) => {
const conn = msftConnections.find(c => c.id === e.target.value);
setMsftConnection(conn || null);
setSelectedSite(null);
setSiteOptions([]);
setFolderOptions([]);
setSelectedFolder('');
}}
>
<option value="">{t('Wählen Sie ein Microsoft-Konto')}</option>
{msftConnections.map((conn) => (
<option key={conn.id} value={conn.id}>
{conn.displayLabel || conn.externalUsername || conn.id}
</option>
))}
</select>
<button
className={styles.secondaryButton}
onClick={handleConnect}
disabled={isConnecting}
style={{ marginTop: '0.5rem' }}
>
{isConnecting ? t('Verbindung wird hergestellt...') : t('Weiteres Konto verbinden')}
</button>
</>
)}
</div>
</div>
{/* Step 2: SharePoint Site Selection */}
{msftConnection && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>2</div>
<div className={styles.stepContent}>
<h4>{t('SharePoint-Seite')}</h4>
{isLoadingSites ? (
<div className={styles.loadingText}>{t('Lade Seiten')}</div>
) : (
<select
className={styles.folderSelect}
value={selectedSite?.siteId || ''}
onChange={(e) => handleSiteChange(e.target.value)}
>
<option value="">{t('Wählen Sie eine Seite')}</option>
{siteOptions.map((site) => (
<option key={site.siteId} value={site.siteId}>
{site.label}
</option>
))}
</select>
)}
</div>
</div>
)}
{/* Step 3: Folder Selection */}
{selectedSite && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>3</div>
<div className={styles.stepContent}>
<h4>{t('Ausgabenordner')}</h4>
<p className={styles.activateDescription}>
{t('Aktueller Pfad:')} <strong>{selectedSite.path}/{currentPath || t('(Stammverzeichnis)')}</strong>
</p>
{isLoadingFolders ? (
<div className={styles.loadingText}>{t('Lade Ordner')}</div>
) : (
<div className={styles.folderBrowser}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
{currentPath && (
<button
className={styles.secondaryButton}
onClick={handleGoUp}
>
{t('Übergeordneter Ordner')}
</button>
)}
<button
className={styles.primaryButton}
onClick={() => {
const fullPath = `${selectedSite?.path || ''}/${currentPath || ''}`.replace(/\/+$/, '');
setSelectedFolder(fullPath || selectedSite?.path || '');
}}
>
{t('Diesen Ordner auswählen')}
</button>
</div>
<div className={styles.folderList}>
{folderOptions.map((folder) => (
<div key={folder.value} className={styles.folderItem}>
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
style={{ cursor: 'pointer', flex: 1 }}
>
📁 {folder.label}
</span>
<button
className={styles.selectButton}
onClick={() => handleFolderSelect(folder)}
>
{t('Auswählen')}
</button>
</div>
))}
{folderOptions.length === 0 && (
<div className={styles.emptyText}>{t('Keine Unterordner gefunden')}</div>
)}
</div>
{selectedFolder && (
<p className={styles.selectedFolderText}>
{t('Ausgewählt:')} <strong>{selectedFolder}</strong>
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Step 4: Save & Activate */}
{selectedFolder && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>4</div>
<div className={styles.stepContent}>
<h4>{existingWorkflow ? t('Konfiguration aktualisieren') : t('Täglichen Import aktivieren')}</h4>
<p className={styles.activateDescription}>
{t('PDF-Dateien in')}{' '}<strong>{selectedFolder}</strong>{' '}{t('werden täglich um 22:00 Uhr verarbeitet. Erfolgreich verarbeitete Dateien werden in einen Unterordner „verarbeitet“ verschoben.')}
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className={styles.primaryButton}
onClick={() => handleSave(true)}
disabled={isActivating}
>
{isActivating ? t('Wird gespeichert...') : (existingWorkflow ? t('Speichern & Aktivieren') : t('Täglichen Import aktivieren'))}
</button>
<button
className={styles.secondaryButton}
onClick={handleRunNow}
disabled={isActivating || isRunningNow}
>
{isRunningNow ? t('Wird gestartet...') : t('Jetzt ausführen')}
</button>
{existingWorkflow && existingWorkflow.active && (
<button
className={styles.secondaryButton}
onClick={handleDeactivate}
disabled={isActivating}
>
{t('Deaktivieren')}
</button>
)}
</div>
</div>
</div>
)}
</>
);
if (embedded) {
return <>{content}</>;
}
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
{content}
</div>
</div>
);
};
export default TrusteeExpenseImportView;