Compare commits
No commits in common. "main" and "HEAD" have entirely different histories.
11 changed files with 73 additions and 782 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
name: Deploy Nyla
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to Infomaniak VM
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
|
||||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
|
||||||
ssh -i ~/.ssh/deploy_key ubuntu@porta.poweron.swiss "
|
|
||||||
cd /srv/nyla/current &&
|
|
||||||
git pull &&
|
|
||||||
npm ci &&
|
|
||||||
npm run build:prod &&
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
"
|
|
||||||
|
|
@ -115,7 +115,6 @@ export interface AccountingConnectorInfo {
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
suggestions?: string[];
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,108 +392,6 @@ export async function deleteWorkflow(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Workflow file IO (envelopeVersioned, .workflow.json)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** envelopeVersioned schema 1.0 — keys mirror the gateway constants. */
|
|
||||||
export const WORKFLOW_FILE_SCHEMA_VERSION = '1.0';
|
|
||||||
export const WORKFLOW_FILE_KIND = 'poweron.workflow';
|
|
||||||
export const WORKFLOW_FILE_EXTENSION = '.workflow.json';
|
|
||||||
|
|
||||||
export interface WorkflowFileEnvelope {
|
|
||||||
$schemaVersion: string;
|
|
||||||
$kind: string;
|
|
||||||
$exportedAt?: string;
|
|
||||||
$gatewayVersion?: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
tags?: string[];
|
|
||||||
templateScope?: AutoTemplateScope;
|
|
||||||
sharedReadOnly?: boolean;
|
|
||||||
notifyOnFailure?: boolean;
|
|
||||||
graph: Automation2Graph;
|
|
||||||
invocations?: WorkflowEntryPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportWorkflowResponse {
|
|
||||||
workflow: AutoWorkflow;
|
|
||||||
warnings: string[];
|
|
||||||
created: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportWorkflowOptions {
|
|
||||||
/** Inline envelope payload (preferred for round-trip in the editor). */
|
|
||||||
envelope?: WorkflowFileEnvelope;
|
|
||||||
/** UDB FileItem.id of a previously uploaded ``.workflow.json``. */
|
|
||||||
fileId?: string;
|
|
||||||
/** When set, the existing workflow is replaced instead of a new one being created. */
|
|
||||||
existingWorkflowId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/workflows/{instanceId}/workflows/import */
|
|
||||||
export async function importWorkflowFromFile(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
options: ImportWorkflowOptions,
|
|
||||||
): Promise<ImportWorkflowResponse> {
|
|
||||||
if (!options.envelope && !options.fileId) {
|
|
||||||
throw new Error('importWorkflowFromFile: either envelope or fileId is required');
|
|
||||||
}
|
|
||||||
return await request({
|
|
||||||
url: `/api/workflows/${instanceId}/workflows/import`,
|
|
||||||
method: 'post',
|
|
||||||
data: options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportWorkflowResult {
|
|
||||||
fileName: string;
|
|
||||||
envelope: WorkflowFileEnvelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/workflows/{instanceId}/workflows/{workflowId}/export
|
|
||||||
*
|
|
||||||
* The backend returns ``{ fileName, envelope }`` when ``download=false`` and a
|
|
||||||
* raw JSON download (``Content-Disposition: attachment``) when ``download=true``.
|
|
||||||
* For programmatic use (e.g. re-uploading to UDB) keep download=false.
|
|
||||||
*/
|
|
||||||
export async function exportWorkflowToFile(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string,
|
|
||||||
download = false,
|
|
||||||
): Promise<ExportWorkflowResult> {
|
|
||||||
return await request({
|
|
||||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}/export`,
|
|
||||||
method: 'get',
|
|
||||||
params: { download },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Quick content-sniffing — used by the UDB FilesTab to flag workflow files. */
|
|
||||||
export function isWorkflowFileContent(payload: unknown): boolean {
|
|
||||||
if (!payload || typeof payload !== 'object') return false;
|
|
||||||
const p = payload as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof p.$schemaVersion === 'string' &&
|
|
||||||
p.$kind === WORKFLOW_FILE_KIND &&
|
|
||||||
!!p.graph &&
|
|
||||||
typeof p.graph === 'object'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Suggest a safe filename from the workflow label (mirrors gateway buildFileName). */
|
|
||||||
export function workflowFileNameFor(label: string): string {
|
|
||||||
const slug = (label || 'workflow')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 80) || 'workflow';
|
|
||||||
return `${slug}${WORKFLOW_FILE_EXTENSION}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
|
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
|
||||||
export async function deleteSystemWorkflow(
|
export async function deleteSystemWorkflow(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
|
|
|
||||||
|
|
@ -275,35 +275,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} else {
|
} else {
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||||
}
|
}
|
||||||
setWorkflows((prev) => {
|
|
||||||
const idx = prev.findIndex((w) => w.id === workflowId);
|
|
||||||
if (idx === -1) return [...prev, wf];
|
|
||||||
const next = prev.slice();
|
|
||||||
next[idx] = { ...prev[idx], ...wf };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
|
||||||
if (status === 404) {
|
|
||||||
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
|
||||||
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
|
||||||
setExecuteResult(null);
|
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
|
||||||
try {
|
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
|
||||||
} catch (refreshErr) {
|
|
||||||
console.error(`${LOG} workflows refresh failed`, refreshErr);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setExecuteResult({
|
setExecuteResult({
|
||||||
success: false,
|
success: false,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
|
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkflowSelect = useCallback(
|
const handleWorkflowSelect = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -153,23 +153,15 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
|
||||||
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
|
||||||
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!instanceId || !request) return;
|
if (!instanceId || !request) return;
|
||||||
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
|
request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' })
|
||||||
setLoadError(null);
|
|
||||||
request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
|
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
const data = res as { options?: Array<{ value: string; label: string }> };
|
const data = res as { options?: Array<{ value: string; label: string }> };
|
||||||
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch(() => {});
|
||||||
console.error('ConnectionPicker: failed to load connections', err);
|
}, [instanceId, request]);
|
||||||
setConnections([]);
|
|
||||||
setLoadError(err instanceof Error ? err.message : String(err));
|
|
||||||
});
|
|
||||||
}, [instanceId, request, authority]);
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||||
|
|
@ -183,16 +175,6 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
||||||
<option key={c.id} value={c.id}>{c.label}</option>
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{!loadError && connections.length === 0 && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
|
|
||||||
{authority
|
|
||||||
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
|
|
||||||
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loadError && (
|
|
||||||
<div style={{ fontSize: 11, color: '#c00', marginTop: 2 }}>{t('Verbindungen konnten nicht geladen werden')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -217,205 +199,6 @@ const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, al
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type SharepointSiteOption = { type: 'site'; value: string; label: string; siteId: string; path: string; webUrl?: string };
|
|
||||||
type SharepointItemOption = { type: 'folder' | 'file'; value: string; label: string; path: string; siteId: string };
|
|
||||||
|
|
||||||
const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, request }) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const isFilePicker = param.frontendType === 'sharepointFile';
|
|
||||||
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
|
|
||||||
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
|
|
||||||
const hasConnection = !!connectionReference;
|
|
||||||
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const [sites, setSites] = React.useState<SharepointSiteOption[]>([]);
|
|
||||||
const [selectedSite, setSelectedSite] = React.useState<SharepointSiteOption | null>(null);
|
|
||||||
const [currentPath, setCurrentPath] = React.useState('');
|
|
||||||
const [items, setItems] = React.useState<SharepointItemOption[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSites = React.useCallback(async () => {
|
|
||||||
if (!request || !connectionReference) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = (await request({
|
|
||||||
url: `/api/sharepoint/folder-options?connectionReference=${encodeURIComponent(connectionReference)}`,
|
|
||||||
method: 'get',
|
|
||||||
})) as SharepointSiteOption[] | null;
|
|
||||||
setSites(Array.isArray(res) ? res : []);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('SharepointPathPicker: failed to load sites', err);
|
|
||||||
setSites([]);
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [request, connectionReference]);
|
|
||||||
|
|
||||||
const loadItems = React.useCallback(async (site: SharepointSiteOption, path: string) => {
|
|
||||||
if (!request) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ connectionReference, siteId: site.siteId });
|
|
||||||
if (path) params.append('path', path);
|
|
||||||
if (isFilePicker) params.append('includeFiles', 'true');
|
|
||||||
const res = (await request({
|
|
||||||
url: `/api/sharepoint/folder-options?${params.toString()}`,
|
|
||||||
method: 'get',
|
|
||||||
})) as SharepointItemOption[] | null;
|
|
||||||
setItems(Array.isArray(res) ? res : []);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('SharepointPathPicker: failed to load items', err);
|
|
||||||
setItems([]);
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [request, connectionReference, isFilePicker]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
if (sites.length === 0 && !loading) loadSites();
|
|
||||||
}, [open, sites.length, loading, loadSites]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (selectedSite) {
|
|
||||||
setCurrentPath('');
|
|
||||||
loadItems(selectedSite, '');
|
|
||||||
} else {
|
|
||||||
setItems([]);
|
|
||||||
}
|
|
||||||
}, [selectedSite, loadItems]);
|
|
||||||
|
|
||||||
const navigateInto = (item: SharepointItemOption) => {
|
|
||||||
if (item.type !== 'folder' || !selectedSite) return;
|
|
||||||
setCurrentPath(item.path);
|
|
||||||
loadItems(selectedSite, item.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goUp = () => {
|
|
||||||
if (!selectedSite || !currentPath) return;
|
|
||||||
const parts = currentPath.split('/');
|
|
||||||
parts.pop();
|
|
||||||
const parent = parts.join('/');
|
|
||||||
setCurrentPath(parent);
|
|
||||||
loadItems(selectedSite, parent);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFullPath = (sub: string) => {
|
|
||||||
const sitePath = (selectedSite?.path || '').replace(/\/+$/, '');
|
|
||||||
const cleanSub = sub.replace(/^\/+/, '');
|
|
||||||
if (!sitePath) return `/${cleanSub}`;
|
|
||||||
if (!cleanSub) return sitePath;
|
|
||||||
return `${sitePath}/${cleanSub}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickCurrentFolder = () => {
|
|
||||||
if (!selectedSite) return;
|
|
||||||
const fullPath = buildFullPath(currentPath);
|
|
||||||
onChange(fullPath);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickItem = (item: SharepointItemOption) => {
|
|
||||||
if (!selectedSite) return;
|
|
||||||
const fullPath = buildFullPath(item.path);
|
|
||||||
onChange(fullPath);
|
|
||||||
if (item.type === 'file') setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={typeof value === 'string' ? value : ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={!hasConnection}
|
|
||||||
placeholder={hasConnection ? (isFilePicker ? t('SharePoint-Dateipfad') : t('SharePoint-Ordnerpfad')) : t('Zuerst {field} wählen', { field: dependsOn })}
|
|
||||||
style={{ flex: 1, padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: hasConnection ? 1 : 0.5 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasConnection}
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
title={t('In SharePoint browsen')}
|
|
||||||
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #0078D4', background: open ? '#0078D4' : '#fff', color: open ? '#fff' : '#0078D4', cursor: hasConnection ? 'pointer' : 'not-allowed', opacity: hasConnection ? 1 : 0.5 }}
|
|
||||||
>
|
|
||||||
{open ? t('Schliessen') : t('Browsen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && hasConnection && (
|
|
||||||
<div style={{ marginTop: 6, border: '1px solid #ddd', borderRadius: 4, padding: 6, background: '#fafafa' }}>
|
|
||||||
{error && <div style={{ fontSize: 11, color: '#c00', marginBottom: 4 }}>{error}</div>}
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 4 }}>
|
|
||||||
<span style={{ fontSize: 11, color: '#555' }}>{t('Seite:')}</span>
|
|
||||||
<select
|
|
||||||
value={selectedSite?.siteId || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const s = sites.find((x) => x.siteId === e.target.value) || null;
|
|
||||||
setSelectedSite(s);
|
|
||||||
}}
|
|
||||||
style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
<option value="">{loading && sites.length === 0 ? t('Lade Seiten') : t('Seite wählen')}</option>
|
|
||||||
{sites.map((s) => (
|
|
||||||
<option key={s.siteId} value={s.siteId}>{s.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="button" onClick={loadSites} title={t('Seiten neu laden')} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer', fontSize: 11 }}>↻</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSite && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 11, color: '#555', marginBottom: 4 }}>
|
|
||||||
<strong>{t('Pfad:')}</strong> {selectedSite.path}/{currentPath || <em>{t('(Stammverzeichnis)')}</em>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
|
|
||||||
{currentPath && (
|
|
||||||
<button type="button" onClick={goUp} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer', fontSize: 11 }}>↑ {t('Hoch')}</button>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={pickCurrentFolder} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #0078D4', background: '#0078D4', color: '#fff', cursor: 'pointer', fontSize: 11 }}>
|
|
||||||
✓ {isFilePicker ? t('Diesen Ordner als Pfad nehmen') : t('Diesen Ordner wählen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: 180, overflow: 'auto', border: '1px solid #eee', borderRadius: 4, background: '#fff' }}>
|
|
||||||
{loading && <div style={{ padding: 6, fontSize: 11, color: '#888' }}>{t('Lade')}</div>}
|
|
||||||
{!loading && items.length === 0 && (
|
|
||||||
<div style={{ padding: 6, fontSize: 11, color: '#888' }}>{isFilePicker ? t('Keine Dateien oder Ordner') : t('Keine Unterordner')}</div>
|
|
||||||
)}
|
|
||||||
{!loading && items.map((item) => (
|
|
||||||
<div key={`${item.type}:${item.value}`} style={{ display: 'flex', alignItems: 'center', padding: '3px 6px', borderBottom: '1px solid #f0f0f0', fontSize: 12 }}>
|
|
||||||
<span
|
|
||||||
onClick={() => (item.type === 'folder' ? navigateInto(item) : pickItem(item))}
|
|
||||||
style={{ flex: 1, cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
title={item.type === 'folder' ? t('Öffnen') : t('Wählen')}
|
|
||||||
>
|
|
||||||
{item.type === 'folder' ? '📁' : '📄'} {item.label}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => pickItem(item)}
|
|
||||||
style={{ padding: '1px 6px', borderRadius: 3, border: '1px solid #0078D4', background: '#fff', color: '#0078D4', cursor: 'pointer', fontSize: 11 }}
|
|
||||||
>
|
|
||||||
{t('Wählen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const cases = Array.isArray(value) ? value : [];
|
const cases = Array.isArray(value) ? value : [];
|
||||||
|
|
@ -619,8 +402,8 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
file: TextInput,
|
file: TextInput,
|
||||||
hidden: HiddenInput,
|
hidden: HiddenInput,
|
||||||
userConnection: ConnectionPicker,
|
userConnection: ConnectionPicker,
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: FolderPicker,
|
||||||
sharepointFile: SharepointPathPicker,
|
sharepointFile: FolderPicker,
|
||||||
clickupList: FolderPicker,
|
clickupList: FolderPicker,
|
||||||
clickupTask: FolderPicker,
|
clickupTask: FolderPicker,
|
||||||
caseList: CaseListEditor,
|
caseList: CaseListEditor,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function _hideOnboarding(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
|
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
|
||||||
const { t, currentLanguage } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const callouts = useMemo(() => ({
|
const callouts = useMemo(() => ({
|
||||||
mandate: t('Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.'),
|
mandate: t('Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.'),
|
||||||
feature: t('Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.'),
|
feature: t('Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.'),
|
||||||
|
|
@ -73,7 +73,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
|
||||||
let workspaceInstancePath: string | undefined;
|
let workspaceInstancePath: string | undefined;
|
||||||
let workspaceInstanceIds: string[] = [];
|
let workspaceInstanceIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
const navRes = await api.get(`/api/navigation?language=${currentLanguage}`);
|
const navRes = await api.get('/api/navigation?language=de');
|
||||||
const blocks = navRes.data?.blocks || [];
|
const blocks = navRes.data?.blocks || [];
|
||||||
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
|
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
|
||||||
const mandates = dynamicBlock?.mandates || [];
|
const mandates = dynamicBlock?.mandates || [];
|
||||||
|
|
@ -165,7 +165,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [navigate, t, currentLanguage]);
|
}, [navigate, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { showOnboarding?: number } | null;
|
const state = location.state as { showOnboarding?: number } | null;
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
export type BackgroundJobStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
|
|
||||||
|
|
||||||
export interface BackgroundJob {
|
|
||||||
id: string;
|
|
||||||
jobType: string;
|
|
||||||
mandateId?: string | null;
|
|
||||||
featureInstanceId?: string | null;
|
|
||||||
triggeredBy?: string | null;
|
|
||||||
status: BackgroundJobStatus;
|
|
||||||
progress: number;
|
|
||||||
progressMessage?: string | null;
|
|
||||||
payload?: Record<string, any>;
|
|
||||||
result?: Record<string, any> | null;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
createdAt?: string;
|
|
||||||
startedAt?: string | null;
|
|
||||||
finishedAt?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TERMINAL_STATUSES: BackgroundJobStatus[] = ['SUCCESS', 'ERROR', 'CANCELLED'];
|
|
||||||
|
|
||||||
export interface UseBackgroundJobOptions {
|
|
||||||
pollMs?: number;
|
|
||||||
enabled?: boolean;
|
|
||||||
onSuccess?: (job: BackgroundJob) => void;
|
|
||||||
onError?: (job: BackgroundJob) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseBackgroundJobResult {
|
|
||||||
job: BackgroundJob | null;
|
|
||||||
isFinal: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polls /api/jobs/{jobId} until the job reaches a terminal status.
|
|
||||||
*
|
|
||||||
* Use after submitting a long-running task to the generic background job
|
|
||||||
* service. Handles polling, cleanup on unmount, and exposes the job record
|
|
||||||
* directly so callers can read `job.progress`, `job.result`, etc.
|
|
||||||
*/
|
|
||||||
export function useBackgroundJob(
|
|
||||||
jobId: string | null | undefined,
|
|
||||||
opts: UseBackgroundJobOptions = {},
|
|
||||||
): UseBackgroundJobResult {
|
|
||||||
const { pollMs = 2000, enabled = true, onSuccess, onError } = opts;
|
|
||||||
const [job, setJob] = useState<BackgroundJob | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const onSuccessRef = useRef(onSuccess);
|
|
||||||
const onErrorRef = useRef(onError);
|
|
||||||
|
|
||||||
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
||||||
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true;
|
|
||||||
return () => { mountedRef.current = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchOnce = useCallback(async (): Promise<BackgroundJob | null> => {
|
|
||||||
if (!jobId) return null;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(`/api/jobs/${jobId}`);
|
|
||||||
const next = res.data as BackgroundJob;
|
|
||||||
if (mountedRef.current) setJob(next);
|
|
||||||
return next;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setJob(prev => prev ?? {
|
|
||||||
id: jobId,
|
|
||||||
jobType: '',
|
|
||||||
status: 'ERROR',
|
|
||||||
progress: 0,
|
|
||||||
errorMessage: err?.response?.data?.detail || err?.message || 'Job nicht abrufbar',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
if (mountedRef.current) setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [jobId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !jobId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let firedTerminal = false;
|
|
||||||
|
|
||||||
const tick = async () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const next = await fetchOnce();
|
|
||||||
if (cancelled) return;
|
|
||||||
const status = next?.status;
|
|
||||||
if (status && TERMINAL_STATUSES.includes(status)) {
|
|
||||||
if (!firedTerminal) {
|
|
||||||
firedTerminal = true;
|
|
||||||
if (status === 'SUCCESS') onSuccessRef.current?.(next!);
|
|
||||||
else onErrorRef.current?.(next!);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timer = setTimeout(tick, pollMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
tick();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [jobId, enabled, pollMs, fetchOnce]);
|
|
||||||
|
|
||||||
const isFinal = !!job && TERMINAL_STATUSES.includes(job.status);
|
|
||||||
const isError = job?.status === 'ERROR' || job?.status === 'CANCELLED';
|
|
||||||
|
|
||||||
return { job, isFinal, isError, isLoading, refetch: async () => { await fetchOnce(); } };
|
|
||||||
}
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
|
import { FaPlay, FaSync, FaCheck, FaBan, FaPen } from 'react-icons/fa';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
|
@ -18,13 +18,7 @@ import {
|
||||||
deleteWorkflow,
|
deleteWorkflow,
|
||||||
executeGraph,
|
executeGraph,
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
importWorkflowFromFile,
|
|
||||||
exportWorkflowToFile,
|
|
||||||
isWorkflowFileContent,
|
|
||||||
workflowFileNameFor,
|
|
||||||
WORKFLOW_FILE_EXTENSION,
|
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type WorkflowFileEnvelope,
|
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
|
@ -62,8 +56,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async (paginationParams?: any) => {
|
const load = useCallback(async (paginationParams?: any) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -188,69 +180,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
[instanceId, request, showSuccess, showError, load, t]
|
[instanceId, request, showSuccess, showError, load, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExport = useCallback(
|
|
||||||
async (row: Automation2Workflow) => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
|
||||||
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
|
|
||||||
const fileName = result.fileName || workflowFileNameFor(row.label);
|
|
||||||
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
|
|
||||||
type: 'application/json;charset=utf-8',
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showSuccess(t('Workflow als Datei exportiert'));
|
|
||||||
} catch (e: any) {
|
|
||||||
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[instanceId, request, showSuccess, showError, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportFileSelected = useCallback(
|
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
e.target.value = '';
|
|
||||||
if (!file || !instanceId) return;
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
let envelope: WorkflowFileEnvelope;
|
|
||||||
try {
|
|
||||||
envelope = JSON.parse(text) as WorkflowFileEnvelope;
|
|
||||||
} catch {
|
|
||||||
showError(t('Datei ist kein gültiges JSON'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isWorkflowFileContent(envelope)) {
|
|
||||||
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await importWorkflowFromFile(request, instanceId, { envelope });
|
|
||||||
const warnings = result?.warnings ?? [];
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
showSuccess(
|
|
||||||
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[instanceId, request, showSuccess, showError, load, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns: ColumnConfig[] = [
|
const columns: ColumnConfig[] = [
|
||||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
||||||
{
|
{
|
||||||
|
|
@ -345,21 +274,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className={styles.secondaryButton}
|
|
||||||
onClick={() => importFileInputRef.current?.click()}
|
|
||||||
disabled={importing || loading}
|
|
||||||
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
|
|
||||||
>
|
|
||||||
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={importFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleImportFileSelected}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => load()}
|
onClick={() => load()}
|
||||||
|
|
@ -423,12 +337,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
loading: (row) => executingId === row.id,
|
loading: (row) => executingId === row.id,
|
||||||
visible: (row) => hasManualTrigger(row),
|
visible: (row) => hasManualTrigger(row),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'export',
|
|
||||||
icon: <FaFileExport />,
|
|
||||||
title: t('Als Datei exportieren'),
|
|
||||||
onClick: (row) => handleExport(row),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
onDelete={(row) => handleDelete(row.id)}
|
onDelete={(row) => handleDelete(row.id)}
|
||||||
hookData={hookData}
|
hookData={hookData}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,12 @@
|
||||||
* testing the connection, and removing the integration.
|
* testing the connection, and removing the integration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useConfirm } from '../../../hooks/useConfirm';
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useBackgroundJob } from '../../../hooks/useBackgroundJob';
|
|
||||||
import {
|
import {
|
||||||
fetchAccountingConnectors,
|
fetchAccountingConnectors,
|
||||||
fetchAccountingConfig,
|
fetchAccountingConfig,
|
||||||
|
|
@ -44,20 +43,16 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [importDone, setImportDone] = useState(false);
|
const [importDone, setImportDone] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
||||||
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
|
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
|
||||||
const [importJobId, setImportJobId] = useState<string | null>(null);
|
|
||||||
const [clearingCache, setClearingCache] = useState(false);
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const mountedRef = useRef(true);
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importDone) return;
|
if (!importDone) return;
|
||||||
const importResetTimer = setTimeout(() => {
|
const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
||||||
setImporting(false);
|
|
||||||
setImportDone(false);
|
|
||||||
setImportJobId(null);
|
|
||||||
}, 5000);
|
|
||||||
return () => clearTimeout(importResetTimer);
|
return () => clearTimeout(importResetTimer);
|
||||||
}, [importDone]);
|
}, [importDone]);
|
||||||
|
|
||||||
|
|
@ -87,50 +82,21 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const _loadImportStatus = useCallback(async () => {
|
const _loadImportStatus = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
try {
|
try {
|
||||||
const data = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
||||||
setImportStatus(data);
|
if (mountedRef.current) setImportStatus(res.data);
|
||||||
} catch (err) {
|
} catch { /* ignore */ }
|
||||||
console.error('[Trustee] import-status fetch failed:', err);
|
|
||||||
}
|
|
||||||
}, [instanceId, request]);
|
}, [instanceId, request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingConfig?.configured) _loadImportStatus();
|
if (existingConfig?.configured) _loadImportStatus();
|
||||||
}, [existingConfig, _loadImportStatus]);
|
}, [existingConfig, _loadImportStatus]);
|
||||||
|
|
||||||
const { job: importJob } = useBackgroundJob(importJobId, {
|
|
||||||
enabled: !!importJobId,
|
|
||||||
pollMs: 2000,
|
|
||||||
onSuccess: (j) => {
|
|
||||||
const summary = (j.result || {}) as Record<string, any>;
|
|
||||||
setImportResult(summary);
|
|
||||||
const errs: string[] = Array.isArray(summary.errors) ? summary.errors : [];
|
|
||||||
if (errs.length) {
|
|
||||||
showError(t('Import teilweise fehlgeschlagen'), errs.join('; '));
|
|
||||||
} else {
|
|
||||||
showSuccess(t('Import abgeschlossen'),
|
|
||||||
t('{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.', {
|
|
||||||
konten: String(summary.accounts || 0),
|
|
||||||
buchungen: String(summary.journalEntries || 0),
|
|
||||||
kontakte: String(summary.contacts || 0),
|
|
||||||
salden: String(summary.accountBalances || 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_loadImportStatus();
|
|
||||||
void loadData();
|
|
||||||
setImportDone(true);
|
|
||||||
},
|
|
||||||
onError: (j) => {
|
|
||||||
showError(t('Import fehlgeschlagen'), j.errorMessage || t('Unbekannter Fehler'));
|
|
||||||
setImportDone(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
|
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
|
||||||
return connectors.find(c => c.connectorType === selectedType);
|
return connectors.find(c => c.connectorType === selectedType);
|
||||||
};
|
};
|
||||||
|
|
@ -225,26 +191,37 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
{existingConfig?.configured && (
|
{existingConfig?.configured && (
|
||||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||||
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||||
|
<> · {t('Letzter Sync:')} {existingConfig.lastSyncStatus}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
|
{existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
|
||||||
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<h4 style={{ marginTop: 0 }}>{t('Letzter Sync fehlgeschlagen')}</h4>
|
<h4 style={{ marginTop: 0 }}>{t('Syncstatus Fehlerprotokoll')}</h4>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{existingConfig.lastSyncAt != null && (
|
{existingConfig.lastSyncAt != null && (
|
||||||
<div style={{ fontSize: '0.9rem' }}>
|
<div style={{ fontSize: '0.9rem' }}>
|
||||||
<strong>{t('Zeitpunkt:')}</strong>{' '}
|
<strong>{t('Letzter Sync:')}</strong>{' '}
|
||||||
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||||||
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||||
|
<> · {t('Status:')} {existingConfig.lastSyncStatus}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{existingConfig.lastSyncStatus === 'error' && (existingConfig.lastSyncErrorMessage ?? '').trim() !== '' && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||||
|
{existingConfig.lastSyncErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{existingConfig.lastSyncStatus === 'error' && (!existingConfig.lastSyncErrorMessage || existingConfig.lastSyncErrorMessage.trim() === '') && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||||
|
Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
|
||||||
{(existingConfig.lastSyncErrorMessage ?? '').trim() !== ''
|
|
||||||
? existingConfig.lastSyncErrorMessage
|
|
||||||
: t('Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,35 +266,22 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedConnector.configFields.map(field => {
|
{selectedConnector.configFields.map(field => (
|
||||||
const datalistId = field.suggestions && field.suggestions.length > 0
|
<div key={field.key}>
|
||||||
? `dl-${selectedConnector.connectorType}-${field.key}`
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
||||||
: undefined;
|
{field.label || field.key}
|
||||||
return (
|
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
|
||||||
<div key={field.key}>
|
</label>
|
||||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
<input
|
||||||
{field.label || field.key}
|
type={field.secret ? 'password' : 'text'}
|
||||||
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
|
className={styles.folderSelect}
|
||||||
</label>
|
value={configValues[field.key] || ''}
|
||||||
<input
|
onChange={e => handleConfigChange(field.key, e.target.value)}
|
||||||
type={field.secret ? 'password' : 'text'}
|
placeholder={field.placeholder || ''}
|
||||||
className={styles.folderSelect}
|
autoComplete={field.secret ? 'new-password' : 'off'}
|
||||||
value={configValues[field.key] || ''}
|
/>
|
||||||
onChange={e => handleConfigChange(field.key, e.target.value)}
|
</div>
|
||||||
placeholder={field.placeholder || ''}
|
))}
|
||||||
autoComplete={field.secret ? 'new-password' : 'off'}
|
|
||||||
list={datalistId}
|
|
||||||
/>
|
|
||||||
{datalistId && (
|
|
||||||
<datalist id={datalistId}>
|
|
||||||
{field.suggestions!.map(s => (
|
|
||||||
<option key={s} value={s} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -376,55 +340,6 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined;
|
|
||||||
const winFrom = importStatus?.lastSyncDateFrom as string | undefined;
|
|
||||||
const winTo = importStatus?.lastSyncDateTo as string | undefined;
|
|
||||||
const counts = (importStatus?.lastSyncCounts || {}) as Record<string, number>;
|
|
||||||
const timeWindow = winFrom && winTo
|
|
||||||
? t('{from} bis {to}', { from: winFrom, to: winTo })
|
|
||||||
: winFrom
|
|
||||||
? t('ab {from}', { from: winFrom })
|
|
||||||
: winTo
|
|
||||||
? t('bis {to}', { to: winTo })
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
background: 'var(--surface-color, #f5f5f5)',
|
|
||||||
border: '1px solid var(--border-color, #e0e0e0)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '0.5rem 0.75rem',
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
}}>
|
|
||||||
{lastSyncAt ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
|
|
||||||
{timeWindow && (
|
|
||||||
<> {' '}· <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '0.2rem' }}>
|
|
||||||
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
|
||||||
konten: String(counts.accounts ?? 0),
|
|
||||||
buchungen: String(counts.journalEntries ?? 0),
|
|
||||||
zeilen: String(counts.journalLines ?? 0),
|
|
||||||
kontakte: String(counts.contacts ?? 0),
|
|
||||||
salden: String(counts.accountBalances ?? 0),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<em>{t('Noch kein Import durchgeführt. Wähle unten ein Zeitfenster und klicke auf «Daten jetzt einlesen».')}</em>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
||||||
|
|
@ -469,47 +384,35 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
setImportJobId(null);
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = {};
|
const body: Record<string, string> = {};
|
||||||
if (dateFrom) body.dateFrom = dateFrom;
|
if (dateFrom) body.dateFrom = dateFrom;
|
||||||
if (dateTo) body.dateTo = dateTo;
|
if (dateTo) body.dateTo = dateTo;
|
||||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||||
const newJobId: string | undefined = result?.jobId;
|
if (mountedRef.current) {
|
||||||
if (newJobId) {
|
setImportResult(res.data);
|
||||||
setImportJobId(newJobId);
|
if (res.data.errors?.length) {
|
||||||
} else {
|
showError(t('Import teilweise fehlgeschlagen'), res.data.errors.join('; '));
|
||||||
showError(t('Import fehlgeschlagen'), t('Kein jobId vom Server erhalten'));
|
} else {
|
||||||
setImportDone(true);
|
showSuccess(t('Import abgeschlossen'),
|
||||||
|
t('{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.', {
|
||||||
|
konten: String(res.data.accounts || 0),
|
||||||
|
buchungen: String(res.data.journalEntries || 0),
|
||||||
|
kontakte: String(res.data.contacts || 0),
|
||||||
|
salden: String(res.data.accountBalances || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
_loadImportStatus();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
||||||
|
} finally {
|
||||||
setImportDone(true);
|
setImportDone(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{importing
|
{importing ? t('Importiere…') : t('Daten jetzt einlesen')}
|
||||||
? (importJob?.progressMessage || t('Importiere…'))
|
|
||||||
: t('Daten jetzt einlesen')}
|
|
||||||
</button>
|
</button>
|
||||||
{importing && importJob && (
|
|
||||||
<div style={{ flex: '1 1 100%', marginTop: '0.5rem' }}>
|
|
||||||
<div style={{
|
|
||||||
width: '100%', height: '6px', background: 'var(--surface-color, #eee)',
|
|
||||||
borderRadius: '3px', overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: `${Math.max(2, importJob.progress || 0)}%`,
|
|
||||||
height: '100%',
|
|
||||||
background: 'var(--primary-color, #4CAF50)',
|
|
||||||
transition: 'width 0.4s ease',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
|
||||||
{importJob.progress}% {importJob.progressMessage ? `· ${importJob.progressMessage}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
disabled={clearingCache}
|
disabled={clearingCache}
|
||||||
|
|
@ -517,8 +420,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setClearingCache(true);
|
setClearingCache(true);
|
||||||
try {
|
try {
|
||||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
const res = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
||||||
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(result?.cleared ?? 0) }));
|
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(res.data?.cleared ?? 0) }));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ interface WorkspaceInputProps {
|
||||||
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
||||||
pendingAttachDsId?: string;
|
pendingAttachDsId?: string;
|
||||||
onPendingAttachDsConsumed?: () => void;
|
onPendingAttachDsConsumed?: () => void;
|
||||||
pendingAttachFdsId?: string;
|
|
||||||
onPendingAttachFdsConsumed?: () => void;
|
|
||||||
onPasteAsFile?: (file: File) => void;
|
onPasteAsFile?: (file: File) => void;
|
||||||
draftAppend?: string;
|
draftAppend?: string;
|
||||||
onDraftAppendConsumed?: () => void;
|
onDraftAppendConsumed?: () => void;
|
||||||
|
|
@ -71,8 +69,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
onDataSourceDrop,
|
onDataSourceDrop,
|
||||||
pendingAttachDsId,
|
pendingAttachDsId,
|
||||||
onPendingAttachDsConsumed,
|
onPendingAttachDsConsumed,
|
||||||
pendingAttachFdsId,
|
|
||||||
onPendingAttachFdsConsumed,
|
|
||||||
onPasteAsFile,
|
onPasteAsFile,
|
||||||
draftAppend,
|
draftAppend,
|
||||||
onDraftAppendConsumed,
|
onDraftAppendConsumed,
|
||||||
|
|
@ -109,15 +105,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
}
|
}
|
||||||
}, [pendingAttachDsId, onPendingAttachDsConsumed]);
|
}, [pendingAttachDsId, onPendingAttachDsConsumed]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingAttachFdsId) {
|
|
||||||
setAttachedFeatureDataSourceIds(prev =>
|
|
||||||
prev.includes(pendingAttachFdsId) ? prev : [...prev, pendingAttachFdsId],
|
|
||||||
);
|
|
||||||
onPendingAttachFdsConsumed?.();
|
|
||||||
}
|
|
||||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
|
|
||||||
|
|
||||||
const promptBeforeVoiceRef = useRef('');
|
const promptBeforeVoiceRef = useRef('');
|
||||||
const finalizedTextRef = useRef('');
|
const finalizedTextRef = useRef('');
|
||||||
const currentInterimRef = useRef('');
|
const currentInterimRef = useRef('');
|
||||||
|
|
|
||||||
|
|
@ -293,29 +293,16 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
|
|
||||||
|
|
||||||
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
|
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
|
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
|
||||||
featureInstanceId: params.featureInstanceId,
|
featureInstanceId: params.featureInstanceId,
|
||||||
featureCode: params.featureCode,
|
featureCode: params.featureCode,
|
||||||
tableName: params.tableName || '',
|
tableName: params.tableName || '',
|
||||||
objectKey: params.objectKey,
|
objectKey: params.objectKey,
|
||||||
label: params.label,
|
label: params.label,
|
||||||
});
|
});
|
||||||
// Backend response shape parity with /datasources — accept either a flat
|
|
||||||
// ``id`` or a wrapped ``featureDataSource.id`` so a future API tweak
|
|
||||||
// doesn't silently break the chip again.
|
|
||||||
const newId =
|
|
||||||
res.data?.id ||
|
|
||||||
res.data?.featureDataSource?.id ||
|
|
||||||
res.data?.dataSource?.id ||
|
|
||||||
'';
|
|
||||||
workspace.refreshFeatureDataSources();
|
workspace.refreshFeatureDataSources();
|
||||||
if (newId) {
|
|
||||||
setPendingAttachFdsId(newId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add feature source to chat:', err);
|
console.error('Failed to add feature source to chat:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -534,8 +521,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
onDataSourceDrop={_handleDataSourceDrop}
|
onDataSourceDrop={_handleDataSourceDrop}
|
||||||
pendingAttachDsId={pendingAttachDsId}
|
pendingAttachDsId={pendingAttachDsId}
|
||||||
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
|
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
|
||||||
pendingAttachFdsId={pendingAttachFdsId}
|
|
||||||
onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
|
|
||||||
onPasteAsFile={_uploadAndAttach}
|
onPasteAsFile={_uploadAndAttach}
|
||||||
draftAppend={draftAppend}
|
draftAppend={draftAppend}
|
||||||
onDraftAppendConsumed={() => setDraftAppend('')}
|
onDraftAppendConsumed={() => setDraftAppend('')}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue