diff --git a/src/App.tsx b/src/App.tsx index a677a0d..aac8210 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -186,6 +186,10 @@ function App() { } /> } /> + {/* Redmine Feature Views */} + } /> + } /> + {/* Catch-all für unbekannte Sub-Pfade */} } /> diff --git a/src/api.ts b/src/api.ts index 69ac956..8771019 100644 --- a/src/api.ts +++ b/src/api.ts @@ -92,6 +92,20 @@ api.interceptors.request.use( config.headers['Accept-Language'] = appLanguage; } + // Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can + // resolve "now" for AI agents and user-visible time strings without + // hardcoding a server-side default. Mirrors the Accept-Language pattern. + if (config.headers) { + try { + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (browserTimezone) { + config.headers['X-User-Timezone'] = browserTimezone; + } + } catch { + // Older browsers without Intl.DateTimeFormat: backend falls back to UTC + } + } + // Add multi-tenant context headers from URL (if not already set) // This ensures Feature-Instance roles are loaded for permission checks const context = getContextFromUrl(); diff --git a/src/api/redmineApi.ts b/src/api/redmineApi.ts new file mode 100644 index 0000000..39bc545 --- /dev/null +++ b/src/api/redmineApi.ts @@ -0,0 +1,398 @@ +/** + * Redmine API + * + * Frontend client for the Redmine feature backend. + * URL pattern: /api/redmine/{instanceId}/... + */ + +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py +// ============================================================================ + +export interface RedmineConfigDto { + id?: string; + featureInstanceId: string; + mandateId?: string | null; + baseUrl: string; + projectId: string; + hasApiKey: boolean; + rootTrackerName: string; + defaultPeriodValue?: Record | null; + schemaCacheTtlSeconds: number; + schemaCachedAt?: number | null; + isActive: boolean; + lastConnectedAt?: number | null; + lastSyncAt?: number | null; + lastFullSyncAt?: number | null; + lastSyncTicketCount?: number | null; + lastSyncErrorMessage?: string | null; +} + +export interface RedmineConfigUpdateRequest { + baseUrl?: string; + projectId?: string; + apiKey?: string; + rootTrackerName?: string; + defaultPeriodValue?: Record | null; + schemaCacheTtlSeconds?: number; + isActive?: boolean; +} + +export interface RedmineFieldChoice { + id: number; + name: string; + isClosed?: boolean | null; +} + +export interface RedmineCustomFieldSchema { + id: number; + name: string; + fieldFormat: string; + isRequired: boolean; + possibleValues: string[]; + multiple: boolean; + defaultValue?: string | null; +} + +export interface RedmineFieldSchema { + projectId: string; + projectName: string; + trackers: RedmineFieldChoice[]; + statuses: RedmineFieldChoice[]; + priorities: RedmineFieldChoice[]; + users: RedmineFieldChoice[]; + categories: RedmineFieldChoice[]; + customFields: RedmineCustomFieldSchema[]; + rootTrackerName: string; + rootTrackerId: number | null; +} + +export interface RedmineRelation { + id: number; + issueId: number; + issueToId: number; + relationType: string; + delay?: number | null; +} + +export interface RedmineCustomFieldValue { + id: number; + name: string; + value: any; +} + +export interface RedmineTicket { + id: number; + subject: string; + description: string; + trackerId?: number | null; + trackerName?: string | null; + statusId?: number | null; + statusName?: string | null; + isClosed: boolean; + priorityId?: number | null; + priorityName?: string | null; + assignedToId?: number | null; + assignedToName?: string | null; + authorId?: number | null; + authorName?: string | null; + parentId?: number | null; + fixedVersionId?: number | null; + fixedVersionName?: string | null; + categoryId?: number | null; + categoryName?: string | null; + createdOn?: string | null; + updatedOn?: string | null; + customFields: RedmineCustomFieldValue[]; + relations: RedmineRelation[]; +} + +export interface RedmineSyncResult { + instanceId: string; + full: boolean; + ticketsUpserted: number; + relationsUpserted: number; + durationMs: number; + lastSyncAt: number; + error?: string | null; +} + +export interface RedmineSyncStatus { + instanceId: string; + lastSyncAt?: number | null; + lastFullSyncAt?: number | null; + lastSyncDurationMs?: number | null; + lastSyncTicketCount?: number | null; + lastSyncErrorAt?: number | null; + lastSyncErrorMessage?: string | null; + mirroredTicketCount: number; + mirroredRelationCount: number; +} + +export interface RedmineConnectionTestResult { + ok: boolean; + reason?: string; + message?: string; + status?: number; + user?: { id: number; name: string }; + project?: { id: number; name: string }; +} + +export interface RedmineStats { + instanceId: string; + dateFrom?: string | null; + dateTo?: string | null; + bucket: string; + trackerIds: number[]; + categoryIds: number[]; + statusFilter: string; + kpis: { + total: number; + open: number; + closed: number; + closedInPeriod: number; + createdInPeriod: number; + orphans: number; + }; + statusByTracker: Array<{ + trackerId?: number | null; + trackerName: string; + countsByStatus: Record; + total: number; + }>; + throughput: Array<{ + bucketKey: string; + label: string; + created: number; + closed: number; + cumTotal: number; + cumOpen: number; + }>; + topAssignees: Array<{ + assignedToId?: number | null; + name: string; + open: number; + }>; + relationDistribution: Array<{ relationType: string; count: number }>; + backlogAging: Array<{ + bucketKey: string; + label: string; + minDays: number; + maxDays?: number | null; + count: number; + }>; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`; + +// ============================================================================ +// Config +// ============================================================================ + +export async function getRedmineConfigApi( + request: ApiRequestFunction, + instanceId: string, +): Promise { + return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' }); +} + +export async function updateRedmineConfigApi( + request: ApiRequestFunction, + instanceId: string, + body: RedmineConfigUpdateRequest, +): Promise { + return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body }); +} + +export async function deleteRedmineConfigApi( + request: ApiRequestFunction, + instanceId: string, +): Promise<{ deleted: boolean }> { + return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' }); +} + +export async function testRedmineConnectionApi( + request: ApiRequestFunction, + instanceId: string, +): Promise { + return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' }); +} + +// ============================================================================ +// Schema +// ============================================================================ + +export async function getRedmineSchemaApi( + request: ApiRequestFunction, + instanceId: string, + forceRefresh = false, +): Promise { + return await request({ + url: `${_baseUrl(instanceId)}/schema`, + method: 'get', + params: forceRefresh ? { forceRefresh: true } : undefined, + }); +} + +// ============================================================================ +// Sync +// ============================================================================ + +export async function runRedmineSyncApi( + request: ApiRequestFunction, + instanceId: string, + force = false, +): Promise { + return await request({ + url: `${_baseUrl(instanceId)}/sync`, + method: 'post', + params: force ? { force: true } : undefined, + }); +} + +export async function getRedmineSyncStatusApi( + request: ApiRequestFunction, + instanceId: string, +): Promise { + return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' }); +} + +// ============================================================================ +// Tickets +// ============================================================================ + +export interface ListTicketsParams { + trackerIds?: number[]; + status?: 'open' | 'closed' | '*'; + dateFrom?: string; + dateTo?: string; + assignedToId?: number; +} + +export async function listRedmineTicketsApi( + request: ApiRequestFunction, + instanceId: string, + params: ListTicketsParams = {}, +): Promise { + const queryParams: Record = {}; + if (params.status) queryParams.status = params.status; + if (params.dateFrom) queryParams.dateFrom = params.dateFrom; + if (params.dateTo) queryParams.dateTo = params.dateTo; + if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId; + if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds; + return await request({ + url: `${_baseUrl(instanceId)}/tickets`, + method: 'get', + params: queryParams, + }); +} + +export async function getRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + issueId: number, +): Promise { + return await request({ + url: `${_baseUrl(instanceId)}/tickets/${issueId}`, + method: 'get', + }); +} + +export interface RedmineTicketUpdateBody { + subject?: string; + description?: string; + trackerId?: number; + statusId?: number; + priorityId?: number; + assignedToId?: number; + parentIssueId?: number; + fixedVersionId?: number; + notes?: string; + customFields?: Record; +} + +export async function updateRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + issueId: number, + body: RedmineTicketUpdateBody, +): Promise { + return await request({ + url: `${_baseUrl(instanceId)}/tickets/${issueId}`, + method: 'put', + data: body, + }); +} + +export interface RedmineTicketCreateBody { + subject: string; + trackerId: number; + description?: string; + statusId?: number; + priorityId?: number; + assignedToId?: number; + parentIssueId?: number; + fixedVersionId?: number; + customFields?: Record; +} + +export async function createRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + body: RedmineTicketCreateBody, +): Promise { + return await request({ + url: `${_baseUrl(instanceId)}/tickets`, + method: 'post', + data: body, + }); +} + +export async function deleteRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + issueId: number, + fallbackStatusId?: number, +): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> { + return await request({ + url: `${_baseUrl(instanceId)}/tickets/${issueId}`, + method: 'delete', + params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined, + }); +} + +// ============================================================================ +// Stats +// ============================================================================ + +export interface RedmineStatsParams { + dateFrom?: string; + dateTo?: string; + bucket?: 'day' | 'week' | 'month'; + trackerIds?: number[]; + categoryIds?: number[]; + statusFilter?: '*' | 'open' | 'closed'; +} + +export async function getRedmineStatsApi( + request: ApiRequestFunction, + instanceId: string, + params: RedmineStatsParams = {}, +): Promise { + const queryParams: Record = {}; + if (params.dateFrom) queryParams.dateFrom = params.dateFrom; + if (params.dateTo) queryParams.dateTo = params.dateTo; + if (params.bucket) queryParams.bucket = params.bucket; + if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds; + if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds; + if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter; + return await request({ + url: `${_baseUrl(instanceId)}/stats`, + method: 'get', + params: queryParams, + }); +} diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 8c7a9e2..de467e5 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -26,7 +26,8 @@ export interface NodeTypeParameter { export interface PortField { name: string; type: string; - description: Record; + /** Plain string or per-language map from the API catalog. */ + description: string | Record; required: boolean; } diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 5a0d0d1..35e42b9 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -22,6 +22,7 @@ import { archiveVersion, createTemplateFromWorkflow, copyTemplate, + importWorkflowFromFile, type NodeType, type NodeTypeCategory, type Automation2Graph, @@ -122,6 +123,7 @@ export const Automation2FlowEditor: React.FC = ({ in instanceId, mandateId: mandateId || '', featureInstanceId: instanceId, + surface: 'graphEditor', }), [instanceId, mandateId]); const [versions, setVersions] = useState([]); const [currentVersionId, setCurrentVersionId] = useState(null); @@ -722,6 +724,10 @@ export const Automation2FlowEditor: React.FC = ({ in hideTabs={['chats']} onFileSelect={onFileSelect} onSourcesChanged={onSourcesChanged} + onWorkflowImportedFromFile={async (workflowId) => { + await loadWorkflows(); + handleWorkflowSelect(workflowId); + }} /> )} @@ -771,6 +777,21 @@ export const Automation2FlowEditor: React.FC = ({ in getCategoryIcon={getCategoryIcon} onSelectionChange={setSelectedNode} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} + onExternalDrop={async (mime, payload) => { + if (mime !== 'application/json+workflow' || !instanceId) return false; + const p = payload as { files?: Array<{ id: string }> } | undefined; + const fileId = p?.files?.[0]?.id; + if (!fileId) return false; + try { + const result = await importWorkflowFromFile(request, instanceId, { fileId }); + await loadWorkflows(); + if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); + return true; + } catch (e) { + console.error(`${LOG} workflow drop import failed`, e); + return false; + } + }} /> {configurableSelected && selectedNode && ( diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index ae236ea..65c6743 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -143,6 +143,11 @@ interface FlowCanvasProps { getCategoryIcon: (category: string) => React.ReactNode; onSelectionChange?: (node: CanvasNode | null) => void; highlightedNodeIds?: Record; + /** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt + * (z. B. ``application/json+workflow`` aus der UDB-FilesTab), + * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. + * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ + onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; } const HIGHLIGHT_COLORS: Record = { @@ -162,6 +167,7 @@ export const FlowCanvas: React.FC = ({ nodes, getCategoryIcon, onSelectionChange, highlightedNodeIds, + onExternalDrop, }) => { const { t } = useLanguage(); const containerRef = useRef(null); @@ -256,8 +262,31 @@ export const FlowCanvas: React.FC = ({ nodes, }, [connections]); const handleDrop = useCallback( - (e: React.DragEvent) => { + async (e: React.DragEvent) => { e.preventDefault(); + // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) + if (onExternalDrop) { + const reservedMimes = new Set([ + 'application/json', + 'application/tree-items', + 'application/file-id', + 'application/file-ids', + 'application/folder-id', + ]); + for (const mime of Array.from(e.dataTransfer.types)) { + if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue; + const raw = e.dataTransfer.getData(mime); + if (!raw) continue; + try { + const payload = JSON.parse(raw); + const handled = await onExternalDrop(mime, payload); + if (handled) return; + } catch { + // andere Drag-Source → ignorieren, Standard-Pfad versuchen + } + } + } + // 2) Standard: Node-Type aus der NodeSidebar const raw = e.dataTransfer.getData('application/json'); if (!raw || !containerRef.current) return; try { @@ -269,7 +298,7 @@ export const FlowCanvas: React.FC = ({ nodes, onDropNodeType(type, Math.max(0, x), Math.max(0, y)); } catch (_) {} }, - [onDropNodeType, panOffset, zoom] + [onDropNodeType, onExternalDrop, panOffset, zoom] ); const handleHandleMouseDown = useCallback( diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index 7147502..8265257 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -5,10 +5,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { CanvasNode } from './FlowCanvas'; -import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi'; +import type { NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowApi'; import { getLabel } from '../nodes/shared/utils'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; +import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -72,12 +73,21 @@ export const NodeConfigPanel: React.FC = ({ node, [onParametersChange] ); + const dataFlow = useAutomation2DataFlow(); + const portTypeCatalog: Record = (dataFlow?.portTypeCatalog as Record | undefined) ?? {}; + if (!node || !nodeType) return null; const isTrigger = node.type.startsWith('trigger.'); const showNameField = onNodeUpdate && !isTrigger; const parameters = nodeType.parameters || []; + const inputPortDefs = nodeType.inputPorts ?? {}; + const outputPortDefs = nodeType.outputPorts ?? {}; + const inputPortEntries = Object.entries(inputPortDefs); + const outputPortEntries = Object.entries(outputPortDefs); + const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0; + return (
{showNameField && ( @@ -101,6 +111,47 @@ export const NodeConfigPanel: React.FC = ({ node, {getLabel(nodeType.description, language)}

)} + {hasPortInfo && ( +
+ + {t('Datenfluss (Eingabe / Ausgabe)')} + + {inputPortEntries.length > 0 && ( +
+
+ {'\u2B07'} {t('Eingabe')} +
+ {inputPortEntries.map(([idx, def]) => ( + <_PortFieldList + key={`in-${idx}`} + portIndex={Number(idx)} + schemaNames={def?.accepts ?? []} + catalog={portTypeCatalog} + emptyLabel={t('keine Felder')} + language={language} + /> + ))} +
+ )} + {outputPortEntries.length > 0 && ( +
+
+ {'\u2B06'} {t('Ausgabe')} +
+ {outputPortEntries.map(([idx, def]) => ( + <_PortFieldList + key={`out-${idx}`} + portIndex={Number(idx)} + schemaNames={def?.schema ? [def.schema] : []} + catalog={portTypeCatalog} + emptyLabel={t('keine Felder')} + language={language} + /> + ))} +
+ )} +
+ )} {parameters.map((param: NodeTypeParameter) => { const frontendType = param.frontendType || 'text'; const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; @@ -120,3 +171,56 @@ export const NodeConfigPanel: React.FC = ({ node,
); }; + +interface _PortFieldListProps { + portIndex: number; + schemaNames: string[]; + catalog: Record; + emptyLabel: string; + language: string; +} + +const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => { + if (!schemaNames.length) return null; + return ( +
+
+ {`#${portIndex} `}{schemaNames.join(' | ')} +
+ {schemaNames.map((name) => { + const schema = catalog[name]; + const fields = schema?.fields ?? []; + if (name === 'Transit') { + return ( +
+ {'\u00B7 Transit (durchgereichte Daten)'} +
+ ); + } + if (!fields.length) { + return ( +
+ {`\u00B7 ${emptyLabel}`} +
+ ); + } + return ( +
    + {fields.map((f) => ( +
  • + {f.name} + {`: ${f.type}`} + {!f.required && {' (optional)'}} + {f.description && ( +
    + {getLabel(f.description, language)} +
    + )} +
  • + ))} +
+ ); + })} +
+ ); +}; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index d0db9f7..5d929f5 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -42,6 +42,16 @@ opacity: 0.5; } +/* Visueller Hint für Custom-Drag-Sources (z. B. Workflow-Files): + * pulst dezent beim Hover, um zu signalisieren "hier kann ich woanders hingezogen werden". */ +@keyframes _customDragPulse { + 0%, 100% { box-shadow: inset 0 0 0 0 transparent; } + 50% { box-shadow: inset 2px 0 0 0 var(--color-primary, #F25843); } +} +.treeNode.hasCustomDrag:hover { + animation: _customDragPulse 1.6s ease-in-out infinite; +} + .chevron { width: 12px; height: 12px; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 289b404..52b1096 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -17,6 +17,18 @@ import { usePrompt, type PromptOptions } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + type UdbSurface, + resolveActionLabel, +} from './actions/types'; +import { useFileActions, runAction, type ResolvedActions } from './actions/registry'; +import { useViewMode } from './actions/useViewMode'; +import { usePointerLongPress } from './actions/usePointerLongPress'; +import { FileActionContextMenu } from './actions/FileActionContextMenu'; +import { FileActionBottomSheet } from './actions/FileActionBottomSheet'; /* ── Public types ──────────────────────────────────────────────────────── */ @@ -80,6 +92,11 @@ export interface FolderTreeProps { onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Optionale Custom-Aktionen (Plugin-Slot, siehe `actions/types.ts`). + * Built-in Aktionen funktionieren auch ohne dieses Prop unverändert. */ + customActions?: FileAction[]; + /** Aufruf-Surface (z. B. ``'graphEditor'``) — wird in Predicates der Custom-Actions gespiegelt. */ + udbContext?: UdbSurface; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -148,6 +165,28 @@ function _computeFlatList( return result; } +function _matchesShortcut(e: KeyboardEvent, shortcut: string): boolean { + const parts = shortcut.toLowerCase().split('+').map(p => p.trim()); + const wantMod = parts.includes('mod'); + const wantShift = parts.includes('shift'); + const wantAlt = parts.includes('alt'); + const wantCtrl = parts.includes('ctrl') && !wantMod; + const key = parts.find(p => !['mod', 'shift', 'alt', 'ctrl'].includes(p)); + if (!key) return false; + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + const modOk = wantMod ? (isMac ? e.metaKey : e.ctrlKey) : true; + const ctrlOk = wantCtrl ? e.ctrlKey : (wantMod ? true : !e.ctrlKey); + const shiftOk = wantShift === e.shiftKey; + const altOk = wantAlt === e.altKey; + const keyOk = e.key.toLowerCase() === key || e.code.toLowerCase() === `key${key}`; + return modOk && ctrlOk && shiftOk && altOk && keyOk; +} + +function _windowConfirm(_title: string, body: string): boolean { + if (typeof window === 'undefined') return true; + return window.confirm(body); +} + function _fileIcon(mime?: string): string { if (!mime) return '\uD83D\uDCC4'; if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; @@ -186,6 +225,21 @@ interface SelectionCtx { onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Action-System Pipeline. Wenn vorhanden, rendert das FileItem zusätzlich + * Right-Click-Menu, Long-Press-Sheet und Custom-Inline-Icons. */ + actions?: { + actionCtx: FileActionContext; + /** Liefert pro Target die nach Kanal sortierten/gefilterten Aktionen. */ + resolveFor: (target: FileActionTarget) => ResolvedActions; + /** Öffnet das Right-Click-Menu am angegebenen Viewport-Punkt. */ + openMenu: (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => void; + /** Öffnet das Bottom-Sheet (Mobile Long-Press). */ + openSheet: (target: FileActionTarget, title?: string) => void; + /** Custom-Drag-MIME-Types, die zusätzlich ans dataTransfer gehängt werden. */ + applyDragPayload: (e: React.DragEvent, target: FileActionTarget) => void; + }; + /** Inline-Rename-Trigger des FolderTree (für die Built-in `core.rename`-Action). */ + registerInlineRename: (fileId: string, fn: () => void) => void; } /* ── Stable trio (chat | scope | neutralize) ────────────────────────────── @@ -276,6 +330,37 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { const isSelected = sel.selectedItemIds.has(file.id); const multiSelected = sel.selectedItemIds.size > 1; + const _beginRename = useCallback(() => { + setRenameValue(file.fileName); + setRenaming(true); + }, [file.fileName]); + useEffect(() => { + sel.registerInlineRename(file.id, _beginRename); + }, [file.id, _beginRename, sel]); + + const _buildActionTarget = useCallback((): FileActionTarget => { + return { files: [file], folders: [] }; + }, [file]); + + const _onContextMenu = useCallback((e: React.MouseEvent) => { + if (!sel.actions) return; + e.preventDefault(); + e.stopPropagation(); + sel.actions.openMenu({ x: e.clientX, y: e.clientY }, _buildActionTarget(), file.fileName); + }, [sel.actions, _buildActionTarget, file.fileName]); + + const _longPressHandlers = usePointerLongPress( + useCallback(() => { + if (!sel.actions) return; + sel.actions.openSheet(_buildActionTarget(), file.fileName); + }, [sel.actions, _buildActionTarget, file.fileName]), + ); + + const inlineCustomActions = useMemo(() => { + if (!sel.actions) return []; + return sel.actions.resolveFor(_buildActionTarget()).inline; + }, [sel.actions, _buildActionTarget]); + const _handleRename = useCallback(async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { @@ -310,11 +395,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { styles.fileNode, isSelected ? styles.multiSelected : '', dragging ? styles.dragging : '', + inlineCustomActions.length > 0 ? styles.hasCustomDrag : '', ].filter(Boolean).join(' ')} onClick={(e) => sel.onItemClick(file.id, 'file', e)} + onContextMenu={_onContextMenu} + {..._longPressHandlers} draggable onDragStart={(e) => { sel.onItemDragStart(e, file.id, 'file', file.fileName); + if (sel.actions) sel.actions.applyDragPayload(e, _buildActionTarget()); setDragging(true); }} onDragEnd={() => setDragging(false)} @@ -345,8 +434,27 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { )} + {!multiSelected && inlineCustomActions.slice(0, 3).map((a) => { + const Icon = a.icon; + return ( + + ); + })} {sel.onRenameFile && !multiSelected && ( - )} @@ -674,8 +782,25 @@ export default function FolderTree({ onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, + customActions, udbContext, }: FolderTreeProps) { const { t } = useLanguage(); + const viewMode = useViewMode(); + const containerRef = useRef(null); + const inlineRenameRegistryRef = useRef void>>(new Map()); + const _registerInlineRename = useCallback((fileId: string, fn: () => void) => { + inlineRenameRegistryRef.current.set(fileId, fn); + }, []); + + const [menuState, setMenuState] = useState<{ + anchor: { x: number; y: number }; + target: FileActionTarget; + title?: string; + } | null>(null); + const [sheetState, setSheetState] = useState<{ + target: FileActionTarget; + title?: string; + } | null>(null); const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); @@ -799,6 +924,60 @@ export default function FolderTree({ return ids; }, [tree]); + const _beginInlineRename = useCallback((fileId: string) => { + const fn = inlineRenameRegistryRef.current.get(fileId); + if (fn) fn(); + }, []); + + const actionCtx: FileActionContext = useMemo(() => ({ + viewMode, + udbContext, + }), [viewMode, udbContext]); + + const fileActions = useFileActions(actionCtx, customActions, { + onRenameFile, + onDeleteFile, + onDeleteFiles, + onDeleteFolders, + onSendToChat, + t, + beginInlineRename: _beginInlineRename, + }); + + const _openMenu = useCallback( + (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => { + setMenuState({ anchor, target, title }); + }, + [], + ); + const _openSheet = useCallback((target: FileActionTarget, title?: string) => { + setSheetState({ target, title }); + }, []); + const _closeMenu = useCallback(() => setMenuState(null), []); + const _closeSheet = useCallback(() => setSheetState(null), []); + + const _applyDragPayload = useCallback( + (e: React.DragEvent, target: FileActionTarget) => { + const drag = fileActions.forTarget(target).drag; + for (const a of drag) { + if (!a.dragMime) continue; + try { + e.dataTransfer.setData( + a.dragMime, + JSON.stringify({ + actionId: a.id, + files: target.files.map((f) => ({ id: f.id, name: f.fileName })), + folders: target.folders.map((f) => ({ id: f.id, name: f.name })), + }), + ); + } catch { + // dataTransfer.setData kann in seltenen Fällen werfen (read-only) — nicht fatal. + } + } + }, + [fileActions], + ); + const sel: SelectionCtx = useMemo(() => { const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); @@ -815,8 +994,55 @@ export default function FolderTree({ onScopeChange, onNeutralizeToggle, onSendToChat, + actions: { + actionCtx, + resolveFor: fileActions.forTarget, + openMenu: _openMenu, + openSheet: _openSheet, + applyDragPayload: _applyDragPayload, + }, + registerInlineRename: _registerInlineRename, }; - }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]); + }, [ + selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, + onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, + onScopeChange, onNeutralizeToggle, onSendToChat, + actionCtx, fileActions.forTarget, _openMenu, _openSheet, _applyDragPayload, _registerInlineRename, + ]); + + // Tastenkürzel — nur dispatchen wenn FolderTree den Fokus enthält und es nicht aus + // einem Input/Editable-Element kommt (sonst kollidiert F2/Delete mit Inline-Rename). + useEffect(() => { + const _onKeyDown = (e: KeyboardEvent) => { + const root = containerRef.current; + if (!root) return; + const active = document.activeElement as HTMLElement | null; + if (!active || !root.contains(active)) return; + const tag = active.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || active.isContentEditable) return; + + const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); + const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); + if (selFileIds.length + selFolderIds.length === 0) return; + + const allFiles = (files ?? []).filter(f => selFileIds.includes(f.id)); + // Folder-Ziele für Shortcuts kommen aktuell nicht vor — Built-in `core.delete` + // operiert auf der Selection. Für diese Iteration genügt das. + const target: FileActionTarget = { files: allFiles, folders: [] }; + const resolved = fileActions.forTarget(target).shortcut; + + for (const a of resolved) { + if (!a.shortcut) continue; + if (_matchesShortcut(e, a.shortcut)) { + e.preventDefault(); + void runAction(a, target, actionCtx, _windowConfirm); + return; + } + } + }; + window.addEventListener('keydown', _onKeyDown); + return () => window.removeEventListener('keydown', _onKeyDown); + }, [selectedItemIds, allFileIds, allFolderIds, files, fileActions, actionCtx]); // Root drop handler: items dropped on the empty area go to root (null) const _handleRootDrop = useCallback(async (e: React.DragEvent) => { @@ -853,8 +1079,17 @@ export default function FolderTree({ onSelect(null); }, [_setSelection, onSelect]); + const menuActions = useMemo( + () => (menuState ? fileActions.forTarget(menuState.target).menu : []), + [menuState, fileActions], + ); + const sheetActions = useMemo( + () => (sheetState ? fileActions.forTarget(sheetState.target).sheet : []), + [sheetState, fileActions], + ); + return ( -
+
+ {menuState && ( + + )} +
); } diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.module.css b/src/components/FolderTree/actions/FileActionBottomSheet.module.css new file mode 100644 index 0000000..49f7d08 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionBottomSheet.module.css @@ -0,0 +1,103 @@ +/* Bottom-Sheet für FolderTree Long-Press (Mobile). */ + +@keyframes _slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes _fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.45); + animation: _fadeIn 0.15s ease-out; +} + +.sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1001; + background: var(--color-bg-elevated, #ffffff); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.18); + padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); + max-height: 80vh; + overflow-y: auto; + animation: _slideUp 0.18s ease-out; +} + +.handle { + width: 36px; + height: 4px; + border-radius: 2px; + background: var(--color-border, rgba(0, 0, 0, 0.18)); + margin: 4px auto 8px; +} + +.title { + padding: 4px 16px 12px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary, #222); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.06)); + margin-bottom: 4px; +} + +.item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 48px; + padding: 12px 16px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-primary, #222); + text-align: left; + font-size: 15px; + line-height: 1.2; +} + +.item:active { + background: var(--color-bg-hover, rgba(25, 118, 210, 0.10)); +} + +.item.danger { + color: var(--color-error, #d32f2f); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + font-size: 17px; + flex-shrink: 0; +} + +.label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--color-text-secondary, #999); + font-style: italic; +} diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.tsx b/src/components/FolderTree/actions/FileActionBottomSheet.tsx new file mode 100644 index 0000000..efe5f62 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionBottomSheet.tsx @@ -0,0 +1,83 @@ +/** + * FileActionBottomSheet — Long-Press Action-Sheet für Mobile. + * + * Slide-Up von unten, 48 px Touch-Targets, ESC + Backdrop schließen. + */ + +import React, { useEffect } from 'react'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; +import { runAction } from './registry'; +import styles from './FileActionBottomSheet.module.css'; + +interface Props { + open: boolean; + actions: FileAction[]; + target: FileActionTarget; + ctx: FileActionContext; + onClose: () => void; + title?: string; + confirm?: (title: string, body: string) => boolean | Promise; +} + +export const FileActionBottomSheet: React.FC = ({ + open, + actions, + target, + ctx, + onClose, + title, + confirm, +}) => { + useEffect(() => { + if (!open) return; + const _onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', _onKey); + return () => window.removeEventListener('keydown', _onKey); + }, [open, onClose]); + + if (!open) return null; + + const _handleClick = async (action: FileAction) => { + onClose(); + await runAction(action, target, ctx, confirm); + }; + + return ( + <> +
+
+ + + ); +}; diff --git a/src/components/FolderTree/actions/FileActionContextMenu.module.css b/src/components/FolderTree/actions/FileActionContextMenu.module.css new file mode 100644 index 0000000..3307011 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionContextMenu.module.css @@ -0,0 +1,103 @@ +/* Context-Menu für FolderTree (Right-Click). + * Floating, ARIA-menu, Backdrop-Click + ESC schließen. */ + +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: transparent; +} + +.menu { + position: fixed; + z-index: 1001; + min-width: 200px; + max-width: 320px; + background: var(--color-bg-elevated, #ffffff); + border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 4px 0; + font-size: 13px; + color: var(--color-text-primary, #222); + user-select: none; +} + +.header { + padding: 4px 12px 6px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.divider { + height: 1px; + margin: 4px 0; + background: var(--color-border, rgba(0, 0, 0, 0.08)); +} + +.item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 12px; + border: none; + background: none; + cursor: pointer; + color: inherit; + text-align: left; + font: inherit; + line-height: 1.2; +} + +.item:hover, +.item:focus-visible { + background: var(--color-bg-hover, rgba(25, 118, 210, 0.08)); + outline: none; +} + +.item.danger { + color: var(--color-error, #d32f2f); +} + +.item.danger:hover, +.item.danger:focus-visible { + background: var(--color-bg-error, rgba(211, 47, 47, 0.08)); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + font-size: 13px; + flex-shrink: 0; +} + +.label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shortcut { + flex-shrink: 0; + font-size: 11px; + color: var(--color-text-secondary, #999); + font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace; + padding-left: 12px; +} + +.empty { + padding: 8px 12px; + font-size: 12px; + color: var(--color-text-secondary, #999); + font-style: italic; +} diff --git a/src/components/FolderTree/actions/FileActionContextMenu.tsx b/src/components/FolderTree/actions/FileActionContextMenu.tsx new file mode 100644 index 0000000..64e4a1c --- /dev/null +++ b/src/components/FolderTree/actions/FileActionContextMenu.tsx @@ -0,0 +1,146 @@ +/** + * FileActionContextMenu — Floating Right-Click-Menu für FolderTree. + * + * Wird vom FolderTree gemountet wenn `onContextMenu` auf einer Zeile feuert. + * Schließt sich bei Backdrop-Klick, ESC oder nach Aktion-Dispatch. + */ + +import React, { useEffect, useRef } from 'react'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; +import { runAction } from './registry'; +import styles from './FileActionContextMenu.module.css'; + +interface Props { + /** Sichtbar/positioniert. ``null`` → nicht gemountet. */ + anchor: { x: number; y: number } | null; + actions: FileAction[]; + target: FileActionTarget; + ctx: FileActionContext; + /** Wird aufgerufen sobald das Menü schließen soll (Backdrop, ESC, nach Action). */ + onClose: () => void; + /** Optional: Header-Label (z. B. Dateiname). */ + title?: string; + /** Optionaler Confirm-Provider (z. B. browser native ``window.confirm``). */ + confirm?: (title: string, body: string) => boolean | Promise; +} + +export const FileActionContextMenu: React.FC = ({ + anchor, + actions, + target, + ctx, + onClose, + title, + confirm, +}) => { + const menuRef = useRef(null); + + useEffect(() => { + if (!anchor) return; + const _onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', _onKey); + return () => window.removeEventListener('keydown', _onKey); + }, [anchor, onClose]); + + useEffect(() => { + if (!anchor || !menuRef.current) return; + menuRef.current.focus(); + }, [anchor]); + + if (!anchor) return null; + + const adjusted = _adjustToViewport(anchor, menuRef.current); + + const _handleClick = async (action: FileAction) => { + onClose(); + await runAction(action, target, ctx, confirm); + }; + + return ( + <> +
{ + e.preventDefault(); + onClose(); + }} + /> +
+ {title &&
{title}
} + {actions.length === 0 ? ( +
+ ) : ( + actions.map((a, idx) => { + const Icon = a.icon; + const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item; + return ( + + {idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && ( +
+ )} + + + ); + }) + )} +
+ + ); +}; + +function _adjustToViewport( + anchor: { x: number; y: number }, + menu: HTMLDivElement | null, +): { x: number; y: number } { + if (!menu) return anchor; + const rect = menu.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const margin = 4; + let x = anchor.x; + let y = anchor.y; + if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin); + if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin); + return { x, y }; +} + +function _formatShortcut(s: string): string { + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + return s + .split('+') + .map((part) => { + const k = part.trim().toLowerCase(); + if (k === 'mod') return isMac ? '\u2318' : 'Ctrl'; + if (k === 'shift') return isMac ? '\u21E7' : 'Shift'; + if (k === 'alt') return isMac ? '\u2325' : 'Alt'; + if (k === 'ctrl') return 'Ctrl'; + return k.length === 1 ? k.toUpperCase() : part; + }) + .join(isMac ? '' : '+'); +} diff --git a/src/components/FolderTree/actions/registry.ts b/src/components/FolderTree/actions/registry.ts new file mode 100644 index 0000000..c2b83bb --- /dev/null +++ b/src/components/FolderTree/actions/registry.ts @@ -0,0 +1,218 @@ +/** + * useFileActions — zentraler Registry-Hook für FolderTree Aktionen. + * + * Liefert eine einheitliche, gefilterte und sortierte Aktion-Liste, die das + * `FolderTree`-Inneres an Right-Click-Menü, Long-Press-Sheet, Tastenkürzel und + * Drag-Source dispatched. Built-in-Aktionen (Rename, Delete, Send-to-Chat) + * werden aus den vorhandenen FolderTree-Callbacks abgeleitet, damit existierende + * Aufrufer nichts ändern müssen. + */ + +import { useMemo } from 'react'; +import { FaPen, FaTrash, FaCommentDots } from 'react-icons/fa'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; + +/** Callback-Bündel mit den heutigen `FolderTreeProps`-Handlern. + * Optional, weil nicht jeder Aufrufer alle Built-ins anbietet. */ +export interface BuiltinCallbacks { + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; + onSendToChat?: ( + items: Array<{ id: string; type: 'file' | 'folder'; name: string }>, + ) => void; + /** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */ + t?: (key: string, vars?: Record) => string; + /** Inline-Rename-Trigger (Eingabefeld in der Zeile). Wird vom FolderTree + * intern bereitgestellt — nicht vom Aufrufer. */ + beginInlineRename?: (fileId: string) => void; +} + +/** Sortierte, gefilterte Aktionsliste pro Kanal. */ +export interface ResolvedActions { + inline: FileAction[]; + menu: FileAction[]; + sheet: FileAction[]; + shortcut: FileAction[]; + drag: FileAction[]; +} + +const _IDENTITY: NonNullable = (s) => s; + +/** Built-in-Definitionen, die aus den heute hartcodierten Callbacks abgeleitet werden. + * Diese erscheinen NUR in den neuen Kanälen (Menu, Sheet, Shortcut) — die Inline-Icons + * werden weiterhin direkt vom FolderTree-Renderer gezeichnet, damit die bestehende + * "Stable-Trio + dynamische Aktionen"-Logik unangetastet bleibt. */ +function _buildBuiltins(cb: BuiltinCallbacks): FileAction[] { + const t: NonNullable = cb.t ?? _IDENTITY; + const list: FileAction[] = []; + + if (cb.onSendToChat) { + list.push({ + id: 'core.sendToChat', + label: t('In Chat senden'), + icon: FaCommentDots, + scope: 'multi', + channels: ['menu', 'sheet'], + sortOrder: 100, + handler: ({ files, folders }) => { + const items = [ + ...files.map((f) => ({ id: f.id, type: 'file' as const, name: f.fileName })), + ...folders.map((f) => ({ id: f.id, type: 'folder' as const, name: f.name })), + ]; + if (items.length > 0) cb.onSendToChat!(items); + }, + }); + } + + if (cb.onRenameFile && cb.beginInlineRename) { + list.push({ + id: 'core.rename', + label: t('Umbenennen'), + icon: FaPen, + scope: 'file', + channels: ['menu', 'sheet', 'shortcut'], + shortcut: 'F2', + sortOrder: 110, + predicate: ({ files, folders }) => files.length === 1 && folders.length === 0, + handler: ({ files }) => { + if (files.length === 1) cb.beginInlineRename!(files[0].id); + }, + }); + } + + if (cb.onDeleteFile || cb.onDeleteFiles || cb.onDeleteFolders) { + list.push({ + id: 'core.delete', + label: ({ files, folders }) => + files.length + folders.length > 1 + ? t('{count} Einträge löschen', { count: String(files.length + folders.length) }) + : t('Löschen'), + icon: FaTrash, + scope: 'multi', + channels: ['menu', 'sheet', 'shortcut'], + shortcut: 'Delete', + danger: true, + sortOrder: 200, + predicate: ({ files, folders }) => files.length > 0 || folders.length > 0, + confirm: { + title: t('Löschen bestätigen'), + body: ({ files, folders }) => + files.length + folders.length > 1 + ? t('{count} Einträge löschen?', { + count: String(files.length + folders.length), + }) + : t('Diesen Eintrag löschen?'), + }, + handler: async ({ files, folders }) => { + if (folders.length > 0 && cb.onDeleteFolders) { + await cb.onDeleteFolders(folders.map((f) => f.id)); + } + if (files.length > 1 && cb.onDeleteFiles) { + await cb.onDeleteFiles(files.map((f) => f.id)); + } else if (files.length === 1) { + if (cb.onDeleteFile) await cb.onDeleteFile(files[0].id); + else if (cb.onDeleteFiles) await cb.onDeleteFiles([files[0].id]); + } + }, + }); + } + + return list; +} + +/** + * Zentrale Registry-Hook. + * + * @param ctx Aktueller Aufruf-Kontext (View-Mode, Mandant, …). + * @param customs Vom Aufrufer registrierte Custom-Actions (Plugin-Slot). + * @param builtins Callback-Bündel der Built-in-Aktionen (aus FolderTreeProps abgeleitet). + * + * Die Rückgabe ist memoized und pro Kanal vorgefiltert; ein `Predicate`-Check + * pro Target erfolgt zusätzlich erst beim Render der jeweiligen Zeile/Sheet. + */ +export function useFileActions( + ctx: FileActionContext, + customs: FileAction[] | undefined, + builtins: BuiltinCallbacks, +): { + /** Alle Aktionen (gemerged + sortiert), unfiltered nach Predicate. */ + all: FileAction[]; + /** Liefert die für ein konkretes Target sichtbaren Aktionen, gruppiert nach Kanal. */ + forTarget: (target: FileActionTarget) => ResolvedActions; +} { + const all = useMemo(() => { + const merged = [..._buildBuiltins(builtins), ...(customs ?? [])]; + merged.sort( + (a, b) => + (a.sortOrder ?? 1000) - (b.sortOrder ?? 1000) || a.id.localeCompare(b.id), + ); + return merged; + // We intentionally depend on each callback identity so re-renders pick up + // updated handlers (closures over instanceId etc.). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + customs, + builtins.onRenameFile, + builtins.onDeleteFile, + builtins.onDeleteFiles, + builtins.onDeleteFolders, + builtins.onSendToChat, + builtins.beginInlineRename, + builtins.t, + ]); + + const forTarget = useMemo(() => { + return (target: FileActionTarget): ResolvedActions => { + const _matches = (a: FileAction): boolean => { + if (a.scope === 'file' && (target.files.length !== 1 || target.folders.length > 0)) + return false; + if (a.scope === 'folder' && (target.folders.length !== 1 || target.files.length > 0)) + return false; + if (a.scope === 'multi' && target.files.length + target.folders.length === 0) + return false; + if (a.predicate && !a.predicate(target, ctx)) return false; + return true; + }; + + const matched = all.filter(_matches); + return { + inline: matched.filter((a) => a.channels.includes('inline')), + menu: matched.filter((a) => a.channels.includes('menu')), + sheet: matched.filter((a) => a.channels.includes('sheet')), + shortcut: matched.filter((a) => a.channels.includes('shortcut')), + drag: matched.filter((a) => a.channels.includes('drop')), + }; + }; + }, [all, ctx]); + + return { all, forTarget }; +} + +/** Hilfs-Dispatcher: führt Confirm + Handler aus, fängt Fehler ab und loggt sie. + * Der eigentliche Confirm-Dialog wird vom Renderer (Context-Menu/Sheet) bereitgestellt + * — dieser Helper bleibt UI-frei und ist von außerhalb React aufrufbar. */ +export async function runAction( + action: FileAction, + target: FileActionTarget, + ctx: FileActionContext, + confirmFn?: (label: string, body: string) => boolean | Promise, +): Promise { + if (action.confirm && confirmFn) { + const ok = await confirmFn(action.confirm.title, action.confirm.body(target)); + if (!ok) return; + } + try { + await action.handler(target, ctx); + } catch (err) { + console.error(`[FileAction] ${action.id} failed`, err); + } +} + +export { resolveActionLabel }; diff --git a/src/components/FolderTree/actions/types.ts b/src/components/FolderTree/actions/types.ts new file mode 100644 index 0000000..d2b75db --- /dev/null +++ b/src/components/FolderTree/actions/types.ts @@ -0,0 +1,87 @@ +/** + * Action-Modell für FolderTree (UDB Action System). + * + * Eine `FileAction` ist die kanonische Beschreibung einer Aktion, die der User + * auf eine Datei oder einen Ordner anwenden kann. Dieselbe Definition rendert + * sich automatisch in mehreren Kanälen: + * - inline → Icon-Button am rechten Zeilenrand + * - menu → Eintrag im Right-Click-Context-Menu + * - sheet → Eintrag im Long-Press Bottom-Sheet (Mobile) + * - shortcut → Tastenkürzel solange FolderTree Fokus hat + * - drop → Drag-Source: hängt eine zusätzliche MIME ans dataTransfer + * + * Vorhandene Built-in-Aktionen (Rename, Delete, Send-to-Chat) bleiben hinter + * dem System bestehen; wenn der Aufrufer keine `customActions` mitliefert, + * verhält sich `FolderTree` 1:1 wie zuvor. + */ + +import type React from 'react'; +import type { FileNode, FolderNode } from '../FolderTree'; + +export type FileActionScope = 'file' | 'folder' | 'multi'; +export type FileActionChannel = 'inline' | 'menu' | 'sheet' | 'shortcut' | 'drop'; + +/** UDB-Aufruf-Kontext — Aufrufer-Sites identifizieren sich, damit Predicates + * pro Surface entscheiden können (z. B. "nur im Graph-Editor sichtbar"). */ +export type UdbSurface = + | 'workspace' + | 'graphEditor' + | 'trustee' + | 'standalone' + | 'sharepoint'; + +export interface FileActionContext { + mandateId?: string; + featureInstanceId?: string; + viewMode: 'desktop' | 'mobile'; + udbContext?: UdbSurface; +} + +export interface FileActionTarget { + files: FileNode[]; + folders: FolderNode[]; +} + +export interface FileActionConfirm { + title: string; + body: (target: FileActionTarget) => string; +} + +export interface FileAction { + /** Global eindeutige Aktion-ID, namespace-prefixed (z. B. ``workflow.openInEditor``). */ + id: string; + /** Anzeige-Label (statisch oder als Funktion vom Target abgeleitet). */ + label: string | ((target: FileActionTarget) => string); + /** Icon-Komponente (react-icons-Style), bekommt optional `size`-Prop. */ + icon: React.ComponentType<{ size?: number }>; + /** Optionale Tönung des Icons (CSS color string). */ + iconColor?: string; + /** Was ist das Target — einzelne Datei, Ordner, oder Mehrfach-Selektion. */ + scope: FileActionScope; + /** Über welche UI-Kanäle wird die Aktion angeboten. */ + channels: FileActionChannel[]; + /** Pure, billig — entscheidet ob die Aktion für das aktuelle Target sichtbar ist. */ + predicate?: (target: FileActionTarget, ctx: FileActionContext) => boolean; + /** Async oder sync. Fehler werden vom Renderer geloggt; Toasts macht der Aufrufer. */ + handler: (target: FileActionTarget, ctx: FileActionContext) => Promise | void; + /** Tastenkürzel, z. B. `mod+e`. ``mod`` = Cmd auf Mac, Ctrl sonst. */ + shortcut?: string; + /** Wenn gesetzt → Bestätigungs-Dialog vor `handler`. */ + confirm?: FileActionConfirm; + /** MIME-Type für Drag-Source: wird zusätzlich ans `dataTransfer` gehängt. */ + dragMime?: string; + /** Sortier-Reihenfolge — kleinere Werte zuerst (Built-ins liegen bei 100, 110, 120…). */ + sortOrder?: number; + /** Visuell als gefährliche/destruktive Aktion markieren (rote Tönung). */ + danger?: boolean; +} + +/** Resolver-Helper: liest das Label eines `FileAction` aus, egal ob String oder Funktion. */ +export function resolveActionLabel(action: FileAction, target: FileActionTarget): string { + return typeof action.label === 'function' ? action.label(target) : action.label; +} + +/** Hilfs-Konstruktor: baut ein leeres Target. */ +export function emptyTarget(): FileActionTarget { + return { files: [], folders: [] }; +} diff --git a/src/components/FolderTree/actions/usePointerLongPress.ts b/src/components/FolderTree/actions/usePointerLongPress.ts new file mode 100644 index 0000000..18722c9 --- /dev/null +++ b/src/components/FolderTree/actions/usePointerLongPress.ts @@ -0,0 +1,75 @@ +import { useCallback, useRef } from 'react'; + +/** + * Long-Press-Erkennung über Pointer-Events. + * + * Liefert Handler die direkt auf `
` etc. gespreaded werden können. + * Ein "Long-Press" feuert nach `thresholdMs` (Default 500 ms) wenn der Pointer + * sich nicht weiter als `moveTolerance` Pixel bewegt hat. + */ + +interface LongPressOptions { + thresholdMs?: number; + moveTolerance?: number; + /** Wenn ``true``, werden auch Maus-Events behandelt (für Desktop-Smoke-Tests). */ + includeMouse?: boolean; +} + +interface LongPressHandlers { + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; + onPointerLeave: (e: React.PointerEvent) => void; +} + +export function usePointerLongPress( + callback: (e: React.PointerEvent) => void, + options: LongPressOptions = {}, +): LongPressHandlers { + const { thresholdMs = 500, moveTolerance = 8, includeMouse = false } = options; + const timerRef = useRef(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); + + const _clear = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + firedRef.current = false; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!includeMouse && e.pointerType === 'mouse') return; + _clear(); + startPosRef.current = { x: e.clientX, y: e.clientY }; + firedRef.current = false; + timerRef.current = window.setTimeout(() => { + firedRef.current = true; + callback(e); + }, thresholdMs); + }, + [callback, includeMouse, thresholdMs, _clear], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (timerRef.current === null || !startPosRef.current) return; + const dx = e.clientX - startPosRef.current.x; + const dy = e.clientY - startPosRef.current.y; + if (Math.abs(dx) > moveTolerance || Math.abs(dy) > moveTolerance) _clear(); + }, + [moveTolerance, _clear], + ); + + return { + onPointerDown, + onPointerMove, + onPointerUp: _clear, + onPointerCancel: _clear, + onPointerLeave: _clear, + }; +} diff --git a/src/components/FolderTree/actions/useViewMode.ts b/src/components/FolderTree/actions/useViewMode.ts new file mode 100644 index 0000000..187b832 --- /dev/null +++ b/src/components/FolderTree/actions/useViewMode.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +/** + * Liefert den aktuellen View-Mode (`'desktop' | 'mobile'`) basierend auf + * Viewport-Breite + Touch-Heuristik. Mobile = Breite < 768 px ODER + * Touch-Primary-Pointer ohne Maus. + */ +export function useViewMode(): 'desktop' | 'mobile' { + const [mode, setMode] = useState<'desktop' | 'mobile'>(() => _detect()); + + useEffect(() => { + const _onResize = () => setMode(_detect()); + window.addEventListener('resize', _onResize); + return () => window.removeEventListener('resize', _onResize); + }, []); + + return mode; +} + +function _detect(): 'desktop' | 'mobile' { + if (typeof window === 'undefined') return 'desktop'; + const isNarrow = window.matchMedia('(max-width: 768px)').matches; + const isCoarse = window.matchMedia('(pointer: coarse)').matches; + return isNarrow || isCoarse ? 'mobile' : 'desktop'; +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 732330b..49f8e58 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -65,6 +65,7 @@ import { CustomActionButton } from '../ActionButtons'; import { formatUnixTimestamp } from '../../../utils/time'; +import { applyFrontendFormat } from '../../../utils/applyFrontendFormat'; import { FormGeneratorControls } from '../FormGeneratorControls'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { @@ -112,6 +113,13 @@ export interface ColumnConfig { cellClassName?: (value: any, row: any) => string; // For custom cell styling fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/") fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel") + // Backend-provided render hints (gateway/.../attributeUtils.py). + // Excel-style format string applied by ``applyFrontendFormat`` to numeric/int + // values, e.g. "R:#'###.00", "M:b" (bytes), "L:0.000". Empty = default rendering. + frontendFormat?: string; + // Pre-translated label tokens for binary/categorical cells, e.g. ["Ja", "-", "Nein"]. + // Resolved server-side via i18n so the FE never needs another translation hop. + frontendFormatLabels?: string[]; } export interface FormGeneratorTableProps { @@ -1721,6 +1729,17 @@ export function FormGeneratorTable>({ return '-'; } + // Backend render hints take priority for binary cells when explicit + // ``frontendFormatLabels`` are provided -- this is how the LLM/user + // overrides the default ✓/✗ tri-state with meaningful labels like + // ["Ja", "-", "Nein"] or ["aktiv", "?", "inaktiv"]. We still defer to the + // inline-editable boolean renderer when no labels are configured so the + // existing checkbox UX is preserved. + if (column.frontendFormatLabels && (typeof value === 'boolean' || (column.type && isCheckboxType(column.type)))) { + const formatted = applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage); + return formatted.text; + } + // Handle boolean/checkbox fields with inline editing support if (column.type && isCheckboxType(column.type)) { return renderBooleanCell(value, column, row); @@ -1894,6 +1913,15 @@ export function FormGeneratorTable>({ case 'boolean': return value ? '✓' : '✗'; case 'number': + case 'float': + case 'integer': + case 'int': + // Honor backend ``frontendFormat`` (e.g. "R:#'###.00", "M:b") if present. + // Without a format hint we keep the existing default locale rendering so + // existing tables continue to look the same. + if (column.frontendFormat || column.frontendFormatLabels) { + return applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage).text; + } return typeof value === 'number' ? value.toLocaleString() : value; default: return String(value); @@ -2427,9 +2455,17 @@ export function FormGeneratorTable>({ const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; + const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; + const alignStyle: React.CSSProperties = formatAlign === 'R' + ? { textAlign: 'right' } + : formatAlign === 'M' + ? { textAlign: 'center' } + : formatAlign === 'L' + ? { textAlign: 'left' } + : isNumeric ? { textAlign: 'right' } : {}; return ( + style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}> {formatCellValue(cellValue, column, row)} ); @@ -2543,9 +2579,19 @@ export function FormGeneratorTable>({ const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; + // ``frontendFormat`` may carry an explicit alignment prefix + // ("L:", "M:", "R:") that overrides the numeric default. + const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; + const alignStyle: React.CSSProperties = formatAlign === 'R' + ? { textAlign: 'right' } + : formatAlign === 'M' + ? { textAlign: 'center' } + : formatAlign === 'L' + ? { textAlign: 'left' } + : isNumeric ? { textAlign: 'right' } : {}; return ( + style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}> {formatCellValue(cellValue, column, row)} ); diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 09618f0..08bd0fa 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,9 +1,17 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; +import { FaFileImport } from 'react-icons/fa'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import FolderTree from '../../components/FolderTree/FolderTree'; import type { FileNode } from '../../components/FolderTree/FolderTree'; +import type { FileAction } from '../../components/FolderTree/actions/types'; import { useFileContext } from '../../contexts/FileContext'; +import { useApiRequest } from '../../hooks/useApi'; +import { + importWorkflowFromFile, + WORKFLOW_FILE_EXTENSION, +} from '../../api/workflowApi'; +import { useToast } from '../../contexts/ToastContext'; import styles from './FilesTab.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -11,10 +19,16 @@ interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string, fileName?: string) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in + * den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste, + * Auto-Select) bleibt Aufgabe des Aufrufers. */ + onWorkflowImported?: (workflowId: string) => void; } -const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat }) => { +const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => { const { t } = useLanguage(); + const { request } = useApiRequest(); + const { showSuccess, showError } = useToast(); const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); @@ -179,6 +193,48 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [refreshFolders, refreshTreeFiles]); + const _customActions: FileAction[] = useMemo(() => { + if (context.surface !== 'graphEditor') return []; + return [ + { + id: 'workflow.openInEditor', + label: t('In Graph-Editor laden'), + icon: FaFileImport, + scope: 'file', + channels: ['inline', 'menu', 'sheet', 'drop'], + dragMime: 'application/json+workflow', + sortOrder: 50, + predicate: ({ files }) => + files.length === 1 && + files[0].fileName.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION), + handler: async ({ files }) => { + const file = files[0]; + if (!context.instanceId || !file) return; + try { + const result = await importWorkflowFromFile(request, context.instanceId, { + fileId: file.id, + }); + const warnings = result?.warnings ?? []; + const wfId = result?.workflow?.id; + if (warnings.length > 0) { + showSuccess( + t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { + n: String(warnings.length), + }), + ); + } else { + showSuccess(t('Workflow importiert (deaktiviert).')); + } + if (wfId && onWorkflowImported) onWorkflowImported(wfId); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + showError(t('Import fehlgeschlagen: {msg}', { msg })); + } + }, + }, + ]; + }, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]); + const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { try { await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); @@ -282,6 +338,8 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat onFolderScopeChange={_onFolderScopeChange} onFolderNeutralizeToggle={_onFolderNeutralizeToggle} onSendToChat={onSendToChat} + customActions={_customActions} + udbContext={context.surface} /> {_fileNodes.length === 0 && ( diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 66aaf1f..6254f51 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1161,7 +1161,9 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, + // Compensate the 3px borderLeft on active rows with -3px paddingLeft so + // the row content stays at exactly the same x-position as inactive rows. + paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0), paddingRight: 4, paddingTop: 3, paddingBottom: 3, @@ -1406,7 +1408,10 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => { }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + // Compensate the 3px borderLeft on active wildcard rows with -3px + // paddingLeft so the row content stays at the same x-position. + paddingLeft: wildcardFds ? 1 : 4, + paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: wildcardFds ? (hovered ? '#ede7f6' : '#7b1fa208') @@ -1585,6 +1590,7 @@ interface _GroupFolderViewProps extends _FeatureActionContext { const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const segments = [...pathSegments, `g:${objectKey}`]; @@ -1592,17 +1598,45 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { const expanded = ctx.featureExpandedPaths.has(pathKey); const chevron = expanded ? '\u25BE' : '\u25B8'; + // Container-wildcard objectKey: matches every record/table inside this group. + // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*` + // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize + // for every child without having to add each one individually. + const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`; + const wildcardFds = ctx.featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey, + ); + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + objectKey: containerObjectKey, + label, + }; + return (
ctx.onToggleFeaturePath(pathKey)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', label); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + // Compensate the 3px border on active wildcard rows so the row + // content stays at the same x-position whether or not it's active. + paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), + paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + background: wildcardFds + ? (hovered ? '#ede7f6' : '#7b1fa208') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), + borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, transition: 'background 0.1s', userSelect: 'none', }} > @@ -1617,6 +1651,52 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { }}> {label} + + + +
{expanded && items.length > 0 && ( @@ -1672,17 +1752,45 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { } }; + // Container-wildcard objectKey for the parent group: matches every record in + // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list. + const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`; + const wildcardFds = ctx.featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId + && f.tableName === table.tableName + && !f.recordFilter + && f.objectKey === containerObjectKey, + ); + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + tableName: table.tableName, + objectKey: containerObjectKey, + label: table.label || table.tableName, + }; + return (
setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', _chatPayload.label); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), + paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + background: wildcardFds + ? (hovered ? '#ede7f6' : '#7b1fa208') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), + borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, transition: 'background 0.1s', userSelect: 'none', }} > @@ -1701,6 +1809,54 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { +{childTables.length} {t('Tabellen')} )} + + + +
{expanded && records && records.length > 0 && ( diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index b872405..421f5d7 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -7,11 +7,23 @@ import styles from './UnifiedDataBar.module.css'; export type UdbTab = 'chats' | 'files' | 'sources'; +/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext` + * weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich + * pro Surface registrieren können. */ +export type UdbSurface = + | 'workspace' + | 'graphEditor' + | 'trustee' + | 'standalone' + | 'sharepoint'; + export interface UdbContext { instanceId: string; mandateId?: string; featureInstanceId?: string; userId?: string; + /** Optionales Surface-Tag, hilft Custom-Actions zu entscheiden, wann sie sichtbar sind. */ + surface?: UdbSurface; } export interface AddToChat_FileItem { @@ -44,6 +56,9 @@ interface UnifiedDataBarProps { onSendToChat_Files?: (items: AddToChat_FileItem[]) => void; onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void; onAttachDataSource?: (dsId: string) => void; + /** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den + * Graph-Editor importiert wurde (Action `workflow.openInEditor`). */ + onWorkflowImportedFromFile?: (workflowId: string) => void; className?: string; } @@ -72,6 +87,7 @@ const UnifiedDataBar: React.FC = ({ onSendToChat_Files, onSendToChat_FeatureSource, onAttachDataSource, + onWorkflowImportedFromFile, className, }) => { const { t } = useLanguage(); @@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC = ({ context={context} onFileSelect={onFileSelect} onSendToChat={onSendToChat_Files} + onWorkflowImported={onWorkflowImportedFromFile} /> )} {currentTab === 'sources' && !hideTabs?.includes('sources') && ( diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index eca461c..f3c18d8 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -142,6 +142,12 @@ export const PAGE_ICONS: Record = { 'page.feature.workspace.dashboard': , 'page.feature.workspace.editor': , 'feature.workspace': , + + // Feature pages - Redmine + 'feature.redmine': , + 'page.feature.redmine.stats': , + 'page.feature.redmine.browser': , + 'page.feature.redmine.settings': , }; // ============================================================================= diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx index c886561..5022b21 100644 --- a/src/hooks/useConfirm.tsx +++ b/src/hooks/useConfirm.tsx @@ -66,12 +66,19 @@ export function useConfirm() { return (
{ + if (e.key === 'Escape') _handleCancel(); + }} + tabIndex={-1} >
e.stopPropagation()} diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx index 3324ef3..60d2621 100644 --- a/src/hooks/usePrompt.tsx +++ b/src/hooks/usePrompt.tsx @@ -73,12 +73,19 @@ export function usePrompt() { return (
{ + if (e.key === 'Escape') _handleCancel(); + }} + tabIndex={-1} >
e.stopPropagation()} diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 03c4b45..7fb75e3 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -48,6 +48,9 @@ import { NeutralizationView } from './views/neutralization'; // CommCoach Views import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; +// Redmine Views +import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine'; + import styles from './FeatureView.module.css'; import { useLanguage } from '../providers/language/LanguageContext'; @@ -168,6 +171,11 @@ const VIEW_COMPONENTS: Record> = { dossier: CommcoachDossierView, settings: CommcoachSettingsView, }, + redmine: { + stats: RedmineStatsView, + browser: RedmineBrowserView, + settings: RedmineSettingsView, + }, }; // ============================================================================= diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index a3218eb..0baa236 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -69,6 +69,8 @@ export const ConnectionsPage: React.FC = () => { maxWidth: attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, }; if (attr.name === 'userId') { diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 79d6199..78f3cbb 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -212,6 +212,8 @@ export const FilesPage: React.FC = () => { maxWidth: attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, })); cols.push({ key: 'sysCreatedBy', diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 38ea58f..ae55350 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -85,6 +85,8 @@ export const PromptsPage: React.FC = () => { maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, })); // Add sysCreatedBy column with FK resolution to show username @@ -100,6 +102,8 @@ export const PromptsPage: React.FC = () => { maxWidth: 250, fkSource: '/api/users/', fkDisplayField: 'username', + frontendFormat: undefined, + frontendFormatLabels: undefined, }); return cols; diff --git a/src/pages/views/redmine/RedmineBrowserView.tsx b/src/pages/views/redmine/RedmineBrowserView.tsx new file mode 100644 index 0000000..cce9851 --- /dev/null +++ b/src/pages/views/redmine/RedmineBrowserView.tsx @@ -0,0 +1,721 @@ +/** + * Redmine Ticket Browser + * + * Split view: tree-as-table on the left (roots = configured root + * tracker + virtual "Orphan" root), editor pane on the right. All reads + * hit the local mirror; saves go through ``updateRedmineTicketApi`` + * which updates Redmine and then refreshes the mirror. + * + * Filters are applied client-side because the mirror already fits in + * memory (2-20k tickets is fine for a sub-200ms filter pass). + */ + +import React, { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import { + RedmineConfigDto, + RedmineFieldSchema, + RedmineTicket, + getRedmineConfigApi, + getRedmineSchemaApi, + listRedmineTicketsApi, +} from '../../../api/redmineApi'; +import { PeriodPicker, PeriodValue } from '../../../components/PeriodPicker'; + +import { + Forest, + FlatRow, + ORPHAN_ROOT_ID, + buildForest, + collectAllIds, + flattenForest, +} from './redmineTreeLogic'; +import { getTrackerStyle, sortByTrackerOrder } from './redmineTrackerColor'; +import RedmineTicketEditor from './RedmineTicketEditor'; + +import styles from './RedmineViews.module.css'; + +// ============================================================================ +// Relation type options -- Redmine's fixed vocabulary plus our synthetic +// "parent" edge (inherited from ``parent_id``). +// ============================================================================ +const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'parent', label: 'parent_id' }, + { value: 'relates', label: 'relates' }, + { value: 'duplicates', label: 'duplicates' }, + { value: 'duplicated', label: 'duplicated' }, + { value: 'blocks', label: 'blocks' }, + { value: 'blocked', label: 'blocked' }, + { value: 'precedes', label: 'precedes' }, + { value: 'follows', label: 'follows' }, + { value: 'copied_to', label: 'copied_to' }, + { value: 'copied_from', label: 'copied_from' }, +]; + +// ============================================================================ +// Closed-state lookup -- the mirror's ``isClosed`` field can be stale or +// missing when the schema cache wasn't yet hydrated at sync time. We trust +// the live schema (``schema.statuses[*].isClosed``) as the source of truth +// and fall back to the ticket's own flag. +// ============================================================================ +const _isTicketClosed = ( + ticket: RedmineTicket, + schemaStatusClosedById: Map, +): boolean => { + if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) { + return schemaStatusClosedById.get(ticket.statusId) === true; + } + return !!ticket.isClosed; +}; + +export const RedmineBrowserView: React.FC = () => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + + const [config, setConfig] = useState(null); + const [schema, setSchema] = useState(null); + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [period, setPeriod] = useState(null); + // Tracker filter is *subtractive*: the set holds tracker ids that are + // currently SHOWN. ``null`` means "uninitialised" -- once the schema is + // loaded we seed the set with all tracker ids so every chip starts active. + // Clicking a chip removes it from the set -> tickets of that tracker + // disappear from the list. + const [selectedTrackerIds, setSelectedTrackerIds] = useState | null>(null); + const [selectedAssigneeIds, setSelectedAssigneeIds] = useState>(new Set()); + const [selectedRelTypes, setSelectedRelTypes] = useState>( + new Set(RELATION_TYPE_OPTIONS.map(r => r.value)), + ); + const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); + // Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic + // value ``__none__`` matches tickets with no sprint assigned. + const [selectedSprints, setSelectedSprints] = useState>(new Set()); + + // UI state + const [expanded, setExpanded] = useState>(new Set()); + const [selectedId, setSelectedId] = useState(null); + + const rootTrackerId = schema?.rootTrackerId ?? null; + + // Seed the tracker filter once the schema is available: every tracker + // starts SELECTED so the user sees everything by default; clicking a chip + // removes that tracker from the visible set. + useEffect(() => { + if (selectedTrackerIds == null && schema) { + setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id))); + } + }, [schema, selectedTrackerIds]); + + // Map statusId -> isClosed, taken from the live schema. Used by the status + // filter so it works even if the mirror's per-ticket ``isClosed`` is stale. + const schemaStatusClosedById = useMemo(() => { + const m = new Map(); + if (schema) { + for (const s of schema.statuses) { + if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed); + } + } + return m; + }, [schema]); + + // Distinct sprints (fixed_version) seen across all loaded tickets. Drives + // the sprint filter dropdown. Sorted alphabetically for stable UI. + const sprintOptions = useMemo(() => { + const m = new Map(); + let hasNone = false; + for (const tk of tickets) { + const name = tk.fixedVersionName?.trim(); + if (name) { + m.set(name, name); + } else { + hasNone = true; + } + } + const opts = Array.from(m.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') }); + return opts; + }, [tickets, t]); + + // Load config + schema once. + const _loadMeta = useCallback(async () => { + if (!instanceId) return; + try { + const [c, s] = await Promise.all([ + getRedmineConfigApi(request, instanceId), + getRedmineSchemaApi(request, instanceId), + ]); + setConfig(c); + setSchema(s); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen')); + } + }, [request, instanceId, t]); + + // Load tickets from mirror whenever the period window changes (backend can + // pre-filter by updatedOn to shrink the payload). + const _loadTickets = useCallback(async () => { + if (!instanceId) return; + setLoading(true); + setError(null); + try { + const result = await listRedmineTicketsApi(request, instanceId, { + status: '*', + dateFrom: period?.fromDate, + dateTo: period?.toDate, + }); + setTickets(result); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen')); + setTickets([]); + } finally { + setLoading(false); + } + }, [request, instanceId, period, t]); + + useEffect(() => { _loadMeta(); }, [_loadMeta]); + useEffect(() => { _loadTickets(); }, [_loadTickets]); + + // Client-side filter pass (tracker / assignee / status). Root-tracker + // tickets are always kept so the tree has roots even if their own tracker + // is deselected -- otherwise the whole forest collapses. + // ``deferredFilters`` lets React keep the filter chips snappy while the + // potentially expensive tree rebuild happens in the background. The + // chips update immediately (urgent state) but the tree picks up the new + // values one tick later, which removes the "click feels frozen" lag. + const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds); + const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds); + const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes); + const deferredStatusFilter = useDeferredValue(statusFilter); + const deferredSelectedSprints = useDeferredValue(selectedSprints); + + const filteredTickets = useMemo(() => { + const trackerSet = deferredSelectedTrackerIds; + const assigneeSet = deferredSelectedAssigneeIds; + const sprintSet = deferredSelectedSprints; + const status = deferredStatusFilter; + return tickets.filter(ticket => { + const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId; + if (!isRoot && trackerSet != null && ticket.trackerId != null) { + if (!trackerSet.has(ticket.trackerId)) return false; + } + if (assigneeSet.size > 0) { + if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false; + } + if (status !== '*') { + const closed = _isTicketClosed(ticket, schemaStatusClosedById); + if (status === 'open' && closed) return false; + if (status === 'closed' && !closed) return false; + } + if (sprintSet.size > 0) { + const sprintKey = ticket.fixedVersionName?.trim() || '__none__'; + if (!sprintSet.has(sprintKey)) return false; + } + return true; + }); + }, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]); + + // Convert the rel-type set to an array once per change instead of on every + // ``buildForest`` call (Array.from() in the deps would re-run the memo + // every render because the array identity is fresh each time). + const allowedRelTypesArr = useMemo( + () => Array.from(deferredSelectedRelTypes), + [deferredSelectedRelTypes], + ); + + const forest: Forest = useMemo(() => { + return buildForest(filteredTickets, { + rootTrackerId, + allowedRelTypes: allowedRelTypesArr, + }); + }, [filteredTickets, rootTrackerId, allowedRelTypesArr]); + + const flatRows: FlatRow[] = useMemo( + () => flattenForest(forest.trees, expanded), + [forest.trees, expanded], + ); + + const ticketsById = useMemo(() => { + const m = new Map(); + for (const ticket of tickets) m.set(ticket.id, ticket); + return m; + }, [tickets]); + + // One-shot initial expansion: the very first time we render a non-empty + // forest, expand the root nodes so the user sees the overview. After that + // the user owns the expand state -- collapsing all must STAY collapsed. + const _didInitExpand = useRef(false); + useEffect(() => { + if (!_didInitExpand.current && forest.trees.length > 0) { + _didInitExpand.current = true; + setExpanded(new Set(forest.trees.map(tr => tr.id))); + } + }, [forest.trees]); + + const _toggleExpand = useCallback((id: number) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _expandAll = useCallback(() => { + setExpanded(new Set(collectAllIds(forest.trees))); + }, [forest.trees]); + + const _collapseAll = useCallback(() => { + setExpanded(new Set()); + }, []); + + const _resetFilters = useCallback(() => { + setPeriod(null); + // "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set. + setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null); + setSelectedAssigneeIds(new Set()); + setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value))); + setStatusFilter('*'); + setSelectedSprints(new Set()); + }, [schema]); + + const _toggleSprint = useCallback((value: string) => { + setSelectedSprints(prev => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); else next.add(value); + return next; + }); + }, []); + + const _toggleTracker = useCallback((id: number) => { + setSelectedTrackerIds(prev => { + // ``prev`` is null only before schema loads -- the chip wouldn't be + // clickable in that state, but stay defensive. + const next = new Set(prev ?? []); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _toggleRelType = useCallback((rt: string) => { + setSelectedRelTypes(prev => { + const next = new Set(prev); + if (next.has(rt)) next.delete(rt); else next.add(rt); + return next; + }); + }, []); + + const _handleTicketSaved = useCallback((updated: RedmineTicket) => { + setTickets(prev => prev.map(x => (x.id === updated.id ? updated : x))); + }, []); + + if (!instanceId) { + return
{t('Keine Feature-Instanz ausgewaehlt')}
; + } + + return ( +
+
+
+

{t('Redmine -- Ticket-Browser')}

+

+ {t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', { + name: schema?.rootTrackerName || config?.rootTrackerName || '—', + })} +

+
+
+ {t('{count} von {total} Tickets sichtbar', { + count: filteredTickets.length, + total: tickets.length, + })} +
+
+ + {error &&
{error}
} + +
+
+ + setPeriod(next)} + direction="past" + defaultPreset={{ kind: 'lastQuarter' }} + enabledPresets={[ + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', + ]} + placeholder={t('Alle Zeiten')} + /> +
+ +
+ + +
+ + {schema && schema.trackers.length > 0 && ( +
+ +
+ {sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => { + const isRoot = tr.id === rootTrackerId; + // Active iff in the visible set (or root). selectedTrackerIds + // is null only during the brief window before the schema seed + // effect runs -- treat as "all visible". + const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id); + const sty = getTrackerStyle(tr.name); + return ( + + ); + })} +
+
+ )} + + {schema && schema.users.length > 0 && ( +
+ + +
+ )} + + {sprintOptions.length > 0 && ( +
+ +
+ {sprintOptions.map(sp => { + const active = selectedSprints.has(sp.value); + return ( + + ); + })} +
+ {selectedSprints.size === 0 && ( + + {t('keine Auswahl = alle Sprints')} + + )} +
+ )} + +
+ +
+ {RELATION_TYPE_OPTIONS.map(rt => { + const active = selectedRelTypes.has(rt.value); + return ( + + ); + })} +
+
+ +
+ + +
+ +
+ {/* Left: tree */} +
+
+ + {t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', { + rows: flatRows.length, + orphans: forest.orphanCount, + })} + +
+ + +
+
+
+ {loading ? ( +
{t('Baum wird aufgebaut ...')}
+ ) : flatRows.length === 0 ? ( +
+ {t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')} +
+ ) : ( +
+
+
{t('Ticket')}
+
Status
+
{t('Prio')}
+
{t('Zuweisung')}
+
{t('Geaendert')}
+
{t('Beziehung')}
+
+ {flatRows.map(row => ( + + ))} +
+ )} +
+
+ + {/* Right: editor */} +
+ {selectedId == null ? ( +
{t('Ticket links auswaehlen')}
+ ) : selectedId === ORPHAN_ROOT_ID ? ( +
+ {t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', { + count: forest.orphanCount, + })} +
+ ) : ( + + )} +
+
+
+ ); +}; + +// ============================================================================ +// TreeRow -- a single grid row, handling its own indent painting. +// ============================================================================ + +interface TreeRowProps { + row: FlatRow; + ticket: RedmineTicket | null; + selected: boolean; + expanded: boolean; + onToggle: (id: number) => void; + onSelect: (id: number) => void; + rootTrackerId: number | null; + schemaStatusClosedById: Map; +} + +const _TreeRowImpl: React.FC = ({ + row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById, +}) => { + const { node, depth, indentLines, hasChildren } = row; + const isOrphanRoot = node.id === ORPHAN_ROOT_ID; + const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId; + const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false; + + const rowClass = [ + styles.treeRow, + selected ? styles.selected : '', + isOrphanRoot ? styles.orphan : '', + ].filter(Boolean).join(' '); + + const _handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggle(node.id); + }; + + const _handleSelect = () => onSelect(node.id); + + return ( +
+
+ {Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => ( + + ))} + {depth > 0 && ( + + + + )} + {hasChildren ? ( + + ) : ( + + )} + { + const sty = getTrackerStyle(ticket.trackerName); + return { + background: sty.bg, + color: sty.fg, + border: `1px solid ${sty.border}`, + }; + })() + : undefined + } + > + {isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')} + + + {isOrphanRoot ? '—' : `#${ticket?.id}`} + + + {isOrphanRoot + ? `Tickets ohne Verbindung zu einer User Story (${node.children.length})` + : (ticket?.subject || '(ohne Titel)')} + +
+
+ {isOrphanRoot ? + : ticket?.statusName + ? {ticket.statusName} + : } +
+
{isOrphanRoot ? : (ticket?.priorityName || '—')}
+
{isOrphanRoot ? : (ticket?.assignedToName || )}
+
+ {isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')} +
+
+ {isOrphanRoot + ? virtuell + : isRootTracker && depth === 0 + ? Root + : node.relType + ? {node.dir === 'in' ? '←' : '→'} {node.relType} + : } +
+
+ ); +}; + +// Custom equality: skip re-rendering rows whose visible inputs are identical. +// ``row`` and ``ticket`` references are stable across re-renders unless the +// underlying tree was rebuilt or the ticket itself changed -- much cheaper +// than re-painting 2k DOM nodes on every filter chip click. +const TreeRow = React.memo(_TreeRowImpl, (prev, next) => { + return ( + prev.row === next.row + && prev.ticket === next.ticket + && prev.selected === next.selected + && prev.expanded === next.expanded + && prev.onToggle === next.onToggle + && prev.onSelect === next.onSelect + && prev.rootTrackerId === next.rootTrackerId + && prev.schemaStatusClosedById === next.schemaStatusClosedById + ); +}); + +export default RedmineBrowserView; diff --git a/src/pages/views/redmine/RedmineSettingsView.tsx b/src/pages/views/redmine/RedmineSettingsView.tsx new file mode 100644 index 0000000..d0ad5e0 --- /dev/null +++ b/src/pages/views/redmine/RedmineSettingsView.tsx @@ -0,0 +1,349 @@ +/** + * Redmine Settings View + * + * Configure the Redmine connection for this feature instance: + * - Base URL, Project ID, API Key, Root Tracker name + * - "Verbindung testen" -- calls whoAmI + getProject and reports the result + * - "Sync starten" -- pulls all (or only changed) tickets into the local mirror + * + * The user tests the feature directly here in Porta -- no pytest sandbox. + */ + +import React, { useCallback, useEffect, useState } from 'react'; + +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import { + RedmineConfigDto, + RedmineConnectionTestResult, + RedmineSyncResult, + RedmineSyncStatus, + deleteRedmineConfigApi, + getRedmineConfigApi, + getRedmineSyncStatusApi, + runRedmineSyncApi, + testRedmineConnectionApi, + updateRedmineConfigApi, +} from '../../../api/redmineApi'; + +import styles from './RedmineViews.module.css'; + +const _formatTs = (ts?: number | null): string => { + if (!ts) return '-'; + try { + return new Date(ts * 1000).toLocaleString(); + } catch { + return String(ts); + } +}; + +const _formatDuration = (ms?: number | null): string => { + if (ms == null) return '-'; + if (ms < 1000) return `${ms} ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)} s`; + const m = s / 60; + return `${m.toFixed(1)} min`; +}; + +export const RedmineSettingsView: React.FC = () => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [testResult, setTestResult] = useState(null); + const [syncResult, setSyncResult] = useState(null); + const [syncStatus, setSyncStatus] = useState(null); + + const [baseUrl, setBaseUrl] = useState(''); + const [projectId, setProjectId] = useState(''); + const [rootTrackerName, setRootTrackerName] = useState('Userstory'); + const [apiKey, setApiKey] = useState(''); + + const _hydrate = useCallback((c: RedmineConfigDto) => { + setConfig(c); + setBaseUrl(c.baseUrl || ''); + setProjectId(c.projectId || ''); + setRootTrackerName(c.rootTrackerName || 'Userstory'); + }, []); + + const _loadStatus = useCallback(async () => { + if (!instanceId) return; + try { + const status = await getRedmineSyncStatusApi(request, instanceId); + setSyncStatus(status); + } catch { + // status is optional; don't block the page on failure + } + }, [request, instanceId]); + + useEffect(() => { + if (!instanceId) return; + let cancelled = false; + (async () => { + setLoading(true); + try { + const cfg = await getRedmineConfigApi(request, instanceId); + if (!cancelled) _hydrate(cfg); + await _loadStatus(); + } catch (e: any) { + if (!cancelled) setError(e?.message || t('Fehler beim Laden')); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [request, instanceId, _hydrate, _loadStatus, t]); + + const _save = useCallback(async () => { + if (!instanceId) return; + setSaving(true); + setError(null); + setSuccess(null); + try { + const body: Record = { + baseUrl: baseUrl.trim(), + projectId: projectId.trim(), + rootTrackerName: rootTrackerName.trim() || 'Userstory', + isActive: true, + }; + if (apiKey.trim() !== '') body.apiKey = apiKey.trim(); + const updated = await updateRedmineConfigApi(request, instanceId, body); + _hydrate(updated); + setApiKey(''); + setSuccess(t('Einstellungen gespeichert.')); + setTimeout(() => setSuccess(null), 3000); + } catch (e: any) { + setError(e?.message || t('Fehler beim Speichern.')); + } finally { + setSaving(false); + } + }, [request, instanceId, baseUrl, projectId, rootTrackerName, apiKey, _hydrate, t]); + + const _test = useCallback(async () => { + if (!instanceId) return; + setTesting(true); + setError(null); + setTestResult(null); + try { + const result = await testRedmineConnectionApi(request, instanceId); + setTestResult(result); + } catch (e: any) { + setError(e?.message || t('Verbindungstest fehlgeschlagen.')); + } finally { + setTesting(false); + } + }, [request, instanceId, t]); + + const _runSync = useCallback(async (force: boolean) => { + if (!instanceId) return; + setSyncing(true); + setError(null); + setSuccess(null); + setSyncResult(null); + try { + const result = await runRedmineSyncApi(request, instanceId, force); + setSyncResult(result); + await _loadStatus(); + const cfg = await getRedmineConfigApi(request, instanceId); + _hydrate(cfg); + setSuccess( + t('Sync erfolgreich.') + + ` ${result.ticketsUpserted} ${t('Tickets')}, ${result.relationsUpserted} ${t('Beziehungen')}, ${_formatDuration(result.durationMs)}.`, + ); + } catch (e: any) { + setError(e?.message || t('Sync fehlgeschlagen.')); + } finally { + setSyncing(false); + } + }, [request, instanceId, _loadStatus, _hydrate, t]); + + const _delete = useCallback(async () => { + if (!instanceId) return; + if (!window.confirm(t('Konfiguration wirklich loeschen? Der lokale Mirror bleibt erhalten.'))) return; + setError(null); + setSuccess(null); + try { + await deleteRedmineConfigApi(request, instanceId); + setBaseUrl(''); + setProjectId(''); + setRootTrackerName('Userstory'); + setApiKey(''); + setConfig(null); + setSuccess(t('Konfiguration geloescht.')); + } catch (e: any) { + setError(e?.message || t('Loeschen fehlgeschlagen.')); + } + }, [request, instanceId, t]); + + if (loading) { + return
{t('Einstellungen werden geladen ...')}
; + } + + const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId; + const canSync = canTest; + + return ( +
+

{t('Redmine -- Einstellungen')}

+

+ {t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')} +

+ + {error &&
{error}
} + {success &&
{success}
} + +
+

{t('Verbindung')}

+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://redmine.example.com" + spellCheck={false} + /> +
{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}
+
+ +
+ + setProjectId(e.target.value)} + placeholder="logobject-mars" + spellCheck={false} + /> +
+ +
+ + setRootTrackerName(e.target.value)} + placeholder="Userstory" + spellCheck={false} + /> +
+ {t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')} +
+
+ +
+ + setApiKey(e.target.value)} + placeholder={config?.hasApiKey ? t('(gesetzt -- leer lassen, um nicht zu aendern)') : t('Redmine API Access Key')} + spellCheck={false} + autoComplete="new-password" + /> +
+ {t('Wird verschluesselt gespeichert. Status: ')} + {config?.hasApiKey ? {t('gesetzt')} : {t('nicht gesetzt')}} +
+
+ +
+ + + {config?.id && ( + + )} +
+ + {testResult && ( +
+ {testResult.ok ? ( +
+ {t('Verbindung OK')}.{' '} + {testResult.user?.name && <>{t('Angemeldet als')} {testResult.user.name}. } + {testResult.project?.name && <>{t('Projekt')}: {testResult.project.name}.} +
+ ) : ( +
+ {t('Verbindung fehlgeschlagen')}.{' '} + {testResult.message || testResult.reason || ''} + {testResult.status ? ` (HTTP ${testResult.status})` : ''} +
+ )} +
+ )} +
+ +
+

{t('Mirror-Sync')}

+

+ {t('Tickets werden in die lokale Datenbank gespiegelt, damit Statistik und Browser auch bei 20\u2019000+ Tickets schnell sind. Nach Aenderungen wird das Mirror-Bild automatisch nachgezogen.')} +

+ +
+
{t('Letzter Sync')}:
+
{_formatTs(config?.lastSyncAt)}
+
{t('Letzter Full-Sync')}:
+
{_formatTs(config?.lastFullSyncAt)}
+
{t('Letzte Sync-Dauer')}:
+
{_formatDuration(syncStatus?.lastSyncDurationMs)}
+
{t('Tickets im Mirror')}:
+
{syncStatus?.mirroredTicketCount ?? '-'}
+
{t('Beziehungen im Mirror')}:
+
{syncStatus?.mirroredRelationCount ?? '-'}
+ {config?.lastSyncErrorMessage && ( + <> +
{t('Letzter Fehler')}:
+
{config.lastSyncErrorMessage}
+ + )} +
+ +
+ + +
+ + {syncResult && ( +
+ {syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:{' '} + {syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '} + {t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}. +
+ )} + + {!canSync && ( +
+ {t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')} +
+ )} +
+
+ ); +}; + +export default RedmineSettingsView; diff --git a/src/pages/views/redmine/RedmineStatsView.tsx b/src/pages/views/redmine/RedmineStatsView.tsx new file mode 100644 index 0000000..e8cbfb7 --- /dev/null +++ b/src/pages/views/redmine/RedmineStatsView.tsx @@ -0,0 +1,395 @@ +/** + * Redmine Statistics View + * + * Default landing view for a Redmine feature instance. Reads aggregated + * stats from the local mirror (fast, even at 20k+ tickets) and renders + * KPIs + charts via ``FormGeneratorReport``. The built-in + * ``dateRangeSelector`` mounts the shared ``PeriodPicker`` -- no extra + * wiring needed. + * + * Buckets returned by the backend are mapped to ``ReportSection``s here + * (frontend does the UI shape; backend stays storage-oriented). + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useApiRequest } from '../../../hooks/useApi'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import { + RedmineFieldSchema, + RedmineStats, + getRedmineSchemaApi, + getRedmineStatsApi, +} from '../../../api/redmineApi'; + +import { FormGeneratorReport } from '../../../components/FormGenerator/FormGeneratorReport'; +import type { + ReportSection, + ReportFilterState, + ReportDateRangeSelectorConfig, + ReportFilterConfig, +} from '../../../components/FormGenerator/FormGeneratorReport'; +import { toIsoDate } from '../../../components/PeriodPicker'; + +import styles from './RedmineViews.module.css'; + +// ============================================================================ +// Helpers -- map raw backend buckets to ReportSection[] +// ============================================================================ + +// Format counts as integers ("Einheiten") -- prevents the chart components +// from falling back to their default currency formatter (CHF). +const _fmtUnits = (v: number): string => { + if (!Number.isFinite(v)) return '0'; + return Math.round(v).toLocaleString('de-CH'); +}; + +const _buildSections = ( + stats: RedmineStats, + t: (key: string, vars?: Record) => string, +): ReportSection[] => { + const sections: ReportSection[] = []; + + // ---- KPI tiles -------------------------------------------------------- + sections.push({ + type: 'kpiGrid', + span: 'full', + items: [ + { label: t('Tickets gesamt'), value: stats.kpis.total }, + { label: t('Offen'), value: stats.kpis.open, color: '#4A6FA5' }, + { label: t('Geschlossen'), value: stats.kpis.closed, color: '#38A169' }, + { label: t('Im Zeitraum erstellt'), value: stats.kpis.createdInPeriod }, + { label: t('Im Zeitraum geschlossen'), value: stats.kpis.closedInPeriod }, + { + label: t('Ohne Userstory (Orphans)'), + value: stats.kpis.orphans, + color: stats.kpis.orphans > 0 ? '#C53030' : undefined, + }, + ], + }); + + // ---- Snapshot chart: total tickets vs. open per bucket end ---------- + // ``cumTotal`` and ``cumOpen`` are computed server-side and are SNAPSHOT + // values (state at the end of each bucket), not flow numbers. The + // difference between the two lines is the cumulative number of closed + // tickets up to that point in time. + if (stats.throughput.length > 0) { + const snapshotData = stats.throughput.map(b => ({ + date: b.label, + total: b.cumTotal, + open: b.cumOpen, + })); + sections.push({ + type: 'lineChart', + span: 'full', + title: t('Bestand pro {bucket}: Total vs. Offen', { bucket: stats.bucket }), + description: t('Snapshot am Ende jeder Periode: wie viele Tickets es zu diesem Zeitpunkt gibt (Total) und wie viele davon noch offen sind. Die Luecke zwischen den Linien sind die bis dahin geschlossenen Tickets.'), + data: snapshotData, + series: [ + { key: 'total', label: t('Total'), color: '#4A6FA5' }, + { key: 'open', label: t('Offen'), color: '#DD6B20' }, + ], + formatValue: _fmtUnits, + }); + } + + // ---- Status per tracker (stacked-like via horizontal bar per tracker) + if (stats.statusByTracker.length > 0) { + const statusKeys = new Set(); + stats.statusByTracker.forEach(row => { + Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k)); + }); + const totals: Record = {}; + stats.statusByTracker.forEach(row => { + Object.entries(row.countsByStatus).forEach(([s, n]) => { + totals[s] = (totals[s] || 0) + n; + }); + }); + sections.push({ + type: 'pieChart', + span: 'half', + title: t('Status-Verteilung (gesamt)'), + donut: true, + data: Object.entries(totals) + .sort(([, a], [, b]) => b - a) + .map(([status, count]) => ({ key: status, value: count })), + formatValue: _fmtUnits, + }); + + sections.push({ + type: 'horizontalBar', + span: 'half', + title: t('Tickets pro Tracker'), + data: stats.statusByTracker + .slice() + .sort((a, b) => b.total - a.total) + .map(row => ({ + key: row.trackerName, + value: row.total, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Top assignees --------------------------------------------------- + if (stats.topAssignees.length > 0) { + sections.push({ + type: 'horizontalBar', + span: 'half', + title: t('Top 10 Zugewiesene (offene Tickets)'), + description: t('Offene Tickets nach zugewiesener Person -- zeigt Auslastung.'), + data: stats.topAssignees.map(a => ({ + key: a.name, + value: a.open, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Relation distribution ------------------------------------------ + if (stats.relationDistribution.length > 0) { + sections.push({ + type: 'pieChart', + span: 'half', + title: t('Beziehungsarten'), + donut: true, + data: stats.relationDistribution.map(r => ({ + key: r.relationType, + value: r.count, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Backlog aging -------------------------------------------------- + if (stats.backlogAging.length > 0) { + sections.push({ + type: 'barChart', + span: 'full', + title: t('Backlog-Alter (offene Tickets)'), + description: t('Verteilung offener Tickets nach Alter -- hilft alte Leichen zu finden.'), + data: stats.backlogAging.map(b => ({ + key: b.label, + value: b.count, + })), + color: '#DD6B20', + formatValue: _fmtUnits, + }); + } + + return sections; +}; + +// ============================================================================ +// Main view +// ============================================================================ + +type BucketSize = 'day' | 'week' | 'month'; + +// Translate the polymorphic ``ReportFilterState.filters`` value for one +// multiselect key into a clean number[] and only call ``setter`` if the +// list actually changed (prevents an infinite render loop when the +// FormGenerator re-emits the same state). +const _applyMultiselectFilter = ( + raw: any, + current: number[], + setter: (next: number[]) => void, +): void => { + let next: number[] | null = null; + if (Array.isArray(raw)) { + next = raw.map(v => Number(v)).filter(n => !Number.isNaN(n)); + } else if (typeof raw === 'string' && raw !== '') { + const n = Number(raw); + if (!Number.isNaN(n)) next = [n]; + } else if (!raw) { + next = []; + } + if (next == null) return; + if (next.length === current.length && next.every((v, i) => v === current[i])) return; + setter(next); +}; + +export const RedmineStatsView: React.FC = () => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + + const [schema, setSchema] = useState(null); + const [schemaError, setSchemaError] = useState(null); + + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [dateFrom, setDateFrom] = useState(undefined); + const [dateTo, setDateTo] = useState(undefined); + const [bucket, setBucket] = useState('week'); + const [trackerIds, setTrackerIds] = useState([]); + const [categoryIds, setCategoryIds] = useState([]); + const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); + + // Load schema once -- we need trackers for the filter dropdown. + const _loadSchema = useCallback(async () => { + if (!instanceId) return; + try { + const res = await getRedmineSchemaApi(request, instanceId); + setSchema(res); + } catch (e: any) { + setSchemaError(e?.response?.data?.detail || e?.message || t('Schema-Laden fehlgeschlagen')); + } + }, [request, instanceId, t]); + + useEffect(() => { _loadSchema(); }, [_loadSchema]); + + // Load stats whenever the filters change. + const _loadStats = useCallback(async () => { + if (!instanceId) return; + setLoading(true); + setError(null); + try { + const res = await getRedmineStatsApi(request, instanceId, { + dateFrom, + dateTo, + bucket, + trackerIds: trackerIds.length > 0 ? trackerIds : undefined, + categoryIds: categoryIds.length > 0 ? categoryIds : undefined, + statusFilter, + }); + setStats(res); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Statistik-Laden fehlgeschlagen')); + setStats(null); + } finally { + setLoading(false); + } + }, [request, instanceId, dateFrom, dateTo, bucket, trackerIds, categoryIds, statusFilter, t]); + + useEffect(() => { _loadStats(); }, [_loadStats]); + + // ---- FormGeneratorReport filter configuration ----------------------- + const dateRangeSelector = useMemo(() => ({ + enabled: true, + direction: 'past', + defaultPresetKind: 'thisQuarter', + enabledPresets: [ + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', + ], + }), []); + + const filterConfigs = useMemo(() => { + const configs: ReportFilterConfig[] = [ + { + key: 'bucket', + label: t('Gruppierung'), + type: 'select', + defaultValue: bucket, + options: [ + { value: 'day', label: t('Tag') }, + { value: 'week', label: t('Woche') }, + { value: 'month', label: t('Monat') }, + ], + }, + { + key: 'statusFilter', + label: t('Status'), + type: 'select', + defaultValue: statusFilter, + options: [ + { value: '*', label: t('Alle') }, + { value: 'open', label: t('Nur offen') }, + { value: 'closed', label: t('Nur geschlossen') }, + ], + }, + ]; + if (schema && schema.trackers.length > 0) { + configs.push({ + key: 'trackerIds', + label: t('Tracker'), + type: 'multiselect', + options: schema.trackers.map(tr => ({ + value: String(tr.id), + label: tr.name, + })), + placeholder: t('Alle Tracker'), + }); + } + if (schema && schema.categories.length > 0) { + configs.push({ + key: 'categoryIds', + label: t('Kategorie'), + type: 'multiselect', + options: schema.categories.map(cat => ({ + value: String(cat.id), + label: cat.name, + })), + placeholder: t('Alle Kategorien'), + }); + } + return configs; + }, [t, bucket, statusFilter, schema]); + + const _handleFilterChange = useCallback((filterState: ReportFilterState) => { + if (filterState.periodValue) { + setDateFrom(filterState.periodValue.fromDate); + setDateTo(filterState.periodValue.toDate); + } else if (filterState.dateRange) { + setDateFrom(toIsoDate(filterState.dateRange.from)); + setDateTo(toIsoDate(filterState.dateRange.to)); + } + + const f = filterState.filters || {}; + if (typeof f.bucket === 'string' && f.bucket !== bucket) { + setBucket(f.bucket as BucketSize); + } + if (typeof f.statusFilter === 'string' && f.statusFilter !== statusFilter) { + const next = f.statusFilter as '*' | 'open' | 'closed'; + if (next === '*' || next === 'open' || next === 'closed') { + setStatusFilter(next); + } + } + _applyMultiselectFilter(f.trackerIds, trackerIds, setTrackerIds); + _applyMultiselectFilter(f.categoryIds, categoryIds, setCategoryIds); + }, [bucket, statusFilter, trackerIds, categoryIds]); + + // ---- Derived report sections ---------------------------------------- + const sections = useMemo(() => { + if (!stats) return []; + return _buildSections(stats, t); + }, [stats, t]); + + if (!instanceId) { + return
{t('Keine Feature-Instanz ausgewaehlt')}
; + } + + return ( +
+

{t('Redmine -- Statistik')}

+

+ {t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')} +

+ + {schemaError &&
{schemaError}
} + {error &&
{error}
} + + +
+ ); +}; + +export default RedmineStatsView; diff --git a/src/pages/views/redmine/RedmineTicketEditor.tsx b/src/pages/views/redmine/RedmineTicketEditor.tsx new file mode 100644 index 0000000..4ef3245 --- /dev/null +++ b/src/pages/views/redmine/RedmineTicketEditor.tsx @@ -0,0 +1,296 @@ +/** + * Right-pane editor for a single Redmine ticket. + * + * Pulls the selected ticket fresh from the backend (mirror) to get + * custom fields + relations, lets the user edit the primary fields and + * adds a "notes" comment. On save, delegates to ``updateRedmineTicketApi`` + * and calls ``onSaved`` so the parent can refresh its mirror list. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useApiRequest } from '../../../hooks/useApi'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import { + RedmineFieldSchema, + RedmineTicket, + RedmineTicketUpdateBody, + getRedmineTicketApi, + updateRedmineTicketApi, +} from '../../../api/redmineApi'; +import { getTrackerStyle } from './redmineTrackerColor'; + +import styles from './RedmineViews.module.css'; + +interface Props { + instanceId: string; + ticketId: number; + schema: RedmineFieldSchema | null; + baseUrl: string; + onSaved: (updated: RedmineTicket) => void; +} + +export const RedmineTicketEditor: React.FC = ({ + instanceId, + ticketId, + schema, + baseUrl, + onSaved, +}) => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + + const [ticket, setTicket] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Local edit state -- keys mirror the update body. + const [subject, setSubject] = useState(''); + const [description, setDescription] = useState(''); + const [trackerId, setTrackerId] = useState(''); + const [statusId, setStatusId] = useState(''); + const [priorityId, setPriorityId] = useState(''); + const [assignedToId, setAssignedToId] = useState(''); + const [parentIssueId, setParentIssueId] = useState(''); + const [notes, setNotes] = useState(''); + const [customFieldValues, setCustomFieldValues] = useState>({}); + + const _load = useCallback(async () => { + setLoading(true); + setError(null); + setSuccessMsg(null); + try { + const t = await getRedmineTicketApi(request, instanceId, ticketId); + setTicket(t); + setSubject(t.subject || ''); + setDescription(t.description || ''); + setTrackerId(t.trackerId ?? ''); + setStatusId(t.statusId ?? ''); + setPriorityId(t.priorityId ?? ''); + setAssignedToId(t.assignedToId ?? ''); + setParentIssueId(t.parentId ? String(t.parentId) : ''); + setNotes(''); + const cfMap: Record = {}; + for (const cf of t.customFields || []) { + cfMap[cf.id] = cf.value == null ? '' : String(cf.value); + } + setCustomFieldValues(cfMap); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Ticket laden fehlgeschlagen')); + setTicket(null); + } finally { + setLoading(false); + } + }, [request, instanceId, ticketId, t]); + + useEffect(() => { _load(); }, [_load]); + + const tracker = useMemo(() => { + if (!schema || trackerId === '') return null; + return schema.trackers.find(x => x.id === trackerId) || null; + }, [schema, trackerId]); + + const _handleSave = useCallback(async () => { + if (!ticket) return; + setSaving(true); + setError(null); + setSuccessMsg(null); + const body: RedmineTicketUpdateBody = {}; + if (subject !== ticket.subject) body.subject = subject; + if (description !== (ticket.description || '')) body.description = description; + if (trackerId !== '' && trackerId !== ticket.trackerId) body.trackerId = Number(trackerId); + if (statusId !== '' && statusId !== ticket.statusId) body.statusId = Number(statusId); + if (priorityId !== '' && priorityId !== ticket.priorityId) body.priorityId = Number(priorityId); + if (assignedToId !== '' && assignedToId !== ticket.assignedToId) body.assignedToId = Number(assignedToId); + const parentNum = parentIssueId.trim() === '' ? null : Number(parentIssueId); + if (parentNum !== null && !Number.isNaN(parentNum) && parentNum !== ticket.parentId) { + body.parentIssueId = parentNum; + } + if (notes.trim() !== '') body.notes = notes.trim(); + const cfDiff: Record = {}; + for (const cf of ticket.customFields || []) { + const current = cf.value == null ? '' : String(cf.value); + const next = customFieldValues[cf.id] ?? ''; + if (next !== current) cfDiff[cf.id] = next; + } + if (Object.keys(cfDiff).length > 0) body.customFields = cfDiff; + + if (Object.keys(body).length === 0) { + setSaving(false); + setSuccessMsg(t('Keine Aenderungen.')); + return; + } + try { + const updated = await updateRedmineTicketApi(request, instanceId, ticketId, body); + setTicket(updated); + setSuccessMsg(t('Ticket gespeichert.')); + setNotes(''); + onSaved(updated); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Speichern fehlgeschlagen')); + } finally { + setSaving(false); + } + }, [ + ticket, subject, description, trackerId, statusId, priorityId, assignedToId, + parentIssueId, notes, customFieldValues, request, instanceId, ticketId, onSaved, t, + ]); + + const redmineUrl = baseUrl && ticket ? `${baseUrl.replace(/\/$/, '')}/issues/${ticket.id}` : null; + + if (loading) { + return
{t('Ticket wird geladen ...')}
; + } + + if (!ticket) { + return ( +
+ {error &&
{error}
} +
{t('Ticket nicht gefunden.')}
+
+ ); + } + + return ( +
+
+ {tracker && (() => { + const sty = getTrackerStyle(tracker.name); + return ( + + {tracker.name} + + ); + })()} + #{ticket.id} +

{subject || t('(ohne Titel)')}

+ {redmineUrl && ( + + {t('In Redmine oeffnen')} + + )} +
+ + {error &&
{error}
} + {successMsg &&
{successMsg}
} + +
+ + setSubject(e.target.value)} /> + + + + + + + + + + + + + + + setParentIssueId(e.target.value)} + /> + + +