721 lines
26 KiB
TypeScript
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;
|