Merge pull request #56 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
0270f59d44
40 changed files with 5004 additions and 71 deletions
|
|
@ -186,6 +186,10 @@ function App() {
|
|||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||
|
||||
{/* Redmine Feature Views */}
|
||||
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||
<Route path="browser" element={<FeatureViewPage view="browser" />} />
|
||||
|
||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||
</Route>
|
||||
|
|
|
|||
14
src/api.ts
14
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();
|
||||
|
|
|
|||
398
src/api/redmineApi.ts
Normal file
398
src/api/redmineApi.ts
Normal file
|
|
@ -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<string, any> | 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<string, any> | 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<string, number>;
|
||||
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<any>) => Promise<any>;
|
||||
|
||||
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineConfigDto> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
|
||||
}
|
||||
|
||||
export async function updateRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineConfigUpdateRequest,
|
||||
): Promise<RedmineConfigDto> {
|
||||
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<RedmineConnectionTestResult> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineSchemaApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
forceRefresh = false,
|
||||
): Promise<RedmineFieldSchema> {
|
||||
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<RedmineSyncResult> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/sync`,
|
||||
method: 'post',
|
||||
params: force ? { force: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRedmineSyncStatusApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineSyncStatus> {
|
||||
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<RedmineTicket[]> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
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<RedmineTicket> {
|
||||
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<number, any>;
|
||||
}
|
||||
|
||||
export async function updateRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
body: RedmineTicketUpdateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
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<number, any>;
|
||||
}
|
||||
|
||||
export async function createRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineTicketCreateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
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<RedmineStats> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@ export interface NodeTypeParameter {
|
|||
export interface PortField {
|
||||
name: string;
|
||||
type: string;
|
||||
description: Record<string, string>;
|
||||
/** Plain string or per-language map from the API catalog. */
|
||||
description: string | Record<string, string>;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
archiveVersion,
|
||||
createTemplateFromWorkflow,
|
||||
copyTemplate,
|
||||
importWorkflowFromFile,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
|
|
@ -122,6 +123,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
instanceId,
|
||||
mandateId: mandateId || '',
|
||||
featureInstanceId: instanceId,
|
||||
surface: 'graphEditor',
|
||||
}), [instanceId, mandateId]);
|
||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||
|
|
@ -722,6 +724,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
hideTabs={['chats']}
|
||||
onFileSelect={onFileSelect}
|
||||
onSourcesChanged={onSourcesChanged}
|
||||
onWorkflowImportedFromFile={async (workflowId) => {
|
||||
await loadWorkflows();
|
||||
handleWorkflowSelect(workflowId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -771,6 +777,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ 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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{configurableSelected && selectedNode && (
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ interface FlowCanvasProps {
|
|||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||
highlightedNodeIds?: Record<string, string>;
|
||||
/** 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> | boolean;
|
||||
}
|
||||
|
||||
const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||
|
|
@ -162,6 +167,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
|||
getCategoryIcon,
|
||||
onSelectionChange,
|
||||
highlightedNodeIds,
|
||||
onExternalDrop,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -256,8 +262,31 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
|
|||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||
} catch (_) {}
|
||||
},
|
||||
[onDropNodeType, panOffset, zoom]
|
||||
[onDropNodeType, onExternalDrop, panOffset, zoom]
|
||||
);
|
||||
|
||||
const handleHandleMouseDown = useCallback(
|
||||
|
|
|
|||
|
|
@ -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<NodeConfigPanelProps> = ({ node,
|
|||
[onParametersChange]
|
||||
);
|
||||
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | 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 (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
{showNameField && (
|
||||
|
|
@ -101,6 +111,47 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
{hasPortInfo && (
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.75rem' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #666)', fontWeight: 600, padding: '0.25rem 0' }}>
|
||||
{t('Datenfluss (Eingabe / Ausgabe)')}
|
||||
</summary>
|
||||
{inputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B07'} {t('Eingabe')}
|
||||
</div>
|
||||
{inputPortEntries.map(([idx, def]) => (
|
||||
<_PortFieldList
|
||||
key={`in-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={def?.accepts ?? []}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{outputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B06'} {t('Ausgabe')}
|
||||
</div>
|
||||
{outputPortEntries.map(([idx, def]) => (
|
||||
<_PortFieldList
|
||||
key={`out-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={def?.schema ? [def.schema] : []}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{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<NodeConfigPanelProps> = ({ node,
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface _PortFieldListProps {
|
||||
portIndex: number;
|
||||
schemaNames: string[];
|
||||
catalog: Record<string, PortSchema>;
|
||||
emptyLabel: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
|
||||
if (!schemaNames.length) return null;
|
||||
return (
|
||||
<div style={{ marginLeft: 4, marginBottom: 4 }}>
|
||||
<div style={{ color: '#888', fontSize: '0.7rem' }}>
|
||||
{`#${portIndex} `}{schemaNames.join(' | ')}
|
||||
</div>
|
||||
{schemaNames.map((name) => {
|
||||
const schema = catalog[name];
|
||||
const fields = schema?.fields ?? [];
|
||||
if (name === 'Transit') {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: '#999', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
{'\u00B7 Transit (durchgereichte Daten)'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!fields.length) {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: '#bbb', fontSize: '0.7rem' }}>
|
||||
{`\u00B7 ${emptyLabel}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
|
||||
{fields.map((f) => (
|
||||
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: '#555' }}>
|
||||
<span style={{ fontFamily: 'monospace', color: '#222' }}>{f.name}</span>
|
||||
<span style={{ color: '#999' }}>{`: ${f.type}`}</span>
|
||||
{!f.required && <span style={{ color: '#bbb' }}>{' (optional)'}</span>}
|
||||
{f.description && (
|
||||
<div style={{ color: '#888', marginLeft: 4 }}>
|
||||
{getLabel(f.description, language)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</span>
|
||||
)}
|
||||
<span className={styles.actions}>
|
||||
{!multiSelected && inlineCustomActions.slice(0, 3).map((a) => {
|
||||
const Icon = a.icon;
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
className={`${styles.actionBtn} ${a.danger ? styles.danger : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (sel.actions) {
|
||||
void runAction(a, _buildActionTarget(), sel.actions.actionCtx);
|
||||
}
|
||||
}}
|
||||
title={resolveActionLabel(a, _buildActionTarget())}
|
||||
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||
>
|
||||
<Icon size={12} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); _beginRename(); }} title={t('Umbenennen')}>
|
||||
<FaPen />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const inlineRenameRegistryRef = useRef<Map<string, () => 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<Set<string>>(new Set());
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(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 (
|
||||
<div className={styles.folderTree}>
|
||||
<div className={styles.folderTree} ref={containerRef} tabIndex={-1}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
|
||||
<span
|
||||
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
|
||||
|
|
@ -910,6 +1145,26 @@ export default function FolderTree({
|
|||
))}
|
||||
</div>
|
||||
<PromptDialog />
|
||||
{menuState && (
|
||||
<FileActionContextMenu
|
||||
anchor={menuState.anchor}
|
||||
actions={menuActions}
|
||||
target={menuState.target}
|
||||
ctx={actionCtx}
|
||||
onClose={_closeMenu}
|
||||
title={menuState.title}
|
||||
confirm={_windowConfirm}
|
||||
/>
|
||||
)}
|
||||
<FileActionBottomSheet
|
||||
open={sheetState !== null}
|
||||
actions={sheetActions}
|
||||
target={sheetState?.target ?? { files: [], folders: [] }}
|
||||
ctx={actionCtx}
|
||||
onClose={_closeSheet}
|
||||
title={sheetState?.title}
|
||||
confirm={_windowConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
83
src/components/FolderTree/actions/FileActionBottomSheet.tsx
Normal file
83
src/components/FolderTree/actions/FileActionBottomSheet.tsx
Normal file
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
||||
export const FileActionBottomSheet: React.FC<Props> = ({
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.backdrop} onClick={onClose} />
|
||||
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className={styles.handle} aria-hidden="true" />
|
||||
{title && <div className={styles.title}>{title}</div>}
|
||||
{actions.length === 0 ? (
|
||||
<div className={styles.empty}>—</div>
|
||||
) : (
|
||||
actions.map((a) => {
|
||||
const Icon = a.icon;
|
||||
const cls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={cls}
|
||||
onClick={() => _handleClick(a)}
|
||||
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||
>
|
||||
<span className={styles.icon}>
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
146
src/components/FolderTree/actions/FileActionContextMenu.tsx
Normal file
146
src/components/FolderTree/actions/FileActionContextMenu.tsx
Normal file
|
|
@ -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<boolean>;
|
||||
}
|
||||
|
||||
export const FileActionContextMenu: React.FC<Props> = ({
|
||||
anchor,
|
||||
actions,
|
||||
target,
|
||||
ctx,
|
||||
onClose,
|
||||
title,
|
||||
confirm,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={styles.menu}
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
style={{ left: adjusted.x, top: adjusted.y }}
|
||||
>
|
||||
{title && <div className={styles.header}>{title}</div>}
|
||||
{actions.length === 0 ? (
|
||||
<div className={styles.empty}>—</div>
|
||||
) : (
|
||||
actions.map((a, idx) => {
|
||||
const Icon = a.icon;
|
||||
const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
|
||||
return (
|
||||
<React.Fragment key={a.id}>
|
||||
{idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && (
|
||||
<div className={styles.divider} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={isDangerCls}
|
||||
onClick={() => _handleClick(a)}
|
||||
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||
>
|
||||
<span className={styles.icon}>
|
||||
<Icon size={13} />
|
||||
</span>
|
||||
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
|
||||
{a.shortcut && <span className={styles.shortcut}>{_formatShortcut(a.shortcut)}</span>}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 ? '' : '+');
|
||||
}
|
||||
218
src/components/FolderTree/actions/registry.ts
Normal file
218
src/components/FolderTree/actions/registry.ts
Normal file
|
|
@ -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<void>;
|
||||
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||
onSendToChat?: (
|
||||
items: Array<{ id: string; type: 'file' | 'folder'; name: string }>,
|
||||
) => void;
|
||||
/** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */
|
||||
t?: (key: string, vars?: Record<string, string>) => 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<BuiltinCallbacks['t']> = (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<BuiltinCallbacks['t']> = 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<boolean>,
|
||||
): Promise<void> {
|
||||
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 };
|
||||
87
src/components/FolderTree/actions/types.ts
Normal file
87
src/components/FolderTree/actions/types.ts
Normal file
|
|
@ -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> | 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: [] };
|
||||
}
|
||||
75
src/components/FolderTree/actions/usePointerLongPress.ts
Normal file
75
src/components/FolderTree/actions/usePointerLongPress.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Long-Press-Erkennung über Pointer-Events.
|
||||
*
|
||||
* Liefert Handler die direkt auf `<div>` 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<number | null>(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,
|
||||
};
|
||||
}
|
||||
25
src/components/FolderTree/actions/useViewMode.ts
Normal file
25
src/components/FolderTree/actions/useViewMode.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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<T = any> {
|
||||
|
|
@ -1721,6 +1729,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
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<T extends Record<string, any>>({
|
|||
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<T extends Record<string, any>>({
|
|||
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 (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
|
||||
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)}
|
||||
</td>
|
||||
);
|
||||
|
|
@ -2543,9 +2579,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
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 (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
|
||||
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)}
|
||||
</td>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
|
||||
const FilesTab: React.FC<FilesTabProps> = ({ 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<FilesTabProps> = ({ 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<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
onFolderScopeChange={_onFolderScopeChange}
|
||||
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
||||
onSendToChat={onSendToChat}
|
||||
customActions={_customActions}
|
||||
udbContext={context.surface}
|
||||
/>
|
||||
|
||||
{_fileNodes.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<div
|
||||
onClick={() => 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}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('Container in Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
||||
const newId = await ctx.onAddFeatureTable(
|
||||
featureNode,
|
||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||
);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||
>
|
||||
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
||||
const newId = await ctx.onAddFeatureTable(
|
||||
featureNode,
|
||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||
);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div>
|
||||
<div
|
||||
onClick={_onToggle}
|
||||
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', _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')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('Container in Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
||||
const newId = await ctx.onAddFeatureTable(
|
||||
featureNode,
|
||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||
{ labelOverride: _chatPayload.label },
|
||||
);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||
>
|
||||
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
||||
const newId = await ctx.onAddFeatureTable(
|
||||
featureNode,
|
||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||
{ labelOverride: _chatPayload.label },
|
||||
);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && records && records.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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<UnifiedDataBarProps> = ({
|
|||
onSendToChat_Files,
|
||||
onSendToChat_FeatureSource,
|
||||
onAttachDataSource,
|
||||
onWorkflowImportedFromFile,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
context={context}
|
||||
onFileSelect={onFileSelect}
|
||||
onSendToChat={onSendToChat_Files}
|
||||
onWorkflowImported={onWorkflowImportedFromFile}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.feature.workspace.dashboard': <FaPlay />,
|
||||
'page.feature.workspace.editor': <FaPlay />,
|
||||
'feature.workspace': <FaPlay />,
|
||||
|
||||
// Feature pages - Redmine
|
||||
'feature.redmine': <FaClipboardList />,
|
||||
'page.feature.redmine.stats': <FaChartBar />,
|
||||
'page.feature.redmine.browser': <FaProjectDiagram />,
|
||||
'page.feature.redmine.settings': <FaCog />,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -66,12 +66,19 @@ export function useConfirm() {
|
|||
|
||||
return (
|
||||
<div
|
||||
onClick={_handleCancel}
|
||||
// Backdrop intentionally has NO onClick handler: this confirm dialog
|
||||
// must only close via the explicit Cancel/Confirm buttons or Escape.
|
||||
// Accidental outside-clicks should NOT dismiss a decision the user
|
||||
// hasn't made yet. (UX policy for all modal dialogs in PORTA.)
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') _handleCancel();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
|
|||
|
|
@ -73,12 +73,19 @@ export function usePrompt() {
|
|||
|
||||
return (
|
||||
<div
|
||||
onClick={_handleCancel}
|
||||
// Backdrop intentionally has NO onClick handler: this dialog must only
|
||||
// close via the explicit Cancel button, the Escape key on the input,
|
||||
// or the Confirm button. Clicking outside the dialog should NOT
|
||||
// dismiss the user's input. (UX policy for all modal forms in PORTA.)
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') _handleCancel();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, ViewComponent>> = {
|
|||
dossier: CommcoachDossierView,
|
||||
settings: CommcoachSettingsView,
|
||||
},
|
||||
redmine: {
|
||||
stats: RedmineStatsView,
|
||||
browser: RedmineBrowserView,
|
||||
settings: RedmineSettingsView,
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
721
src/pages/views/redmine/RedmineBrowserView.tsx
Normal file
721
src/pages/views/redmine/RedmineBrowserView.tsx
Normal file
|
|
@ -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<number, boolean>,
|
||||
): 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<RedmineConfigDto | null>(null);
|
||||
const [schema, setSchema] = useState<RedmineFieldSchema | null>(null);
|
||||
const [tickets, setTickets] = useState<RedmineTicket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [period, setPeriod] = useState<PeriodValue | null>(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<Set<number> | null>(null);
|
||||
const [selectedAssigneeIds, setSelectedAssigneeIds] = useState<Set<number>>(new Set());
|
||||
const [selectedRelTypes, setSelectedRelTypes] = useState<Set<string>>(
|
||||
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<Set<string>>(new Set());
|
||||
|
||||
// UI state
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(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<number, boolean>();
|
||||
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<string, string>();
|
||||
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<number, RedmineTicket>();
|
||||
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 <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.browserPage}>
|
||||
<div className={styles.browserHeader}>
|
||||
<div>
|
||||
<h2 className={styles.heading} style={{ margin: 0 }}>{t('Redmine -- Ticket-Browser')}</h2>
|
||||
<p className={styles.subheading} style={{ margin: 0 }}>
|
||||
{t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', {
|
||||
name: schema?.rootTrackerName || config?.rootTrackerName || '—',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #718096)' }}>
|
||||
{t('{count} von {total} Tickets sichtbar', {
|
||||
count: filteredTickets.length,
|
||||
total: tickets.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.alertErr}>{error}</div>}
|
||||
|
||||
<div className={styles.browserFilters}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label>{t('Zeitraum (letzte Aenderung)')}</label>
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={(next) => setPeriod(next)}
|
||||
direction="past"
|
||||
defaultPreset={{ kind: 'lastQuarter' }}
|
||||
enabledPresets={[
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||
]}
|
||||
placeholder={t('Alle Zeiten')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label>Status</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="*">{t('Alle')}</option>
|
||||
<option value="open">{t('Nur offen')}</option>
|
||||
<option value="closed">{t('Nur geschlossen')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{schema && schema.trackers.length > 0 && (
|
||||
<div className={styles.filterGroup} style={{ minWidth: 240 }}>
|
||||
<label>{t('Tracker (Klick blendet aus -- Root bleibt immer aktiv)')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{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 (
|
||||
<button
|
||||
key={tr.id}
|
||||
type="button"
|
||||
onClick={() => !isRoot && _toggleTracker(tr.id)}
|
||||
style={{
|
||||
padding: '2px 9px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${active ? sty.border : 'var(--border-color, #e2e8f0)'}`,
|
||||
background: active ? sty.bg : '#fff',
|
||||
color: active ? sty.fg : 'var(--text-secondary, #4a5568)',
|
||||
fontSize: '0.75rem',
|
||||
cursor: isRoot ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
opacity: isRoot ? 0.9 : 1,
|
||||
textDecoration: !active ? 'line-through' : 'none',
|
||||
}}
|
||||
title={
|
||||
isRoot
|
||||
? t('Root-Tracker -- immer aktiv')
|
||||
: active
|
||||
? t('Klicken, um {name} auszublenden', { name: tr.name })
|
||||
: t('Klicken, um {name} wieder anzuzeigen', { name: tr.name })
|
||||
}
|
||||
>
|
||||
{isRoot ? `⚓ ${tr.name}` : tr.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schema && schema.users.length > 0 && (
|
||||
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
|
||||
<label>{t('Zuweisung')}</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
multiple
|
||||
size={Math.min(4, schema.users.length)}
|
||||
value={Array.from(selectedAssigneeIds).map(String)}
|
||||
onChange={e => {
|
||||
const ids = Array.from(e.target.selectedOptions).map(o => Number(o.value));
|
||||
setSelectedAssigneeIds(new Set(ids));
|
||||
}}
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
{schema.users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sprintOptions.length > 0 && (
|
||||
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
|
||||
<label>{t('Sprint (Zielversion)')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 88, overflow: 'auto' }}>
|
||||
{sprintOptions.map(sp => {
|
||||
const active = selectedSprints.has(sp.value);
|
||||
return (
|
||||
<button
|
||||
key={sp.value}
|
||||
type="button"
|
||||
onClick={() => _toggleSprint(sp.value)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
|
||||
background: active ? '#4A6FA5' : '#fff',
|
||||
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
title={sp.label}
|
||||
>
|
||||
{sp.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedSprints.size === 0 && (
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary, #718096)' }}>
|
||||
{t('keine Auswahl = alle Sprints')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.filterGroup} style={{ minWidth: 260 }}>
|
||||
<label>{t('Beziehungsarten')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{RELATION_TYPE_OPTIONS.map(rt => {
|
||||
const active = selectedRelTypes.has(rt.value);
|
||||
return (
|
||||
<button
|
||||
key={rt.value}
|
||||
type="button"
|
||||
onClick={() => _toggleRelType(rt.value)}
|
||||
title={t('Beziehungstyp ein-/ausschalten -- nur aktive Typen werden im Baum verfolgt')}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
|
||||
background: active ? '#4A6FA5' : '#fff',
|
||||
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{rt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<button className={styles.btnSecondary} onClick={_resetFilters}>
|
||||
{t('Filter zuruecksetzen')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.browserBody}>
|
||||
{/* Left: tree */}
|
||||
<div className={styles.browserTreeContainer}>
|
||||
<div className={styles.browserToolbar}>
|
||||
<span>
|
||||
{t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', {
|
||||
rows: flatRows.length,
|
||||
orphans: forest.orphanCount,
|
||||
})}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<button className={styles.linkBtn} onClick={_expandAll} disabled={flatRows.length === 0}>
|
||||
{t('Alle ausklappen')}
|
||||
</button>
|
||||
<button className={styles.linkBtn} onClick={_collapseAll} disabled={flatRows.length === 0}>
|
||||
{t('Einklappen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.treeScroll}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>{t('Baum wird aufgebaut ...')}</div>
|
||||
) : flatRows.length === 0 ? (
|
||||
<div className={styles.placeholder}>
|
||||
{t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.treeGrid}>
|
||||
<div className={styles.treeHeader}>
|
||||
<div>{t('Ticket')}</div>
|
||||
<div>Status</div>
|
||||
<div>{t('Prio')}</div>
|
||||
<div>{t('Zuweisung')}</div>
|
||||
<div>{t('Geaendert')}</div>
|
||||
<div>{t('Beziehung')}</div>
|
||||
</div>
|
||||
{flatRows.map(row => (
|
||||
<TreeRow
|
||||
key={row.node.id}
|
||||
row={row}
|
||||
ticket={ticketsById.get(row.node.id) || null}
|
||||
selected={selectedId === row.node.id}
|
||||
expanded={expanded.has(row.node.id)}
|
||||
onToggle={_toggleExpand}
|
||||
onSelect={setSelectedId}
|
||||
rootTrackerId={rootTrackerId}
|
||||
schemaStatusClosedById={schemaStatusClosedById}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: editor */}
|
||||
<div className={styles.browserEditorContainer}>
|
||||
{selectedId == null ? (
|
||||
<div className={styles.browserToolbar}>{t('Ticket links auswaehlen')}</div>
|
||||
) : selectedId === ORPHAN_ROOT_ID ? (
|
||||
<div className={styles.browserToolbar}>
|
||||
{t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', {
|
||||
count: forest.orphanCount,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RedmineTicketEditor
|
||||
instanceId={instanceId}
|
||||
ticketId={selectedId}
|
||||
schema={schema}
|
||||
baseUrl={config?.baseUrl || ''}
|
||||
onSaved={_handleTicketSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<number, boolean>;
|
||||
}
|
||||
|
||||
const _TreeRowImpl: React.FC<TreeRowProps> = ({
|
||||
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 (
|
||||
<div className={rowClass} onClick={_handleSelect}>
|
||||
<div className={styles.treeCell}>
|
||||
{Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${styles.indent}${indentLines[i] ? ` ${styles.line ?? 'line'}` : ''}`}
|
||||
/>
|
||||
))}
|
||||
{depth > 0 && (
|
||||
<span className={`${styles.indentElbow}${row.isLast ? '' : ` ${styles.mid ?? 'mid'}`}`}>
|
||||
<span className={styles.elbowBar} />
|
||||
</span>
|
||||
)}
|
||||
{hasChildren ? (
|
||||
<button className={styles.treeToggle} onClick={_handleToggle} title={expanded ? 'Einklappen' : 'Ausklappen'}>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.treeTogglePlaceholder} />
|
||||
)}
|
||||
<span
|
||||
className={`${styles.trackerPill} ${isOrphanRoot ? styles.orphanPill : ''}`}
|
||||
style={
|
||||
!isOrphanRoot && ticket?.trackerName
|
||||
? (() => {
|
||||
const sty = getTrackerStyle(ticket.trackerName);
|
||||
return {
|
||||
background: sty.bg,
|
||||
color: sty.fg,
|
||||
border: `1px solid ${sty.border}`,
|
||||
};
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')}
|
||||
</span>
|
||||
<span className={styles.ticketId}>
|
||||
{isOrphanRoot ? '—' : `#${ticket?.id}`}
|
||||
</span>
|
||||
<span className={styles.ticketSubject} title={isOrphanRoot ? '' : (ticket?.subject || '')}>
|
||||
{isOrphanRoot
|
||||
? `Tickets ohne Verbindung zu einer User Story (${node.children.length})`
|
||||
: (ticket?.subject || '(ohne Titel)')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{isOrphanRoot ? <span className={styles.muted}>—</span>
|
||||
: ticket?.statusName
|
||||
? <span className={`${styles.statusPill}${isClosed ? ` ${styles.closed ?? 'closed'}` : ''}`}>{ticket.statusName}</span>
|
||||
: <span className={styles.muted}>—</span>}
|
||||
</div>
|
||||
<div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.priorityName || '—')}</div>
|
||||
<div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.assignedToName || <span className={styles.muted}>—</span>)}</div>
|
||||
<div style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
|
||||
</div>
|
||||
<div>
|
||||
{isOrphanRoot
|
||||
? <span className={styles.relBadge}>virtuell</span>
|
||||
: isRootTracker && depth === 0
|
||||
? <span className={`${styles.relBadge} ${styles.root ?? 'root'}`}>Root</span>
|
||||
: node.relType
|
||||
? <span className={styles.relBadge}>{node.dir === 'in' ? '←' : '→'} {node.relType}</span>
|
||||
: <span className={styles.muted}>—</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
349
src/pages/views/redmine/RedmineSettingsView.tsx
Normal file
349
src/pages/views/redmine/RedmineSettingsView.tsx
Normal file
|
|
@ -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<RedmineConfigDto | null>(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<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<RedmineConnectionTestResult | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<RedmineSyncResult | null>(null);
|
||||
const [syncStatus, setSyncStatus] = useState<RedmineSyncStatus | null>(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<string, any> = {
|
||||
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 <div className={styles.loading}>{t('Einstellungen werden geladen ...')}</div>;
|
||||
}
|
||||
|
||||
const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId;
|
||||
const canSync = canTest;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h2 className={styles.heading}>{t('Redmine -- Einstellungen')}</h2>
|
||||
<p className={styles.subheading}>
|
||||
{t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')}
|
||||
</p>
|
||||
|
||||
{error && <div className={styles.alertErr}>{error}</div>}
|
||||
{success && <div className={styles.alertOk}>{success}</div>}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Verbindung')}</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Basis-URL')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder="https://redmine.example.com"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className={styles.hint}>{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Projekt-ID oder -Slug')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
placeholder="logobject-mars"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('Wurzel-Tracker (Name)')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={rootTrackerName}
|
||||
onChange={e => setRootTrackerName(e.target.value)}
|
||||
placeholder="Userstory"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
{t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('API-Key')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={apiKey}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
{t('Wird verschluesselt gespeichert. Status: ')}
|
||||
{config?.hasApiKey ? <strong>{t('gesetzt')}</strong> : <em>{t('nicht gesetzt')}</em>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<button className={styles.btn} onClick={_save} disabled={saving}>
|
||||
{saving ? t('Speichere ...') : t('Speichern')}
|
||||
</button>
|
||||
<button className={styles.btnSecondary} onClick={_test} disabled={!canTest || testing}>
|
||||
{testing ? t('Teste ...') : t('Verbindung testen')}
|
||||
</button>
|
||||
{config?.id && (
|
||||
<button className={styles.btnDanger} onClick={_delete}>
|
||||
{t('Konfiguration loeschen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div style={{ marginTop: '0.85rem' }}>
|
||||
{testResult.ok ? (
|
||||
<div className={styles.alertOk}>
|
||||
<strong>{t('Verbindung OK')}.</strong>{' '}
|
||||
{testResult.user?.name && <>{t('Angemeldet als')} <strong>{testResult.user.name}</strong>. </>}
|
||||
{testResult.project?.name && <>{t('Projekt')}: <strong>{testResult.project.name}</strong>.</>}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.alertErr}>
|
||||
<strong>{t('Verbindung fehlgeschlagen')}.</strong>{' '}
|
||||
{testResult.message || testResult.reason || ''}
|
||||
{testResult.status ? ` (HTTP ${testResult.status})` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{t('Mirror-Sync')}</h3>
|
||||
<p className={styles.hint} style={{ marginBottom: '0.85rem' }}>
|
||||
{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.')}
|
||||
</p>
|
||||
|
||||
<div className={styles.kvGrid} style={{ marginBottom: '0.85rem' }}>
|
||||
<div className={styles.kvLabel}>{t('Letzter Sync')}:</div>
|
||||
<div className={styles.kvValue}>{_formatTs(config?.lastSyncAt)}</div>
|
||||
<div className={styles.kvLabel}>{t('Letzter Full-Sync')}:</div>
|
||||
<div className={styles.kvValue}>{_formatTs(config?.lastFullSyncAt)}</div>
|
||||
<div className={styles.kvLabel}>{t('Letzte Sync-Dauer')}:</div>
|
||||
<div className={styles.kvValue}>{_formatDuration(syncStatus?.lastSyncDurationMs)}</div>
|
||||
<div className={styles.kvLabel}>{t('Tickets im Mirror')}:</div>
|
||||
<div className={styles.kvValue}>{syncStatus?.mirroredTicketCount ?? '-'}</div>
|
||||
<div className={styles.kvLabel}>{t('Beziehungen im Mirror')}:</div>
|
||||
<div className={styles.kvValue}>{syncStatus?.mirroredRelationCount ?? '-'}</div>
|
||||
{config?.lastSyncErrorMessage && (
|
||||
<>
|
||||
<div className={styles.kvLabel}>{t('Letzter Fehler')}:</div>
|
||||
<div className={styles.kvValue} style={{ color: '#c53030' }}>{config.lastSyncErrorMessage}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<button className={styles.btn} onClick={() => _runSync(false)} disabled={!canSync || syncing}>
|
||||
{syncing ? t('Synchronisiere ...') : t('Sync starten (inkrementell)')}
|
||||
</button>
|
||||
<button className={styles.btnSecondary} onClick={() => _runSync(true)} disabled={!canSync || syncing}>
|
||||
{t('Full-Sync (alle Tickets)')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncResult && (
|
||||
<div className={styles.alertInfo} style={{ marginTop: '0.85rem' }}>
|
||||
<strong>{syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:</strong>{' '}
|
||||
{syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '}
|
||||
{t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canSync && (
|
||||
<div className={styles.hint} style={{ marginTop: '0.85rem' }}>
|
||||
{t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedmineSettingsView;
|
||||
395
src/pages/views/redmine/RedmineStatsView.tsx
Normal file
395
src/pages/views/redmine/RedmineStatsView.tsx
Normal file
|
|
@ -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, any>) => 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<string>();
|
||||
stats.statusByTracker.forEach(row => {
|
||||
Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k));
|
||||
});
|
||||
const totals: Record<string, number> = {};
|
||||
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<RedmineFieldSchema | null>(null);
|
||||
const [schemaError, setSchemaError] = useState<string | null>(null);
|
||||
|
||||
const [stats, setStats] = useState<RedmineStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [dateFrom, setDateFrom] = useState<string | undefined>(undefined);
|
||||
const [dateTo, setDateTo] = useState<string | undefined>(undefined);
|
||||
const [bucket, setBucket] = useState<BucketSize>('week');
|
||||
const [trackerIds, setTrackerIds] = useState<number[]>([]);
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||
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<ReportDateRangeSelectorConfig>(() => ({
|
||||
enabled: true,
|
||||
direction: 'past',
|
||||
defaultPresetKind: 'thisQuarter',
|
||||
enabledPresets: [
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||
],
|
||||
}), []);
|
||||
|
||||
const filterConfigs = useMemo<ReportFilterConfig[]>(() => {
|
||||
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<ReportSection[]>(() => {
|
||||
if (!stats) return [];
|
||||
return _buildSections(stats, t);
|
||||
}, [stats, t]);
|
||||
|
||||
if (!instanceId) {
|
||||
return <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pageWide}>
|
||||
<h2 className={styles.heading}>{t('Redmine -- Statistik')}</h2>
|
||||
<p className={styles.subheading}>
|
||||
{t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')}
|
||||
</p>
|
||||
|
||||
{schemaError && <div className={styles.alertErr}>{schemaError}</div>}
|
||||
{error && <div className={styles.alertErr}>{error}</div>}
|
||||
|
||||
<FormGeneratorReport
|
||||
title={stats
|
||||
? t('{total} Tickets ({open} offen, {closed} geschlossen)', {
|
||||
total: stats.kpis.total,
|
||||
open: stats.kpis.open,
|
||||
closed: stats.kpis.closed,
|
||||
})
|
||||
: undefined}
|
||||
sections={sections}
|
||||
loading={loading}
|
||||
noDataMessage={t('Keine Tickets im Mirror. Starte zuerst den Sync auf der Einstellungen-Seite.')}
|
||||
dateRangeSelector={dateRangeSelector}
|
||||
filters={filterConfigs}
|
||||
onFilterChange={_handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedmineStatsView;
|
||||
296
src/pages/views/redmine/RedmineTicketEditor.tsx
Normal file
296
src/pages/views/redmine/RedmineTicketEditor.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
instanceId,
|
||||
ticketId,
|
||||
schema,
|
||||
baseUrl,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const [ticket, setTicket] = useState<RedmineTicket | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Local edit state -- keys mirror the update body.
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [trackerId, setTrackerId] = useState<number | ''>('');
|
||||
const [statusId, setStatusId] = useState<number | ''>('');
|
||||
const [priorityId, setPriorityId] = useState<number | ''>('');
|
||||
const [assignedToId, setAssignedToId] = useState<number | ''>('');
|
||||
const [parentIssueId, setParentIssueId] = useState<string>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<number, string>>({});
|
||||
|
||||
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<number, string> = {};
|
||||
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<number, any> = {};
|
||||
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 <div className={styles.loading}>{t('Ticket wird geladen ...')}</div>;
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className={styles.editorScroll}>
|
||||
{error && <div className={styles.alertErr}>{error}</div>}
|
||||
<div className={styles.placeholder}>{t('Ticket nicht gefunden.')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.editorScroll}>
|
||||
<div className={styles.editorHeader}>
|
||||
{tracker && (() => {
|
||||
const sty = getTrackerStyle(tracker.name);
|
||||
return (
|
||||
<span
|
||||
className={styles.trackerPill}
|
||||
style={{ background: sty.bg, color: sty.fg, border: `1px solid ${sty.border}` }}
|
||||
>
|
||||
{tracker.name}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className={styles.ticketId}>#{ticket.id}</span>
|
||||
<h3>{subject || t('(ohne Titel)')}</h3>
|
||||
{redmineUrl && (
|
||||
<a
|
||||
href={redmineUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.btnSecondary}
|
||||
style={{ textDecoration: 'none', fontSize: '0.8rem' }}
|
||||
>
|
||||
{t('In Redmine oeffnen')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.alertErr}>{error}</div>}
|
||||
{successMsg && <div className={styles.alertOk}>{successMsg}</div>}
|
||||
|
||||
<div className={styles.editorGrid}>
|
||||
<label>{t('Titel')}</label>
|
||||
<input className={styles.input} value={subject} onChange={e => setSubject(e.target.value)} />
|
||||
|
||||
<label>{t('Tracker')}</label>
|
||||
<select className={styles.select} value={trackerId} onChange={e => setTrackerId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||
<option value="">{t('(unveraendert)')}</option>
|
||||
{schema?.trackers.map(tr => (
|
||||
<option key={tr.id} value={tr.id}>{tr.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label>Status</label>
|
||||
<select className={styles.select} value={statusId} onChange={e => setStatusId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||
<option value="">{t('(unveraendert)')}</option>
|
||||
{schema?.statuses.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}{s.isClosed ? ' (geschlossen)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label>{t('Prioritaet')}</label>
|
||||
<select className={styles.select} value={priorityId} onChange={e => setPriorityId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||
<option value="">{t('(unveraendert)')}</option>
|
||||
{schema?.priorities.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label>{t('Zuweisung')}</label>
|
||||
<select className={styles.select} value={assignedToId} onChange={e => setAssignedToId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||
<option value="">{t('(nicht zugewiesen)')}</option>
|
||||
{schema?.users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label>{t('Uebergeordnet')}</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="number"
|
||||
value={parentIssueId}
|
||||
placeholder={t('Ticket-ID oder leer')}
|
||||
onChange={e => setParentIssueId(e.target.value)}
|
||||
/>
|
||||
|
||||
<label className={styles.fullRow} style={{ paddingTop: '0.45rem' }}>{t('Beschreibung')}</label>
|
||||
<textarea
|
||||
className={`${styles.textarea} ${styles.fullRow}`}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
{ticket.customFields.length > 0 && (
|
||||
<div className={styles.fullRow} style={{ marginTop: '0.4rem', fontSize: '0.82rem', fontWeight: 600, color: 'var(--text-primary, #1a202c)' }}>
|
||||
{t('Benutzerdefinierte Felder')}
|
||||
</div>
|
||||
)}
|
||||
{ticket.customFields.map(cf => (
|
||||
<React.Fragment key={cf.id}>
|
||||
<label>{cf.name}</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={customFieldValues[cf.id] ?? ''}
|
||||
onChange={e => setCustomFieldValues(prev => ({ ...prev, [cf.id]: e.target.value }))}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<label className={styles.fullRow} style={{ paddingTop: '0.75rem' }}>{t('Kommentar (wird an Redmine geschickt)')}</label>
|
||||
<textarea
|
||||
className={`${styles.textarea} ${styles.fullRow}`}
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder={t('Optional -- wird beim Speichern als Journal-Entry angelegt')}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
|
||||
{ticket.relations.length > 0 && (
|
||||
<>
|
||||
<label>{t('Beziehungen')}</label>
|
||||
<ul className={styles.relationList}>
|
||||
{ticket.relations.map(r => (
|
||||
<li key={r.id}>
|
||||
<strong>{r.relationType}</strong>:{' '}
|
||||
{r.issueId === ticket.id
|
||||
? `#${r.issueId} -> #${r.issueToId}`
|
||||
: `#${r.issueId} -> #${r.issueToId}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonRow}>
|
||||
<button className={styles.btnSecondary} onClick={_load} disabled={saving}>
|
||||
{t('Zuruecksetzen')}
|
||||
</button>
|
||||
<button className={styles.btn} onClick={_handleSave} disabled={saving}>
|
||||
{saving ? t('Speichere ...') : t('Speichern')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// A stable-ish color mapping for the most common tracker names. Unknown
|
||||
// trackers fall back to a neutral blue.
|
||||
export default RedmineTicketEditor;
|
||||
514
src/pages/views/redmine/RedmineViews.module.css
Normal file
514
src/pages/views/redmine/RedmineViews.module.css
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
.page {
|
||||
padding: 1.25rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* Wide variant: take all available width. Used by data-heavy views (Stats,
|
||||
* Browser) where columns and charts benefit from the extra real estate. */
|
||||
.pageWide {
|
||||
padding: 1.25rem;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--text-primary, #1a202c);
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
margin: -0.75rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--text-primary, #1a202c);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--text-primary, #1a202c);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #718096);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: #fff;
|
||||
color: var(--text-primary, #1a202c);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4A6FA5);
|
||||
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.18);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: var(--primary-color, #4A6FA5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
|
||||
.btn:disabled {
|
||||
background: var(--color-medium-gray, #cbd5e0);
|
||||
color: var(--text-secondary, #718096);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
composes: btn;
|
||||
background: #fff;
|
||||
color: var(--primary-color, #4A6FA5);
|
||||
border: 1px solid var(--primary-color, #4A6FA5);
|
||||
}
|
||||
|
||||
.btnSecondary:hover:not(:disabled) {
|
||||
background: rgba(74, 111, 165, 0.06);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
composes: btn;
|
||||
background: #C53030;
|
||||
}
|
||||
|
||||
.btnDanger:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
|
||||
.alertOk {
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: #e6fffa;
|
||||
color: #2c7a7b;
|
||||
border: 1px solid #b2f5ea;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alertErr {
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: #fff5f5;
|
||||
color: #c53030;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alertInfo {
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: #ebf8ff;
|
||||
color: #2c5282;
|
||||
border: 1px solid #bee3f8;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.kvGrid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
}
|
||||
|
||||
.kvLabel {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
}
|
||||
|
||||
.kvValue {
|
||||
color: var(--text-primary, #1a202c);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #718096);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #718096);
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px dashed var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Browser view: tree-as-table + editor split */
|
||||
/* ================================================================== */
|
||||
|
||||
.browserPage {
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
height: calc(100vh - 140px);
|
||||
min-height: 540px;
|
||||
}
|
||||
|
||||
.browserHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.browserFilters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 0.75rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filterGroup { display: flex; flex-direction: column; gap: 0.25rem; min-width: 180px; }
|
||||
.filterGroup label { font-size: 0.76rem; font-weight: 500; color: var(--text-secondary, #4a5568); }
|
||||
|
||||
.browserBody {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(620px, 1.6fr) minmax(420px, 1fr);
|
||||
gap: 0.85rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browserTreeContainer,
|
||||
.browserEditorContainer {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browserToolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
}
|
||||
|
||||
.browserToolbar .linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #4A6FA5);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.browserToolbar .linkBtn:hover { text-decoration: underline; }
|
||||
.browserToolbar .linkBtn:disabled { color: var(--text-secondary, #a0aec0); cursor: not-allowed; text-decoration: none; }
|
||||
|
||||
/* Tree grid */
|
||||
.treeScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.treeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1fr) 110px 90px 160px 100px 110px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.treeHeader {
|
||||
display: contents;
|
||||
}
|
||||
.treeHeader > div {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #F7FAFC;
|
||||
font-weight: 600;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.treeRow {
|
||||
display: contents;
|
||||
cursor: pointer;
|
||||
}
|
||||
.treeRow > div {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color, #edf2f7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.treeRow.selected > div { background: rgba(74, 111, 165, 0.12); }
|
||||
.treeRow:hover:not(.selected) > div { background: #F7FAFC; }
|
||||
|
||||
.treeRow.orphan > div { background: #FFFBEB; font-style: italic; color: var(--text-secondary, #4a5568); }
|
||||
.treeRow.orphan.selected > div { background: rgba(246, 173, 85, 0.22); }
|
||||
|
||||
.treeCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indent {
|
||||
flex: 0 0 18px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
.indent.line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color, #cbd5e0);
|
||||
}
|
||||
.indentElbow {
|
||||
flex: 0 0 18px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
.indentElbow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
width: 1px;
|
||||
background: var(--border-color, #cbd5e0);
|
||||
}
|
||||
.indentElbow.mid::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color, #cbd5e0);
|
||||
}
|
||||
.indentElbow::before,
|
||||
.indentElbow::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.indentElbow .elbowBar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: -3px;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
background: var(--border-color, #cbd5e0);
|
||||
}
|
||||
|
||||
.treeToggle {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.treeToggle:hover { background: #E2E8F0; }
|
||||
.treeTogglePlaceholder { flex: 0 0 18px; }
|
||||
|
||||
.trackerPill {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trackerPill.orphanPill {
|
||||
color: var(--text-secondary, #4a5568);
|
||||
background: transparent !important;
|
||||
border: 1px dashed var(--border-color, #cbd5e0);
|
||||
}
|
||||
|
||||
.ticketId {
|
||||
color: var(--text-secondary, #718096);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ticketSubject {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
background: #E2E8F0;
|
||||
color: var(--text-primary, #2d3748);
|
||||
}
|
||||
.statusPill.closed { background: #C6F6D5; color: #22543d; }
|
||||
|
||||
.relBadge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
color: var(--text-secondary, #718096);
|
||||
background: #F7FAFC;
|
||||
}
|
||||
.relBadge.root { background: rgba(74, 111, 165, 0.1); color: var(--primary-color, #4A6FA5); border-color: rgba(74, 111, 165, 0.3); }
|
||||
|
||||
.muted { color: var(--text-secondary, #a0aec0); }
|
||||
|
||||
/* Editor pane */
|
||||
.editorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color, #edf2f7);
|
||||
}
|
||||
.editorHeader h3 { margin: 0; font-size: 1.05rem; font-weight: 600; color: var(--text-primary, #1a202c); flex: 1; }
|
||||
|
||||
.editorGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 0.5rem 0.85rem;
|
||||
align-items: start;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.editorGrid .fullRow {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.editorGrid label { font-weight: 500; color: var(--text-secondary, #4a5568); padding-top: 0.45rem; }
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary, #1a202c);
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
.textarea:focus { outline: none; border-color: var(--primary-color, #4A6FA5); box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.18); }
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.select:focus { outline: none; border-color: var(--primary-color, #4A6FA5); box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.18); }
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--border-color, #edf2f7);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.relationList {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
}
|
||||
.relationList li { margin-bottom: 0.2rem; }
|
||||
3
src/pages/views/redmine/index.ts
Normal file
3
src/pages/views/redmine/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { RedmineSettingsView } from './RedmineSettingsView';
|
||||
export { RedmineStatsView } from './RedmineStatsView';
|
||||
export { RedmineBrowserView } from './RedmineBrowserView';
|
||||
144
src/pages/views/redmine/redmineTrackerColor.ts
Normal file
144
src/pages/views/redmine/redmineTrackerColor.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Tracker color palette for the Redmine views.
|
||||
*
|
||||
* Single source of truth so the Browser-View, Editor and any future
|
||||
* consumer agree on colors. Returns both background AND foreground so that
|
||||
* white/light pills stay readable.
|
||||
*
|
||||
* Matching is substring-based on a lowercased tracker name. Order matters:
|
||||
* the first rule that matches wins. Add new tracker names by extending the
|
||||
* ``_RULES`` table -- never duplicate the matching logic in callers.
|
||||
*/
|
||||
|
||||
export interface TrackerStyle {
|
||||
/** Pill background color. */
|
||||
bg: string;
|
||||
/** Pill foreground (text) color, contrast-safe over ``bg``. */
|
||||
fg: string;
|
||||
/** Border color -- needed mainly for the white pill so it's visible. */
|
||||
border: string;
|
||||
}
|
||||
|
||||
const _DARK_BLUE = '#2C5282'; // Userstory
|
||||
const _LIGHT_BLUE = '#63B3ED'; // Feature
|
||||
const _DARK_YELLOW = '#B7791F'; // Acc. Criteria
|
||||
const _LIGHT_YELLOW = '#FAF089'; // Testcase
|
||||
const _YELLOW = '#ECC94B'; // Change Request (mid yellow)
|
||||
const _GRAY = '#A0AEC0'; // Support, Development, Planning, ...
|
||||
|
||||
const _DARK_TEXT = '#1A202C';
|
||||
const _LIGHT_TEXT = '#FFFFFF';
|
||||
|
||||
interface Rule {
|
||||
/** Lowercase substrings; matches if ANY substring is in the tracker name. */
|
||||
match: string[];
|
||||
style: TrackerStyle;
|
||||
}
|
||||
|
||||
const _RULES: Rule[] = [
|
||||
// Userstory -- dark blue, white text.
|
||||
{
|
||||
match: ['userstory', 'user story', 'user-story'],
|
||||
style: { bg: _DARK_BLUE, fg: _LIGHT_TEXT, border: _DARK_BLUE },
|
||||
},
|
||||
// Feature -- light blue, dark text (for contrast on the lighter shade).
|
||||
{
|
||||
match: ['feature'],
|
||||
style: { bg: _LIGHT_BLUE, fg: _DARK_TEXT, border: _LIGHT_BLUE },
|
||||
},
|
||||
// Acc. Criteria -- mid yellow, dark text.
|
||||
{
|
||||
match: ['acc.', 'acceptance', 'akzeptanz', 'krit'],
|
||||
style: { bg: _YELLOW, fg: _DARK_TEXT, border: _YELLOW },
|
||||
},
|
||||
// Testcase -- light yellow, dark text.
|
||||
{
|
||||
match: ['testcase', 'test case', 'test-case'],
|
||||
style: { bg: _LIGHT_YELLOW, fg: _DARK_TEXT, border: '#ECC94B' },
|
||||
},
|
||||
// Change Request -- dark yellow (amber), white text for contrast.
|
||||
{
|
||||
match: ['change request', 'change-request', 'changerequest'],
|
||||
style: { bg: _DARK_YELLOW, fg: _LIGHT_TEXT, border: _DARK_YELLOW },
|
||||
},
|
||||
// Gray bucket: explicit list of "auxiliary" trackers.
|
||||
{
|
||||
match: [
|
||||
'support',
|
||||
'development',
|
||||
'planning',
|
||||
'aut bug', // "Automatic Bug" / similar -- explicit user request
|
||||
'notes',
|
||||
'interface',
|
||||
'sales consulting',
|
||||
],
|
||||
style: { bg: _GRAY, fg: _LIGHT_TEXT, border: _GRAY },
|
||||
},
|
||||
];
|
||||
|
||||
const _DEFAULT_STYLE: TrackerStyle = { bg: _GRAY, fg: _LIGHT_TEXT, border: _GRAY };
|
||||
|
||||
// Memo: tracker names are a tiny, fixed vocabulary; computing the substring
|
||||
// match for every chip / pill / row on every render adds up. We cache by
|
||||
// lowercased key so the rule-walk runs once per tracker name per session.
|
||||
const _styleCache = new Map<string, TrackerStyle>();
|
||||
|
||||
export const getTrackerStyle = (name: string | null | undefined): TrackerStyle => {
|
||||
const key = (name || '').toLowerCase();
|
||||
if (!key) return _DEFAULT_STYLE;
|
||||
const cached = _styleCache.get(key);
|
||||
if (cached) return cached;
|
||||
let resolved: TrackerStyle = _DEFAULT_STYLE;
|
||||
for (const rule of _RULES) {
|
||||
if (rule.match.some(m => key.includes(m))) {
|
||||
resolved = rule.style;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_styleCache.set(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
/** Convenience: return only the background color (most common usage). */
|
||||
export const getTrackerBg = (name: string | null | undefined): string => getTrackerStyle(name).bg;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display order
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The UI shows tracker filter chips in a fixed conceptual order so the team
|
||||
// always sees the hierarchy at a glance:
|
||||
// Userstory > Feature > Acc. Criteria > Testcase > Change Request >
|
||||
// everything else (A-Z).
|
||||
// Trackers that don't match a known prefix fall to the end and are sorted
|
||||
// alphabetically among themselves.
|
||||
|
||||
const _ORDER_RULES: Array<{ rank: number; match: string[] }> = [
|
||||
{ rank: 0, match: ['userstory', 'user story', 'user-story'] },
|
||||
{ rank: 1, match: ['feature'] },
|
||||
{ rank: 2, match: ['acc.', 'acceptance', 'akzeptanz', 'krit'] },
|
||||
{ rank: 3, match: ['testcase', 'test case', 'test-case'] },
|
||||
{ rank: 4, match: ['change request', 'change-request', 'changerequest'] },
|
||||
];
|
||||
|
||||
/** Sort rank for a tracker name. Lower number = appears earlier. Unknown
|
||||
* trackers get a high rank and are sorted alphabetically among themselves. */
|
||||
export const getTrackerSortRank = (name: string | null | undefined): number => {
|
||||
const key = (name || '').toLowerCase();
|
||||
if (!key) return 1000;
|
||||
for (const r of _ORDER_RULES) {
|
||||
if (r.match.some(m => key.includes(m))) return r.rank;
|
||||
}
|
||||
return 1000;
|
||||
};
|
||||
|
||||
/** Stable sort of any object array by its tracker-name accessor, applying
|
||||
* ``getTrackerSortRank`` first and then alphabetical tie-break. */
|
||||
export const sortByTrackerOrder = <T>(items: T[], nameOf: (item: T) => string): T[] => {
|
||||
return items.slice().sort((a, b) => {
|
||||
const ra = getTrackerSortRank(nameOf(a));
|
||||
const rb = getTrackerSortRank(nameOf(b));
|
||||
if (ra !== rb) return ra - rb;
|
||||
return nameOf(a).localeCompare(nameOf(b));
|
||||
});
|
||||
};
|
||||
262
src/pages/views/redmine/redmineTreeLogic.ts
Normal file
262
src/pages/views/redmine/redmineTreeLogic.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Pure tree-building helpers for the Redmine Browser view.
|
||||
*
|
||||
* Algorithmic contract (kept deliberately simple so the UI stays predictable):
|
||||
*
|
||||
* 1. Tickets that are NOT in the input ``tickets`` list (i.e. filtered out
|
||||
* by the caller) take no part in the relation graph -- they vanish
|
||||
* completely. No edges, no adjacency entries.
|
||||
*
|
||||
* 2. Roots = tickets whose tracker matches ``rootTrackerId`` (typically
|
||||
* "Userstory"). Roots are seeded into the BFS queue in ascending id
|
||||
* order so output is stable across re-renders.
|
||||
*
|
||||
* 3. Construction is a **multi-source breadth-first search**: ALL roots
|
||||
* are seeded into the queue together. The algorithm then walks
|
||||
* strictly level-by-level across the entire forest:
|
||||
*
|
||||
* Level 0: every User Story
|
||||
* Level 1: every ticket directly related to ANY User Story
|
||||
* Level 2: every ticket related to a level-1 ticket, ...
|
||||
*
|
||||
* Consequence: a ticket reachable from two roots ends up under the
|
||||
* one that finds it FIRST at the shallowest distance. A Feature
|
||||
* directly related to a User Story is therefore guaranteed to sit
|
||||
* at depth 1 under that US -- it can never end up nested deeper
|
||||
* under an Acceptance Criteria just because another root reached
|
||||
* the AC earlier.
|
||||
*
|
||||
* 4. Each ticket appears AT MOST ONCE in the entire forest (global
|
||||
* ``visited`` set). Once placed, neither the same nor a different
|
||||
* relation can pull it elsewhere. Cycles are therefore impossible.
|
||||
*
|
||||
* 5. Tickets that no User Story ever reaches end up under the synthetic
|
||||
* ``ORPHAN_ROOT_ID``, built with the same multi-source BFS but
|
||||
* seeded from the orphan candidates.
|
||||
*/
|
||||
|
||||
import { RedmineTicket } from '../../../api/redmineApi';
|
||||
|
||||
const _MAX_NODES = 20000; // hard safety cap across the whole forest
|
||||
|
||||
export const ORPHAN_ROOT_ID = -1;
|
||||
|
||||
export interface TreeNode {
|
||||
/** Redmine issue id, or ``ORPHAN_ROOT_ID`` for the virtual orphan root. */
|
||||
id: number;
|
||||
/** Relation type connecting this node to its parent (``null`` for roots). */
|
||||
relType: string | null;
|
||||
/** ``out`` = this ticket is the target of the parent's outgoing edge,
|
||||
* ``in`` = this ticket is the source (the edge points back at the parent). */
|
||||
dir: 'out' | 'in' | null;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relType: string;
|
||||
}
|
||||
|
||||
interface Neighbor {
|
||||
id: number;
|
||||
relType: string;
|
||||
dir: 'out' | 'in';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge + adjacency construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _relationsToEdges = (tickets: RedmineTicket[]): Edge[] => {
|
||||
const seen = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
for (const t of tickets) {
|
||||
if (t.parentId != null) {
|
||||
const key = `p:${t.parentId}:${t.id}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
edges.push({ fromId: t.parentId, toId: t.id, relType: 'parent' });
|
||||
}
|
||||
}
|
||||
for (const r of t.relations || []) {
|
||||
const key = `r:${r.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({ fromId: r.issueId, toId: r.issueToId, relType: r.relationType });
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
};
|
||||
|
||||
const _buildAdjacency = (
|
||||
edges: Edge[],
|
||||
visibleIds: Set<number>,
|
||||
allowedRelTypes: Set<string> | null,
|
||||
): Map<number, Neighbor[]> => {
|
||||
const map = new Map<number, Neighbor[]>();
|
||||
const _add = (k: number, n: Neighbor) => {
|
||||
const arr = map.get(k);
|
||||
if (arr) arr.push(n); else map.set(k, [n]);
|
||||
};
|
||||
for (const e of edges) {
|
||||
// Filter rule #1: a filtered-out ticket on EITHER side drops the edge.
|
||||
if (!visibleIds.has(e.fromId)) continue;
|
||||
if (!visibleIds.has(e.toId)) continue;
|
||||
if (allowedRelTypes && !allowedRelTypes.has(e.relType)) continue;
|
||||
_add(e.fromId, { id: e.toId, relType: e.relType, dir: 'out' });
|
||||
_add(e.toId, { id: e.fromId, relType: e.relType, dir: 'in' });
|
||||
}
|
||||
// Stable neighbour order so re-renders produce the same tree.
|
||||
for (const [k, arr] of map) {
|
||||
arr.sort((a, b) => a.id - b.id);
|
||||
map.set(k, arr);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-source BFS over a set of seed roots, sharing one ``visited`` set.
|
||||
// Returns the seed roots in their input order, with their full BFS subtrees
|
||||
// attached. The BFS interleaves roots level by level, so a ticket reachable
|
||||
// from multiple roots is attached to the first root that reaches it at the
|
||||
// shallowest distance.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _bfsForest = (
|
||||
seedIds: number[],
|
||||
adjacency: Map<number, Neighbor[]>,
|
||||
visited: Set<number>,
|
||||
): TreeNode[] => {
|
||||
const roots: TreeNode[] = [];
|
||||
const queue: TreeNode[] = [];
|
||||
// Seed every root first -- THIS is what makes it multi-source.
|
||||
for (const id of seedIds) {
|
||||
if (visited.has(id)) continue; // a previous seed already absorbed it
|
||||
visited.add(id);
|
||||
const node: TreeNode = { id, relType: null, dir: null, children: [] };
|
||||
roots.push(node);
|
||||
queue.push(node);
|
||||
}
|
||||
// Standard FIFO BFS. Because roots were enqueued in order, the first
|
||||
// root that can claim a neighbour at level 1 wins it.
|
||||
while (queue.length > 0) {
|
||||
if (visited.size >= _MAX_NODES) break;
|
||||
const node = queue.shift()!;
|
||||
const neighbors = adjacency.get(node.id) || [];
|
||||
for (const n of neighbors) {
|
||||
if (visited.has(n.id)) continue; // already placed elsewhere -> skip
|
||||
visited.add(n.id);
|
||||
const child: TreeNode = { id: n.id, relType: n.relType, dir: n.dir, children: [] };
|
||||
node.children.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuildForestOptions {
|
||||
rootTrackerId: number | null;
|
||||
/** Only edges whose ``relType`` is in this list contribute to the tree.
|
||||
* ``undefined`` / empty array means "all relation types allowed". */
|
||||
allowedRelTypes?: string[];
|
||||
}
|
||||
|
||||
export interface Forest {
|
||||
trees: TreeNode[];
|
||||
reachableCount: number;
|
||||
orphanCount: number;
|
||||
}
|
||||
|
||||
export const buildForest = (
|
||||
tickets: RedmineTicket[],
|
||||
opts: BuildForestOptions,
|
||||
): Forest => {
|
||||
const { rootTrackerId } = opts;
|
||||
const allowedRelTypes = opts.allowedRelTypes && opts.allowedRelTypes.length > 0
|
||||
? new Set(opts.allowedRelTypes)
|
||||
: null;
|
||||
|
||||
const visibleIds = new Set(tickets.map(t => t.id));
|
||||
const edges = _relationsToEdges(tickets);
|
||||
const adjacency = _buildAdjacency(edges, visibleIds, allowedRelTypes);
|
||||
|
||||
// Root seeds in stable order (by id ASC). Multi-source BFS will then
|
||||
// interleave their expansion level by level.
|
||||
const rootSeeds = (rootTrackerId != null
|
||||
? tickets.filter(t => t.trackerId === rootTrackerId)
|
||||
: []
|
||||
).slice().sort((a, b) => a.id - b.id).map(t => t.id);
|
||||
|
||||
const visited = new Set<number>();
|
||||
const trees: TreeNode[] = _bfsForest(rootSeeds, adjacency, visited);
|
||||
const reachableCount = visited.size;
|
||||
|
||||
// Orphans = tickets the root BFS never reached. They form their own
|
||||
// multi-source BFS forest under the synthetic Orphan node.
|
||||
const orphanSeeds = tickets
|
||||
.filter(t => !visited.has(t.id))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map(t => t.id);
|
||||
const orphanCount = orphanSeeds.length;
|
||||
const orphanChildren = _bfsForest(orphanSeeds, adjacency, visited);
|
||||
|
||||
if (orphanChildren.length > 0) {
|
||||
trees.push({
|
||||
id: ORPHAN_ROOT_ID,
|
||||
relType: null,
|
||||
dir: null,
|
||||
children: orphanChildren,
|
||||
});
|
||||
}
|
||||
|
||||
return { trees, reachableCount, orphanCount };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flatten + expand-all helpers (unchanged contract)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlatRow {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
/** Per-ancestor: ``true`` if that ancestor has a following sibling. */
|
||||
indentLines: boolean[];
|
||||
isLast: boolean;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
export const flattenForest = (
|
||||
trees: TreeNode[],
|
||||
expanded: Set<number>,
|
||||
): FlatRow[] => {
|
||||
const rows: FlatRow[] = [];
|
||||
const _walk = (node: TreeNode, depth: number, lines: boolean[], isLast: boolean) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
rows.push({ node, depth, indentLines: lines, isLast, hasChildren });
|
||||
if (!hasChildren) return;
|
||||
if (!expanded.has(node.id)) return;
|
||||
node.children.forEach((child, idx) => {
|
||||
const childIsLast = idx === node.children.length - 1;
|
||||
_walk(child, depth + 1, [...lines, !isLast], childIsLast);
|
||||
});
|
||||
};
|
||||
trees.forEach((root, idx) => {
|
||||
_walk(root, 0, [], idx === trees.length - 1);
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const collectAllIds = (trees: TreeNode[]): number[] => {
|
||||
const acc: number[] = [];
|
||||
const _w = (n: TreeNode) => {
|
||||
if (n.children.length > 0) acc.push(n.id);
|
||||
n.children.forEach(_w);
|
||||
};
|
||||
trees.forEach(_w);
|
||||
return acc;
|
||||
};
|
||||
|
|
@ -66,6 +66,13 @@ interface TabDef {
|
|||
Wrapper: React.FC<{ instanceId: string }>;
|
||||
}
|
||||
|
||||
interface TabGroupDef {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
tabs: TabDef[];
|
||||
}
|
||||
|
||||
function _buildApiEndpoint(instanceId: string, suffix: string): string {
|
||||
return `/api/trustee/${instanceId}/${suffix}`;
|
||||
}
|
||||
|
|
@ -136,21 +143,53 @@ const _DataAccountBalancesWrapper = _makeReadOnlyWrapper(useTrusteeDataAccountBa
|
|||
const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs');
|
||||
const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs');
|
||||
|
||||
function _buildTabs(t: (k: string) => string): TabDef[] {
|
||||
// Group structure mirrors `DATA_OBJECTS` in `gateway/modules/features/trustee/mainTrustee.py`
|
||||
// (UDB folders): Stammdaten · Lokale Daten · Konfiguration · Daten aus Buchhaltungssystem.
|
||||
// "Stammdaten" is page-only (Organisation/Rolle/Zugriff/Vertrag are admin tables that
|
||||
// don't appear in the UDB because the feature instance IS the organisation).
|
||||
function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||
return [
|
||||
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
|
||||
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
|
||||
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
|
||||
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
|
||||
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
|
||||
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
|
||||
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Konten (Sync)'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
|
||||
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen (Sync)'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
|
||||
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen (Sync)'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
|
||||
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte (Sync)'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
|
||||
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden (Sync)'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
|
||||
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Konfiguration'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
|
||||
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Buchhaltungs-Synchronisation'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
|
||||
{
|
||||
id: 'master',
|
||||
label: t('Stammdaten'),
|
||||
color: '#1976d2',
|
||||
tabs: [
|
||||
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
|
||||
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
|
||||
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
|
||||
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'localData',
|
||||
label: t('Lokale Daten'),
|
||||
color: '#388e3c',
|
||||
tabs: [
|
||||
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
|
||||
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: t('Konfiguration'),
|
||||
color: '#5e35b1',
|
||||
tabs: [
|
||||
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
|
||||
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'accountingData',
|
||||
label: t('Daten aus Buchhaltungssystem'),
|
||||
color: '#ef6c00',
|
||||
tabs: [
|
||||
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
|
||||
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
|
||||
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
|
||||
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
|
||||
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -163,16 +202,16 @@ export const TrusteeDataTablesView: React.FC = () => {
|
|||
const instanceId = useInstanceId();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tabs = useMemo(() => _buildTabs(t), [t]);
|
||||
const visibleTabs = tabs;
|
||||
const tabGroups = useMemo(() => _buildTabGroups(t), [t]);
|
||||
const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]);
|
||||
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const activeTab = useMemo(() => {
|
||||
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
|
||||
return requestedTab;
|
||||
}
|
||||
return visibleTabs[0]?.id || tabs[0].id;
|
||||
}, [requestedTab, visibleTabs, tabs]);
|
||||
return visibleTabs[0]?.id || '';
|
||||
}, [requestedTab, visibleTabs]);
|
||||
|
||||
const _setActiveTab = useCallback((tabId: string) => {
|
||||
setSearchParams({ tab: tabId }, { replace: true });
|
||||
|
|
@ -217,47 +256,84 @@ export const TrusteeDataTablesView: React.FC = () => {
|
|||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
borderBottom: '2px solid var(--border-color, #e0e0e0)',
|
||||
flexWrap: 'wrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => _setActiveTab(tab.id)}
|
||||
{tabGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
style={{
|
||||
padding: '0.625rem 1rem',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
|
||||
background: 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
marginBottom: '-2px',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '11rem 1fr',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
{tab.readOnly && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '0.375rem',
|
||||
fontSize: '0.6875rem',
|
||||
color: 'var(--text-secondary, #888)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
title={t('Nur lesen – Daten kommen aus dem Sync.')}
|
||||
>
|
||||
({t('read-only')})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: group.color,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
borderLeft: `3px solid ${group.color}`,
|
||||
paddingLeft: '0.5rem',
|
||||
}}
|
||||
title={group.label}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{group.tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => _setActiveTab(tab.id)}
|
||||
title={tab.readOnly ? t('Nur lesen – Daten kommen aus dem Sync.') : tab.label}
|
||||
style={{
|
||||
padding: '0.375rem 0.75rem',
|
||||
border: `1px solid ${isActive ? tab.color : 'var(--border-color, #e0e0e0)'}`,
|
||||
borderRadius: 4,
|
||||
background: isActive ? `${tab.color}15` : 'transparent',
|
||||
color: isActive ? tab.color : 'var(--text-secondary, #555)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontSize: '0.8125rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
{tab.readOnly && (
|
||||
<span
|
||||
aria-label={t('Nur lesen')}
|
||||
style={{ fontSize: '0.75rem', opacity: 0.7, lineHeight: 1 }}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
|||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: attr.fkSource,
|
||||
fkDisplayField: attr.fkDisplayField,
|
||||
frontendFormat: attr.frontendFormat,
|
||||
frontendFormatLabels: attr.frontendFormatLabels,
|
||||
}));
|
||||
}, [attributes, hiddenColumns]);
|
||||
|
||||
|
|
|
|||
181
src/utils/applyFrontendFormat.ts
Normal file
181
src/utils/applyFrontendFormat.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Copyright (c) 2026 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Central frontend formatter for backend ``frontend_format`` / ``frontend_format_labels``
|
||||
// hints (see gateway/modules/shared/attributeUtils.py). Applied by FormGeneratorTable
|
||||
// for numeric, int and binary cells. Pure function so it can be unit-tested in isolation.
|
||||
//
|
||||
// Format string syntax (Excel-inspired, stays simple on purpose):
|
||||
// <ALIGN>:<PATTERN>
|
||||
// ALIGN ∈ { L, M, R } -- left / middle / right alignment hint
|
||||
// PATTERN may contain literal text wrapped in @...@ (e.g. "@CHF@ #'###.00")
|
||||
//
|
||||
// Numeric patterns:
|
||||
// - "#'###.00" Swiss thousands separator + 2 decimals 1'444'555.67
|
||||
// - "0.000" Force 3 decimals, no thousands separator 4.556
|
||||
// - "0" Integer, no decimals 12
|
||||
// - "b" Auto-scale Byte units (B/KB/MB/GB/TB) 12.3 MB
|
||||
// - "@CHF@ #'###.00" → "CHF 1'234.50" (literal text via @...@)
|
||||
//
|
||||
// Binary (boolean) values use ``frontendFormatLabels`` as a 3-tuple
|
||||
// [trueLabel, neutralLabel, falseLabel]. ``neutralLabel`` is rendered for
|
||||
// ``null``/``undefined`` -- pass "" or "-" if you want to hide it.
|
||||
|
||||
export type RenderAlign = 'left' | 'right' | 'center';
|
||||
|
||||
export interface AppliedFormat {
|
||||
/** Display string ready for the cell. */
|
||||
text: string;
|
||||
/** Alignment hint for the cell, if the format specified one. */
|
||||
align?: RenderAlign;
|
||||
}
|
||||
|
||||
const _ALIGN_MAP: Record<string, RenderAlign> = {
|
||||
L: 'left',
|
||||
M: 'center',
|
||||
R: 'right',
|
||||
};
|
||||
|
||||
const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
/**
|
||||
* Split "ALIGN:PATTERN" into its parts. Returns ``[alignChar, pattern]``,
|
||||
* with ``alignChar`` being ``""`` if no align prefix is present.
|
||||
*/
|
||||
function _splitAlign(format: string): [string, string] {
|
||||
if (format.length >= 2 && format[1] === ':' && _ALIGN_MAP[format[0]] !== undefined) {
|
||||
return [format[0], format.slice(2)];
|
||||
}
|
||||
return ['', format];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the literal-text segment ``@…@`` if present, returning
|
||||
* ``[prefix, numericPattern, suffix]``. The literal segment is dropped from
|
||||
* the numeric pattern so the rest can be parsed as a number format. Only the
|
||||
* first literal block is recognised (good enough for ``@CHF@ #'###.00`` and
|
||||
* ``#'###.00 @CHF@`` cases).
|
||||
*/
|
||||
function _extractLiteral(pattern: string): { prefix: string; numericPattern: string; suffix: string } {
|
||||
const match = pattern.match(/^([^@]*)@([^@]*)@(.*)$/);
|
||||
if (!match) {
|
||||
return { prefix: '', numericPattern: pattern, suffix: '' };
|
||||
}
|
||||
const [, before, literal, after] = match;
|
||||
if (!after.trim() && before.trim()) {
|
||||
return { prefix: '', numericPattern: before.trim(), suffix: literal };
|
||||
}
|
||||
return { prefix: literal, numericPattern: after.trim() || before.trim(), suffix: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ``value`` as bytes auto-scaled to the largest unit ``< 1024``.
|
||||
* Locale-formatted to one decimal for KB+, integer for raw B.
|
||||
*/
|
||||
function _formatBytes(value: number, locale: string): string {
|
||||
const sign = value < 0 ? '-' : '';
|
||||
let abs = Math.abs(value);
|
||||
let unitIdx = 0;
|
||||
while (abs >= 1024 && unitIdx < _BYTE_UNITS.length - 1) {
|
||||
abs /= 1024;
|
||||
unitIdx += 1;
|
||||
}
|
||||
const decimals = unitIdx === 0 ? 0 : 1;
|
||||
const formatted = abs.toLocaleString(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
return `${sign}${formatted} ${_BYTE_UNITS[unitIdx]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric value following ``pattern``. Supported patterns:
|
||||
* - "b" byte units
|
||||
* - "#'###.00" thousands separator + N decimals (digits after the dot)
|
||||
* - "0.000" N decimals, no thousands separator
|
||||
* - "0" integer
|
||||
* Falls back to ``toLocaleString`` for unknown patterns so we never break the cell.
|
||||
*/
|
||||
function _formatNumeric(value: number, pattern: string, locale: string): string {
|
||||
if (!pattern) return value.toLocaleString(locale);
|
||||
if (pattern === 'b' || pattern === 'B') return _formatBytes(value, locale);
|
||||
const decimalsMatch = pattern.match(/[.,](0+)\s*$/);
|
||||
const decimals = decimalsMatch ? decimalsMatch[1].length : 0;
|
||||
const useThousands = pattern.includes("'") || pattern.includes('#');
|
||||
return value.toLocaleString(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: useThousands,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply backend render hints to an arbitrary value.
|
||||
*
|
||||
* - ``type === 'binary'`` (or boolean value) renders the i18n-resolved label
|
||||
* tuple from ``formatLabels``.
|
||||
* - Numeric/int values are formatted by ``_formatNumeric`` according to
|
||||
* the ``ALIGN:PATTERN`` format string.
|
||||
* - If ``format`` is empty, the value is rendered with ``toLocaleString`` for
|
||||
* numbers and ``String(value)`` for everything else (no format == no change).
|
||||
*/
|
||||
export function applyFrontendFormat(
|
||||
value: unknown,
|
||||
format: string | undefined,
|
||||
formatLabels: string[] | undefined,
|
||||
type: string | undefined,
|
||||
locale: string = 'de-CH',
|
||||
): AppliedFormat {
|
||||
const [alignChar, pattern] = format ? _splitAlign(format) : ['', ''];
|
||||
const align = _ALIGN_MAP[alignChar];
|
||||
|
||||
// Boolean / binary rendering with i18n-resolved labels
|
||||
if (type === 'binary' || type === 'boolean' || typeof value === 'boolean') {
|
||||
if (value === null || value === undefined) {
|
||||
const neutral = formatLabels && formatLabels.length >= 2 ? formatLabels[1] : '-';
|
||||
return { text: neutral, align };
|
||||
}
|
||||
if (formatLabels && formatLabels.length >= 1) {
|
||||
const trueLabel = formatLabels[0] ?? '';
|
||||
const falseLabel = formatLabels[2] ?? formatLabels[formatLabels.length - 1] ?? '';
|
||||
return { text: value ? trueLabel : falseLabel, align };
|
||||
}
|
||||
return { text: value ? '✓' : '✗', align };
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return { text: '-', align };
|
||||
}
|
||||
|
||||
const numeric = typeof value === 'number'
|
||||
? value
|
||||
: (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value))
|
||||
? Number(value)
|
||||
: NaN);
|
||||
|
||||
if (Number.isFinite(numeric) && (type === 'number' || type === 'float' || type === 'integer' || type === 'int' || pattern || typeof value === 'number')) {
|
||||
if (!pattern) {
|
||||
return { text: numeric.toLocaleString(locale), align };
|
||||
}
|
||||
const { prefix, numericPattern, suffix } = _extractLiteral(pattern);
|
||||
const numStr = _formatNumeric(numeric, numericPattern, locale);
|
||||
const text = `${prefix ? `${prefix} ` : ''}${numStr}${suffix ? ` ${suffix}` : ''}`.trim();
|
||||
return { text, align };
|
||||
}
|
||||
|
||||
return { text: String(value), align };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: returns just the formatted string. Use this when you only
|
||||
* need the text (e.g. CSV export) and the alignment is irrelevant.
|
||||
*/
|
||||
export function applyFrontendFormatText(
|
||||
value: unknown,
|
||||
format: string | undefined,
|
||||
formatLabels: string[] | undefined,
|
||||
type: string | undefined,
|
||||
locale: string = 'de-CH',
|
||||
): string {
|
||||
return applyFrontendFormat(value, format, formatLabels, type, locale).text;
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ export type AttributeType =
|
|||
| 'select'
|
||||
| 'multiselect'
|
||||
| 'multilingual'
|
||||
| 'integer'
|
||||
| 'integer'
|
||||
| 'int'
|
||||
| 'float'
|
||||
| 'number'
|
||||
| 'timestamp'
|
||||
|
|
|
|||
Loading…
Reference in a new issue