ctx.onToggleFeaturePath(pathKey)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
+ draggable
+ onDragStart={(e) => {
+ e.stopPropagation();
+ e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
+ e.dataTransfer.setData('text/plain', label);
+ e.dataTransfer.effectAllowed = 'copy';
+ }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ // Compensate the 3px border on active wildcard rows so the row
+ // content stays at the same x-position whether or not it's active.
+ paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
+ paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
- background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ background: wildcardFds
+ ? (hovered ? '#ede7f6' : '#7b1fa208')
+ : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
+ borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
transition: 'background 0.1s', userSelect: 'none',
}}
>
@@ -1617,6 +1651,52 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
}}>
{label}
+
+
+
+
{expanded && items.length > 0 && (
@@ -1672,17 +1752,45 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
}
};
+ // Container-wildcard objectKey for the parent group: matches every record in
+ // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list.
+ const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`;
+ const wildcardFds = ctx.featureDataSources.find(
+ f => f.featureInstanceId === featureNode.featureInstanceId
+ && f.tableName === table.tableName
+ && !f.recordFilter
+ && f.objectKey === containerObjectKey,
+ );
+ const _chatPayload = {
+ featureInstanceId: featureNode.featureInstanceId,
+ featureCode: featureNode.featureCode,
+ tableName: table.tableName,
+ objectKey: containerObjectKey,
+ label: table.label || table.tableName,
+ };
+
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
+ draggable
+ onDragStart={(e) => {
+ e.stopPropagation();
+ e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
+ e.dataTransfer.setData('text/plain', _chatPayload.label);
+ e.dataTransfer.effectAllowed = 'copy';
+ }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
+ paddingRight: 4, paddingTop: 3, paddingBottom: 3,
cursor: 'pointer', borderRadius: 3,
- background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ background: wildcardFds
+ ? (hovered ? '#ede7f6' : '#7b1fa208')
+ : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
+ borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
transition: 'background 0.1s', userSelect: 'none',
}}
>
@@ -1701,6 +1809,54 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
+{childTables.length} {t('Tabellen')}
)}
+
+
+
+
{expanded && records && records.length > 0 && (
diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
index b872405..421f5d7 100644
--- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx
+++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
@@ -7,11 +7,23 @@ import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources';
+/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext`
+ * weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich
+ * pro Surface registrieren können. */
+export type UdbSurface =
+ | 'workspace'
+ | 'graphEditor'
+ | 'trustee'
+ | 'standalone'
+ | 'sharepoint';
+
export interface UdbContext {
instanceId: string;
mandateId?: string;
featureInstanceId?: string;
userId?: string;
+ /** Optionales Surface-Tag, hilft Custom-Actions zu entscheiden, wann sie sichtbar sind. */
+ surface?: UdbSurface;
}
export interface AddToChat_FileItem {
@@ -44,6 +56,9 @@ interface UnifiedDataBarProps {
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
onAttachDataSource?: (dsId: string) => void;
+ /** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den
+ * Graph-Editor importiert wurde (Action `workflow.openInEditor`). */
+ onWorkflowImportedFromFile?: (workflowId: string) => void;
className?: string;
}
@@ -72,6 +87,7 @@ const UnifiedDataBar: React.FC
= ({
onSendToChat_Files,
onSendToChat_FeatureSource,
onAttachDataSource,
+ onWorkflowImportedFromFile,
className,
}) => {
const { t } = useLanguage();
@@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC = ({
context={context}
onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
+ onWorkflowImported={onWorkflowImportedFromFile}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index eca461c..f3c18d8 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -142,6 +142,12 @@ export const PAGE_ICONS: Record = {
'page.feature.workspace.dashboard': ,
'page.feature.workspace.editor': ,
'feature.workspace': ,
+
+ // Feature pages - Redmine
+ 'feature.redmine': ,
+ 'page.feature.redmine.stats': ,
+ 'page.feature.redmine.browser': ,
+ 'page.feature.redmine.settings': ,
};
// =============================================================================
diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx
index c886561..5022b21 100644
--- a/src/hooks/useConfirm.tsx
+++ b/src/hooks/useConfirm.tsx
@@ -66,12 +66,19 @@ export function useConfirm() {
return (
{
+ if (e.key === 'Escape') _handleCancel();
+ }}
+ tabIndex={-1}
>
e.stopPropagation()}
diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx
index 3324ef3..60d2621 100644
--- a/src/hooks/usePrompt.tsx
+++ b/src/hooks/usePrompt.tsx
@@ -73,12 +73,19 @@ export function usePrompt() {
return (
{
+ if (e.key === 'Escape') _handleCancel();
+ }}
+ tabIndex={-1}
>
e.stopPropagation()}
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index 03c4b45..7fb75e3 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -48,6 +48,9 @@ import { NeutralizationView } from './views/neutralization';
// CommCoach Views
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
+// Redmine Views
+import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
+
import styles from './FeatureView.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@@ -168,6 +171,11 @@ const VIEW_COMPONENTS: Record
> = {
dossier: CommcoachDossierView,
settings: CommcoachSettingsView,
},
+ redmine: {
+ stats: RedmineStatsView,
+ browser: RedmineBrowserView,
+ settings: RedmineSettingsView,
+ },
};
// =============================================================================
diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx
index a3218eb..0baa236 100644
--- a/src/pages/basedata/ConnectionsPage.tsx
+++ b/src/pages/basedata/ConnectionsPage.tsx
@@ -69,6 +69,8 @@ export const ConnectionsPage: React.FC = () => {
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
+ frontendFormat: (attr as any).frontendFormat,
+ frontendFormatLabels: (attr as any).frontendFormatLabels,
};
if (attr.name === 'userId') {
diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx
index 79d6199..78f3cbb 100644
--- a/src/pages/basedata/FilesPage.tsx
+++ b/src/pages/basedata/FilesPage.tsx
@@ -212,6 +212,8 @@ export const FilesPage: React.FC = () => {
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
+ frontendFormat: (attr as any).frontendFormat,
+ frontendFormatLabels: (attr as any).frontendFormatLabels,
}));
cols.push({
key: 'sysCreatedBy',
diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx
index 38ea58f..ae55350 100644
--- a/src/pages/basedata/PromptsPage.tsx
+++ b/src/pages/basedata/PromptsPage.tsx
@@ -85,6 +85,8 @@ export const PromptsPage: React.FC = () => {
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
+ frontendFormat: (attr as any).frontendFormat,
+ frontendFormatLabels: (attr as any).frontendFormatLabels,
}));
// Add sysCreatedBy column with FK resolution to show username
@@ -100,6 +102,8 @@ export const PromptsPage: React.FC = () => {
maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
+ frontendFormat: undefined,
+ frontendFormatLabels: undefined,
});
return cols;
diff --git a/src/pages/views/redmine/RedmineBrowserView.tsx b/src/pages/views/redmine/RedmineBrowserView.tsx
new file mode 100644
index 0000000..cce9851
--- /dev/null
+++ b/src/pages/views/redmine/RedmineBrowserView.tsx
@@ -0,0 +1,721 @@
+/**
+ * Redmine Ticket Browser
+ *
+ * Split view: tree-as-table on the left (roots = configured root
+ * tracker + virtual "Orphan" root), editor pane on the right. All reads
+ * hit the local mirror; saves go through ``updateRedmineTicketApi``
+ * which updates Redmine and then refreshes the mirror.
+ *
+ * Filters are applied client-side because the mirror already fits in
+ * memory (2-20k tickets is fine for a sub-200ms filter pass).
+ */
+
+import React, {
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { useApiRequest } from '../../../hooks/useApi';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import {
+ RedmineConfigDto,
+ RedmineFieldSchema,
+ RedmineTicket,
+ getRedmineConfigApi,
+ getRedmineSchemaApi,
+ listRedmineTicketsApi,
+} from '../../../api/redmineApi';
+import { PeriodPicker, PeriodValue } from '../../../components/PeriodPicker';
+
+import {
+ Forest,
+ FlatRow,
+ ORPHAN_ROOT_ID,
+ buildForest,
+ collectAllIds,
+ flattenForest,
+} from './redmineTreeLogic';
+import { getTrackerStyle, sortByTrackerOrder } from './redmineTrackerColor';
+import RedmineTicketEditor from './RedmineTicketEditor';
+
+import styles from './RedmineViews.module.css';
+
+// ============================================================================
+// Relation type options -- Redmine's fixed vocabulary plus our synthetic
+// "parent" edge (inherited from ``parent_id``).
+// ============================================================================
+const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [
+ { value: 'parent', label: 'parent_id' },
+ { value: 'relates', label: 'relates' },
+ { value: 'duplicates', label: 'duplicates' },
+ { value: 'duplicated', label: 'duplicated' },
+ { value: 'blocks', label: 'blocks' },
+ { value: 'blocked', label: 'blocked' },
+ { value: 'precedes', label: 'precedes' },
+ { value: 'follows', label: 'follows' },
+ { value: 'copied_to', label: 'copied_to' },
+ { value: 'copied_from', label: 'copied_from' },
+];
+
+// ============================================================================
+// Closed-state lookup -- the mirror's ``isClosed`` field can be stale or
+// missing when the schema cache wasn't yet hydrated at sync time. We trust
+// the live schema (``schema.statuses[*].isClosed``) as the source of truth
+// and fall back to the ticket's own flag.
+// ============================================================================
+const _isTicketClosed = (
+ ticket: RedmineTicket,
+ schemaStatusClosedById: Map,
+): boolean => {
+ if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) {
+ return schemaStatusClosedById.get(ticket.statusId) === true;
+ }
+ return !!ticket.isClosed;
+};
+
+export const RedmineBrowserView: React.FC = () => {
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const instanceId = useInstanceId();
+
+ const [config, setConfig] = useState(null);
+ const [schema, setSchema] = useState(null);
+ const [tickets, setTickets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Filters
+ const [period, setPeriod] = useState(null);
+ // Tracker filter is *subtractive*: the set holds tracker ids that are
+ // currently SHOWN. ``null`` means "uninitialised" -- once the schema is
+ // loaded we seed the set with all tracker ids so every chip starts active.
+ // Clicking a chip removes it from the set -> tickets of that tracker
+ // disappear from the list.
+ const [selectedTrackerIds, setSelectedTrackerIds] = useState | null>(null);
+ const [selectedAssigneeIds, setSelectedAssigneeIds] = useState>(new Set());
+ const [selectedRelTypes, setSelectedRelTypes] = useState>(
+ new Set(RELATION_TYPE_OPTIONS.map(r => r.value)),
+ );
+ const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*');
+ // Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic
+ // value ``__none__`` matches tickets with no sprint assigned.
+ const [selectedSprints, setSelectedSprints] = useState>(new Set());
+
+ // UI state
+ const [expanded, setExpanded] = useState>(new Set());
+ const [selectedId, setSelectedId] = useState(null);
+
+ const rootTrackerId = schema?.rootTrackerId ?? null;
+
+ // Seed the tracker filter once the schema is available: every tracker
+ // starts SELECTED so the user sees everything by default; clicking a chip
+ // removes that tracker from the visible set.
+ useEffect(() => {
+ if (selectedTrackerIds == null && schema) {
+ setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id)));
+ }
+ }, [schema, selectedTrackerIds]);
+
+ // Map statusId -> isClosed, taken from the live schema. Used by the status
+ // filter so it works even if the mirror's per-ticket ``isClosed`` is stale.
+ const schemaStatusClosedById = useMemo(() => {
+ const m = new Map();
+ if (schema) {
+ for (const s of schema.statuses) {
+ if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed);
+ }
+ }
+ return m;
+ }, [schema]);
+
+ // Distinct sprints (fixed_version) seen across all loaded tickets. Drives
+ // the sprint filter dropdown. Sorted alphabetically for stable UI.
+ const sprintOptions = useMemo(() => {
+ const m = new Map();
+ let hasNone = false;
+ for (const tk of tickets) {
+ const name = tk.fixedVersionName?.trim();
+ if (name) {
+ m.set(name, name);
+ } else {
+ hasNone = true;
+ }
+ }
+ const opts = Array.from(m.entries())
+ .map(([value, label]) => ({ value, label }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') });
+ return opts;
+ }, [tickets, t]);
+
+ // Load config + schema once.
+ const _loadMeta = useCallback(async () => {
+ if (!instanceId) return;
+ try {
+ const [c, s] = await Promise.all([
+ getRedmineConfigApi(request, instanceId),
+ getRedmineSchemaApi(request, instanceId),
+ ]);
+ setConfig(c);
+ setSchema(s);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen'));
+ }
+ }, [request, instanceId, t]);
+
+ // Load tickets from mirror whenever the period window changes (backend can
+ // pre-filter by updatedOn to shrink the payload).
+ const _loadTickets = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await listRedmineTicketsApi(request, instanceId, {
+ status: '*',
+ dateFrom: period?.fromDate,
+ dateTo: period?.toDate,
+ });
+ setTickets(result);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen'));
+ setTickets([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [request, instanceId, period, t]);
+
+ useEffect(() => { _loadMeta(); }, [_loadMeta]);
+ useEffect(() => { _loadTickets(); }, [_loadTickets]);
+
+ // Client-side filter pass (tracker / assignee / status). Root-tracker
+ // tickets are always kept so the tree has roots even if their own tracker
+ // is deselected -- otherwise the whole forest collapses.
+ // ``deferredFilters`` lets React keep the filter chips snappy while the
+ // potentially expensive tree rebuild happens in the background. The
+ // chips update immediately (urgent state) but the tree picks up the new
+ // values one tick later, which removes the "click feels frozen" lag.
+ const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds);
+ const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds);
+ const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes);
+ const deferredStatusFilter = useDeferredValue(statusFilter);
+ const deferredSelectedSprints = useDeferredValue(selectedSprints);
+
+ const filteredTickets = useMemo(() => {
+ const trackerSet = deferredSelectedTrackerIds;
+ const assigneeSet = deferredSelectedAssigneeIds;
+ const sprintSet = deferredSelectedSprints;
+ const status = deferredStatusFilter;
+ return tickets.filter(ticket => {
+ const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId;
+ if (!isRoot && trackerSet != null && ticket.trackerId != null) {
+ if (!trackerSet.has(ticket.trackerId)) return false;
+ }
+ if (assigneeSet.size > 0) {
+ if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false;
+ }
+ if (status !== '*') {
+ const closed = _isTicketClosed(ticket, schemaStatusClosedById);
+ if (status === 'open' && closed) return false;
+ if (status === 'closed' && !closed) return false;
+ }
+ if (sprintSet.size > 0) {
+ const sprintKey = ticket.fixedVersionName?.trim() || '__none__';
+ if (!sprintSet.has(sprintKey)) return false;
+ }
+ return true;
+ });
+ }, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]);
+
+ // Convert the rel-type set to an array once per change instead of on every
+ // ``buildForest`` call (Array.from() in the deps would re-run the memo
+ // every render because the array identity is fresh each time).
+ const allowedRelTypesArr = useMemo(
+ () => Array.from(deferredSelectedRelTypes),
+ [deferredSelectedRelTypes],
+ );
+
+ const forest: Forest = useMemo(() => {
+ return buildForest(filteredTickets, {
+ rootTrackerId,
+ allowedRelTypes: allowedRelTypesArr,
+ });
+ }, [filteredTickets, rootTrackerId, allowedRelTypesArr]);
+
+ const flatRows: FlatRow[] = useMemo(
+ () => flattenForest(forest.trees, expanded),
+ [forest.trees, expanded],
+ );
+
+ const ticketsById = useMemo(() => {
+ const m = new Map();
+ for (const ticket of tickets) m.set(ticket.id, ticket);
+ return m;
+ }, [tickets]);
+
+ // One-shot initial expansion: the very first time we render a non-empty
+ // forest, expand the root nodes so the user sees the overview. After that
+ // the user owns the expand state -- collapsing all must STAY collapsed.
+ const _didInitExpand = useRef(false);
+ useEffect(() => {
+ if (!_didInitExpand.current && forest.trees.length > 0) {
+ _didInitExpand.current = true;
+ setExpanded(new Set(forest.trees.map(tr => tr.id)));
+ }
+ }, [forest.trees]);
+
+ const _toggleExpand = useCallback((id: number) => {
+ setExpanded(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const _expandAll = useCallback(() => {
+ setExpanded(new Set(collectAllIds(forest.trees)));
+ }, [forest.trees]);
+
+ const _collapseAll = useCallback(() => {
+ setExpanded(new Set());
+ }, []);
+
+ const _resetFilters = useCallback(() => {
+ setPeriod(null);
+ // "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set.
+ setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null);
+ setSelectedAssigneeIds(new Set());
+ setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value)));
+ setStatusFilter('*');
+ setSelectedSprints(new Set());
+ }, [schema]);
+
+ const _toggleSprint = useCallback((value: string) => {
+ setSelectedSprints(prev => {
+ const next = new Set(prev);
+ if (next.has(value)) next.delete(value); else next.add(value);
+ return next;
+ });
+ }, []);
+
+ const _toggleTracker = useCallback((id: number) => {
+ setSelectedTrackerIds(prev => {
+ // ``prev`` is null only before schema loads -- the chip wouldn't be
+ // clickable in that state, but stay defensive.
+ const next = new Set(prev ?? []);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const _toggleRelType = useCallback((rt: string) => {
+ setSelectedRelTypes(prev => {
+ const next = new Set(prev);
+ if (next.has(rt)) next.delete(rt); else next.add(rt);
+ return next;
+ });
+ }, []);
+
+ const _handleTicketSaved = useCallback((updated: RedmineTicket) => {
+ setTickets(prev => prev.map(x => (x.id === updated.id ? updated : x)));
+ }, []);
+
+ if (!instanceId) {
+ return {t('Keine Feature-Instanz ausgewaehlt')}
;
+ }
+
+ return (
+
+
+
+
{t('Redmine -- Ticket-Browser')}
+
+ {t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', {
+ name: schema?.rootTrackerName || config?.rootTrackerName || '—',
+ })}
+
+
+
+ {t('{count} von {total} Tickets sichtbar', {
+ count: filteredTickets.length,
+ total: tickets.length,
+ })}
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
setPeriod(next)}
+ direction="past"
+ defaultPreset={{ kind: 'lastQuarter' }}
+ enabledPresets={[
+ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
+ 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
+ ]}
+ placeholder={t('Alle Zeiten')}
+ />
+
+
+
+
+
+
+
+ {schema && schema.trackers.length > 0 && (
+
+
+
+ {sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => {
+ const isRoot = tr.id === rootTrackerId;
+ // Active iff in the visible set (or root). selectedTrackerIds
+ // is null only during the brief window before the schema seed
+ // effect runs -- treat as "all visible".
+ const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id);
+ const sty = getTrackerStyle(tr.name);
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {schema && schema.users.length > 0 && (
+
+
+
+
+ )}
+
+ {sprintOptions.length > 0 && (
+
+
+
+ {sprintOptions.map(sp => {
+ const active = selectedSprints.has(sp.value);
+ return (
+
+ );
+ })}
+
+ {selectedSprints.size === 0 && (
+
+ {t('keine Auswahl = alle Sprints')}
+
+ )}
+
+ )}
+
+
+
+
+ {RELATION_TYPE_OPTIONS.map(rt => {
+ const active = selectedRelTypes.has(rt.value);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ {/* Left: tree */}
+
+
+
+ {t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', {
+ rows: flatRows.length,
+ orphans: forest.orphanCount,
+ })}
+
+
+
+
+
+
+
+ {loading ? (
+
{t('Baum wird aufgebaut ...')}
+ ) : flatRows.length === 0 ? (
+
+ {t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
+
+ ) : (
+
+
+
{t('Ticket')}
+
Status
+
{t('Prio')}
+
{t('Zuweisung')}
+
{t('Geaendert')}
+
{t('Beziehung')}
+
+ {flatRows.map(row => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Right: editor */}
+
+ {selectedId == null ? (
+
{t('Ticket links auswaehlen')}
+ ) : selectedId === ORPHAN_ROOT_ID ? (
+
+ {t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', {
+ count: forest.orphanCount,
+ })}
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+// ============================================================================
+// TreeRow -- a single grid row, handling its own indent painting.
+// ============================================================================
+
+interface TreeRowProps {
+ row: FlatRow;
+ ticket: RedmineTicket | null;
+ selected: boolean;
+ expanded: boolean;
+ onToggle: (id: number) => void;
+ onSelect: (id: number) => void;
+ rootTrackerId: number | null;
+ schemaStatusClosedById: Map;
+}
+
+const _TreeRowImpl: React.FC = ({
+ row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById,
+}) => {
+ const { node, depth, indentLines, hasChildren } = row;
+ const isOrphanRoot = node.id === ORPHAN_ROOT_ID;
+ const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId;
+ const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false;
+
+ const rowClass = [
+ styles.treeRow,
+ selected ? styles.selected : '',
+ isOrphanRoot ? styles.orphan : '',
+ ].filter(Boolean).join(' ');
+
+ const _handleToggle = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggle(node.id);
+ };
+
+ const _handleSelect = () => onSelect(node.id);
+
+ return (
+
+
+ {Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => (
+
+ ))}
+ {depth > 0 && (
+
+
+
+ )}
+ {hasChildren ? (
+
+ ) : (
+
+ )}
+ {
+ const sty = getTrackerStyle(ticket.trackerName);
+ return {
+ background: sty.bg,
+ color: sty.fg,
+ border: `1px solid ${sty.border}`,
+ };
+ })()
+ : undefined
+ }
+ >
+ {isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')}
+
+
+ {isOrphanRoot ? '—' : `#${ticket?.id}`}
+
+
+ {isOrphanRoot
+ ? `Tickets ohne Verbindung zu einer User Story (${node.children.length})`
+ : (ticket?.subject || '(ohne Titel)')}
+
+
+
+ {isOrphanRoot ? —
+ : ticket?.statusName
+ ? {ticket.statusName}
+ : —}
+
+
{isOrphanRoot ? — : (ticket?.priorityName || '—')}
+
{isOrphanRoot ? — : (ticket?.assignedToName || —)}
+
+ {isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
+
+
+ {isOrphanRoot
+ ? virtuell
+ : isRootTracker && depth === 0
+ ? Root
+ : node.relType
+ ? {node.dir === 'in' ? '←' : '→'} {node.relType}
+ : —}
+
+
+ );
+};
+
+// Custom equality: skip re-rendering rows whose visible inputs are identical.
+// ``row`` and ``ticket`` references are stable across re-renders unless the
+// underlying tree was rebuilt or the ticket itself changed -- much cheaper
+// than re-painting 2k DOM nodes on every filter chip click.
+const TreeRow = React.memo(_TreeRowImpl, (prev, next) => {
+ return (
+ prev.row === next.row
+ && prev.ticket === next.ticket
+ && prev.selected === next.selected
+ && prev.expanded === next.expanded
+ && prev.onToggle === next.onToggle
+ && prev.onSelect === next.onSelect
+ && prev.rootTrackerId === next.rootTrackerId
+ && prev.schemaStatusClosedById === next.schemaStatusClosedById
+ );
+});
+
+export default RedmineBrowserView;
diff --git a/src/pages/views/redmine/RedmineSettingsView.tsx b/src/pages/views/redmine/RedmineSettingsView.tsx
new file mode 100644
index 0000000..d0ad5e0
--- /dev/null
+++ b/src/pages/views/redmine/RedmineSettingsView.tsx
@@ -0,0 +1,349 @@
+/**
+ * Redmine Settings View
+ *
+ * Configure the Redmine connection for this feature instance:
+ * - Base URL, Project ID, API Key, Root Tracker name
+ * - "Verbindung testen" -- calls whoAmI + getProject and reports the result
+ * - "Sync starten" -- pulls all (or only changed) tickets into the local mirror
+ *
+ * The user tests the feature directly here in Porta -- no pytest sandbox.
+ */
+
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useApiRequest } from '../../../hooks/useApi';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import {
+ RedmineConfigDto,
+ RedmineConnectionTestResult,
+ RedmineSyncResult,
+ RedmineSyncStatus,
+ deleteRedmineConfigApi,
+ getRedmineConfigApi,
+ getRedmineSyncStatusApi,
+ runRedmineSyncApi,
+ testRedmineConnectionApi,
+ updateRedmineConfigApi,
+} from '../../../api/redmineApi';
+
+import styles from './RedmineViews.module.css';
+
+const _formatTs = (ts?: number | null): string => {
+ if (!ts) return '-';
+ try {
+ return new Date(ts * 1000).toLocaleString();
+ } catch {
+ return String(ts);
+ }
+};
+
+const _formatDuration = (ms?: number | null): string => {
+ if (ms == null) return '-';
+ if (ms < 1000) return `${ms} ms`;
+ const s = ms / 1000;
+ if (s < 60) return `${s.toFixed(1)} s`;
+ const m = s / 60;
+ return `${m.toFixed(1)} min`;
+};
+
+export const RedmineSettingsView: React.FC = () => {
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const instanceId = useInstanceId();
+
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [syncing, setSyncing] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [testResult, setTestResult] = useState(null);
+ const [syncResult, setSyncResult] = useState(null);
+ const [syncStatus, setSyncStatus] = useState(null);
+
+ const [baseUrl, setBaseUrl] = useState('');
+ const [projectId, setProjectId] = useState('');
+ const [rootTrackerName, setRootTrackerName] = useState('Userstory');
+ const [apiKey, setApiKey] = useState('');
+
+ const _hydrate = useCallback((c: RedmineConfigDto) => {
+ setConfig(c);
+ setBaseUrl(c.baseUrl || '');
+ setProjectId(c.projectId || '');
+ setRootTrackerName(c.rootTrackerName || 'Userstory');
+ }, []);
+
+ const _loadStatus = useCallback(async () => {
+ if (!instanceId) return;
+ try {
+ const status = await getRedmineSyncStatusApi(request, instanceId);
+ setSyncStatus(status);
+ } catch {
+ // status is optional; don't block the page on failure
+ }
+ }, [request, instanceId]);
+
+ useEffect(() => {
+ if (!instanceId) return;
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const cfg = await getRedmineConfigApi(request, instanceId);
+ if (!cancelled) _hydrate(cfg);
+ await _loadStatus();
+ } catch (e: any) {
+ if (!cancelled) setError(e?.message || t('Fehler beim Laden'));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [request, instanceId, _hydrate, _loadStatus, t]);
+
+ const _save = useCallback(async () => {
+ if (!instanceId) return;
+ setSaving(true);
+ setError(null);
+ setSuccess(null);
+ try {
+ const body: Record = {
+ baseUrl: baseUrl.trim(),
+ projectId: projectId.trim(),
+ rootTrackerName: rootTrackerName.trim() || 'Userstory',
+ isActive: true,
+ };
+ if (apiKey.trim() !== '') body.apiKey = apiKey.trim();
+ const updated = await updateRedmineConfigApi(request, instanceId, body);
+ _hydrate(updated);
+ setApiKey('');
+ setSuccess(t('Einstellungen gespeichert.'));
+ setTimeout(() => setSuccess(null), 3000);
+ } catch (e: any) {
+ setError(e?.message || t('Fehler beim Speichern.'));
+ } finally {
+ setSaving(false);
+ }
+ }, [request, instanceId, baseUrl, projectId, rootTrackerName, apiKey, _hydrate, t]);
+
+ const _test = useCallback(async () => {
+ if (!instanceId) return;
+ setTesting(true);
+ setError(null);
+ setTestResult(null);
+ try {
+ const result = await testRedmineConnectionApi(request, instanceId);
+ setTestResult(result);
+ } catch (e: any) {
+ setError(e?.message || t('Verbindungstest fehlgeschlagen.'));
+ } finally {
+ setTesting(false);
+ }
+ }, [request, instanceId, t]);
+
+ const _runSync = useCallback(async (force: boolean) => {
+ if (!instanceId) return;
+ setSyncing(true);
+ setError(null);
+ setSuccess(null);
+ setSyncResult(null);
+ try {
+ const result = await runRedmineSyncApi(request, instanceId, force);
+ setSyncResult(result);
+ await _loadStatus();
+ const cfg = await getRedmineConfigApi(request, instanceId);
+ _hydrate(cfg);
+ setSuccess(
+ t('Sync erfolgreich.') +
+ ` ${result.ticketsUpserted} ${t('Tickets')}, ${result.relationsUpserted} ${t('Beziehungen')}, ${_formatDuration(result.durationMs)}.`,
+ );
+ } catch (e: any) {
+ setError(e?.message || t('Sync fehlgeschlagen.'));
+ } finally {
+ setSyncing(false);
+ }
+ }, [request, instanceId, _loadStatus, _hydrate, t]);
+
+ const _delete = useCallback(async () => {
+ if (!instanceId) return;
+ if (!window.confirm(t('Konfiguration wirklich loeschen? Der lokale Mirror bleibt erhalten.'))) return;
+ setError(null);
+ setSuccess(null);
+ try {
+ await deleteRedmineConfigApi(request, instanceId);
+ setBaseUrl('');
+ setProjectId('');
+ setRootTrackerName('Userstory');
+ setApiKey('');
+ setConfig(null);
+ setSuccess(t('Konfiguration geloescht.'));
+ } catch (e: any) {
+ setError(e?.message || t('Loeschen fehlgeschlagen.'));
+ }
+ }, [request, instanceId, t]);
+
+ if (loading) {
+ return {t('Einstellungen werden geladen ...')}
;
+ }
+
+ const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId;
+ const canSync = canTest;
+
+ return (
+
+
{t('Redmine -- Einstellungen')}
+
+ {t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')}
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+
{t('Verbindung')}
+
+
+
+
setBaseUrl(e.target.value)}
+ placeholder="https://redmine.example.com"
+ spellCheck={false}
+ />
+
{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}
+
+
+
+
+ setProjectId(e.target.value)}
+ placeholder="logobject-mars"
+ spellCheck={false}
+ />
+
+
+
+
+
setRootTrackerName(e.target.value)}
+ placeholder="Userstory"
+ spellCheck={false}
+ />
+
+ {t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')}
+
+
+
+
+
+
setApiKey(e.target.value)}
+ placeholder={config?.hasApiKey ? t('(gesetzt -- leer lassen, um nicht zu aendern)') : t('Redmine API Access Key')}
+ spellCheck={false}
+ autoComplete="new-password"
+ />
+
+ {t('Wird verschluesselt gespeichert. Status: ')}
+ {config?.hasApiKey ? {t('gesetzt')} : {t('nicht gesetzt')}}
+
+
+
+
+
+
+ {config?.id && (
+
+ )}
+
+
+ {testResult && (
+
+ {testResult.ok ? (
+
+ {t('Verbindung OK')}.{' '}
+ {testResult.user?.name && <>{t('Angemeldet als')} {testResult.user.name}. >}
+ {testResult.project?.name && <>{t('Projekt')}: {testResult.project.name}.>}
+
+ ) : (
+
+ {t('Verbindung fehlgeschlagen')}.{' '}
+ {testResult.message || testResult.reason || ''}
+ {testResult.status ? ` (HTTP ${testResult.status})` : ''}
+
+ )}
+
+ )}
+
+
+
+
{t('Mirror-Sync')}
+
+ {t('Tickets werden in die lokale Datenbank gespiegelt, damit Statistik und Browser auch bei 20\u2019000+ Tickets schnell sind. Nach Aenderungen wird das Mirror-Bild automatisch nachgezogen.')}
+
+
+
+
{t('Letzter Sync')}:
+
{_formatTs(config?.lastSyncAt)}
+
{t('Letzter Full-Sync')}:
+
{_formatTs(config?.lastFullSyncAt)}
+
{t('Letzte Sync-Dauer')}:
+
{_formatDuration(syncStatus?.lastSyncDurationMs)}
+
{t('Tickets im Mirror')}:
+
{syncStatus?.mirroredTicketCount ?? '-'}
+
{t('Beziehungen im Mirror')}:
+
{syncStatus?.mirroredRelationCount ?? '-'}
+ {config?.lastSyncErrorMessage && (
+ <>
+
{t('Letzter Fehler')}:
+
{config.lastSyncErrorMessage}
+ >
+ )}
+
+
+
+
+
+
+
+ {syncResult && (
+
+ {syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:{' '}
+ {syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '}
+ {t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}.
+
+ )}
+
+ {!canSync && (
+
+ {t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')}
+
+ )}
+
+
+ );
+};
+
+export default RedmineSettingsView;
diff --git a/src/pages/views/redmine/RedmineStatsView.tsx b/src/pages/views/redmine/RedmineStatsView.tsx
new file mode 100644
index 0000000..e8cbfb7
--- /dev/null
+++ b/src/pages/views/redmine/RedmineStatsView.tsx
@@ -0,0 +1,395 @@
+/**
+ * Redmine Statistics View
+ *
+ * Default landing view for a Redmine feature instance. Reads aggregated
+ * stats from the local mirror (fast, even at 20k+ tickets) and renders
+ * KPIs + charts via ``FormGeneratorReport``. The built-in
+ * ``dateRangeSelector`` mounts the shared ``PeriodPicker`` -- no extra
+ * wiring needed.
+ *
+ * Buckets returned by the backend are mapped to ``ReportSection``s here
+ * (frontend does the UI shape; backend stays storage-oriented).
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { useApiRequest } from '../../../hooks/useApi';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import {
+ RedmineFieldSchema,
+ RedmineStats,
+ getRedmineSchemaApi,
+ getRedmineStatsApi,
+} from '../../../api/redmineApi';
+
+import { FormGeneratorReport } from '../../../components/FormGenerator/FormGeneratorReport';
+import type {
+ ReportSection,
+ ReportFilterState,
+ ReportDateRangeSelectorConfig,
+ ReportFilterConfig,
+} from '../../../components/FormGenerator/FormGeneratorReport';
+import { toIsoDate } from '../../../components/PeriodPicker';
+
+import styles from './RedmineViews.module.css';
+
+// ============================================================================
+// Helpers -- map raw backend buckets to ReportSection[]
+// ============================================================================
+
+// Format counts as integers ("Einheiten") -- prevents the chart components
+// from falling back to their default currency formatter (CHF).
+const _fmtUnits = (v: number): string => {
+ if (!Number.isFinite(v)) return '0';
+ return Math.round(v).toLocaleString('de-CH');
+};
+
+const _buildSections = (
+ stats: RedmineStats,
+ t: (key: string, vars?: Record) => string,
+): ReportSection[] => {
+ const sections: ReportSection[] = [];
+
+ // ---- KPI tiles --------------------------------------------------------
+ sections.push({
+ type: 'kpiGrid',
+ span: 'full',
+ items: [
+ { label: t('Tickets gesamt'), value: stats.kpis.total },
+ { label: t('Offen'), value: stats.kpis.open, color: '#4A6FA5' },
+ { label: t('Geschlossen'), value: stats.kpis.closed, color: '#38A169' },
+ { label: t('Im Zeitraum erstellt'), value: stats.kpis.createdInPeriod },
+ { label: t('Im Zeitraum geschlossen'), value: stats.kpis.closedInPeriod },
+ {
+ label: t('Ohne Userstory (Orphans)'),
+ value: stats.kpis.orphans,
+ color: stats.kpis.orphans > 0 ? '#C53030' : undefined,
+ },
+ ],
+ });
+
+ // ---- Snapshot chart: total tickets vs. open per bucket end ----------
+ // ``cumTotal`` and ``cumOpen`` are computed server-side and are SNAPSHOT
+ // values (state at the end of each bucket), not flow numbers. The
+ // difference between the two lines is the cumulative number of closed
+ // tickets up to that point in time.
+ if (stats.throughput.length > 0) {
+ const snapshotData = stats.throughput.map(b => ({
+ date: b.label,
+ total: b.cumTotal,
+ open: b.cumOpen,
+ }));
+ sections.push({
+ type: 'lineChart',
+ span: 'full',
+ title: t('Bestand pro {bucket}: Total vs. Offen', { bucket: stats.bucket }),
+ description: t('Snapshot am Ende jeder Periode: wie viele Tickets es zu diesem Zeitpunkt gibt (Total) und wie viele davon noch offen sind. Die Luecke zwischen den Linien sind die bis dahin geschlossenen Tickets.'),
+ data: snapshotData,
+ series: [
+ { key: 'total', label: t('Total'), color: '#4A6FA5' },
+ { key: 'open', label: t('Offen'), color: '#DD6B20' },
+ ],
+ formatValue: _fmtUnits,
+ });
+ }
+
+ // ---- Status per tracker (stacked-like via horizontal bar per tracker)
+ if (stats.statusByTracker.length > 0) {
+ const statusKeys = new Set();
+ stats.statusByTracker.forEach(row => {
+ Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k));
+ });
+ const totals: Record = {};
+ stats.statusByTracker.forEach(row => {
+ Object.entries(row.countsByStatus).forEach(([s, n]) => {
+ totals[s] = (totals[s] || 0) + n;
+ });
+ });
+ sections.push({
+ type: 'pieChart',
+ span: 'half',
+ title: t('Status-Verteilung (gesamt)'),
+ donut: true,
+ data: Object.entries(totals)
+ .sort(([, a], [, b]) => b - a)
+ .map(([status, count]) => ({ key: status, value: count })),
+ formatValue: _fmtUnits,
+ });
+
+ sections.push({
+ type: 'horizontalBar',
+ span: 'half',
+ title: t('Tickets pro Tracker'),
+ data: stats.statusByTracker
+ .slice()
+ .sort((a, b) => b.total - a.total)
+ .map(row => ({
+ key: row.trackerName,
+ value: row.total,
+ })),
+ formatValue: _fmtUnits,
+ });
+ }
+
+ // ---- Top assignees ---------------------------------------------------
+ if (stats.topAssignees.length > 0) {
+ sections.push({
+ type: 'horizontalBar',
+ span: 'half',
+ title: t('Top 10 Zugewiesene (offene Tickets)'),
+ description: t('Offene Tickets nach zugewiesener Person -- zeigt Auslastung.'),
+ data: stats.topAssignees.map(a => ({
+ key: a.name,
+ value: a.open,
+ })),
+ formatValue: _fmtUnits,
+ });
+ }
+
+ // ---- Relation distribution ------------------------------------------
+ if (stats.relationDistribution.length > 0) {
+ sections.push({
+ type: 'pieChart',
+ span: 'half',
+ title: t('Beziehungsarten'),
+ donut: true,
+ data: stats.relationDistribution.map(r => ({
+ key: r.relationType,
+ value: r.count,
+ })),
+ formatValue: _fmtUnits,
+ });
+ }
+
+ // ---- Backlog aging --------------------------------------------------
+ if (stats.backlogAging.length > 0) {
+ sections.push({
+ type: 'barChart',
+ span: 'full',
+ title: t('Backlog-Alter (offene Tickets)'),
+ description: t('Verteilung offener Tickets nach Alter -- hilft alte Leichen zu finden.'),
+ data: stats.backlogAging.map(b => ({
+ key: b.label,
+ value: b.count,
+ })),
+ color: '#DD6B20',
+ formatValue: _fmtUnits,
+ });
+ }
+
+ return sections;
+};
+
+// ============================================================================
+// Main view
+// ============================================================================
+
+type BucketSize = 'day' | 'week' | 'month';
+
+// Translate the polymorphic ``ReportFilterState.filters`` value for one
+// multiselect key into a clean number[] and only call ``setter`` if the
+// list actually changed (prevents an infinite render loop when the
+// FormGenerator re-emits the same state).
+const _applyMultiselectFilter = (
+ raw: any,
+ current: number[],
+ setter: (next: number[]) => void,
+): void => {
+ let next: number[] | null = null;
+ if (Array.isArray(raw)) {
+ next = raw.map(v => Number(v)).filter(n => !Number.isNaN(n));
+ } else if (typeof raw === 'string' && raw !== '') {
+ const n = Number(raw);
+ if (!Number.isNaN(n)) next = [n];
+ } else if (!raw) {
+ next = [];
+ }
+ if (next == null) return;
+ if (next.length === current.length && next.every((v, i) => v === current[i])) return;
+ setter(next);
+};
+
+export const RedmineStatsView: React.FC = () => {
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const instanceId = useInstanceId();
+
+ const [schema, setSchema] = useState(null);
+ const [schemaError, setSchemaError] = useState(null);
+
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [dateFrom, setDateFrom] = useState(undefined);
+ const [dateTo, setDateTo] = useState(undefined);
+ const [bucket, setBucket] = useState('week');
+ const [trackerIds, setTrackerIds] = useState([]);
+ const [categoryIds, setCategoryIds] = useState([]);
+ const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*');
+
+ // Load schema once -- we need trackers for the filter dropdown.
+ const _loadSchema = useCallback(async () => {
+ if (!instanceId) return;
+ try {
+ const res = await getRedmineSchemaApi(request, instanceId);
+ setSchema(res);
+ } catch (e: any) {
+ setSchemaError(e?.response?.data?.detail || e?.message || t('Schema-Laden fehlgeschlagen'));
+ }
+ }, [request, instanceId, t]);
+
+ useEffect(() => { _loadSchema(); }, [_loadSchema]);
+
+ // Load stats whenever the filters change.
+ const _loadStats = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await getRedmineStatsApi(request, instanceId, {
+ dateFrom,
+ dateTo,
+ bucket,
+ trackerIds: trackerIds.length > 0 ? trackerIds : undefined,
+ categoryIds: categoryIds.length > 0 ? categoryIds : undefined,
+ statusFilter,
+ });
+ setStats(res);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || t('Statistik-Laden fehlgeschlagen'));
+ setStats(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [request, instanceId, dateFrom, dateTo, bucket, trackerIds, categoryIds, statusFilter, t]);
+
+ useEffect(() => { _loadStats(); }, [_loadStats]);
+
+ // ---- FormGeneratorReport filter configuration -----------------------
+ const dateRangeSelector = useMemo(() => ({
+ enabled: true,
+ direction: 'past',
+ defaultPresetKind: 'thisQuarter',
+ enabledPresets: [
+ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
+ 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
+ ],
+ }), []);
+
+ const filterConfigs = useMemo(() => {
+ const configs: ReportFilterConfig[] = [
+ {
+ key: 'bucket',
+ label: t('Gruppierung'),
+ type: 'select',
+ defaultValue: bucket,
+ options: [
+ { value: 'day', label: t('Tag') },
+ { value: 'week', label: t('Woche') },
+ { value: 'month', label: t('Monat') },
+ ],
+ },
+ {
+ key: 'statusFilter',
+ label: t('Status'),
+ type: 'select',
+ defaultValue: statusFilter,
+ options: [
+ { value: '*', label: t('Alle') },
+ { value: 'open', label: t('Nur offen') },
+ { value: 'closed', label: t('Nur geschlossen') },
+ ],
+ },
+ ];
+ if (schema && schema.trackers.length > 0) {
+ configs.push({
+ key: 'trackerIds',
+ label: t('Tracker'),
+ type: 'multiselect',
+ options: schema.trackers.map(tr => ({
+ value: String(tr.id),
+ label: tr.name,
+ })),
+ placeholder: t('Alle Tracker'),
+ });
+ }
+ if (schema && schema.categories.length > 0) {
+ configs.push({
+ key: 'categoryIds',
+ label: t('Kategorie'),
+ type: 'multiselect',
+ options: schema.categories.map(cat => ({
+ value: String(cat.id),
+ label: cat.name,
+ })),
+ placeholder: t('Alle Kategorien'),
+ });
+ }
+ return configs;
+ }, [t, bucket, statusFilter, schema]);
+
+ const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
+ if (filterState.periodValue) {
+ setDateFrom(filterState.periodValue.fromDate);
+ setDateTo(filterState.periodValue.toDate);
+ } else if (filterState.dateRange) {
+ setDateFrom(toIsoDate(filterState.dateRange.from));
+ setDateTo(toIsoDate(filterState.dateRange.to));
+ }
+
+ const f = filterState.filters || {};
+ if (typeof f.bucket === 'string' && f.bucket !== bucket) {
+ setBucket(f.bucket as BucketSize);
+ }
+ if (typeof f.statusFilter === 'string' && f.statusFilter !== statusFilter) {
+ const next = f.statusFilter as '*' | 'open' | 'closed';
+ if (next === '*' || next === 'open' || next === 'closed') {
+ setStatusFilter(next);
+ }
+ }
+ _applyMultiselectFilter(f.trackerIds, trackerIds, setTrackerIds);
+ _applyMultiselectFilter(f.categoryIds, categoryIds, setCategoryIds);
+ }, [bucket, statusFilter, trackerIds, categoryIds]);
+
+ // ---- Derived report sections ----------------------------------------
+ const sections = useMemo(() => {
+ if (!stats) return [];
+ return _buildSections(stats, t);
+ }, [stats, t]);
+
+ if (!instanceId) {
+ return {t('Keine Feature-Instanz ausgewaehlt')}
;
+ }
+
+ return (
+
+
{t('Redmine -- Statistik')}
+
+ {t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')}
+
+
+ {schemaError &&
{schemaError}
}
+ {error &&
{error}
}
+
+
+
+ );
+};
+
+export default RedmineStatsView;
diff --git a/src/pages/views/redmine/RedmineTicketEditor.tsx b/src/pages/views/redmine/RedmineTicketEditor.tsx
new file mode 100644
index 0000000..4ef3245
--- /dev/null
+++ b/src/pages/views/redmine/RedmineTicketEditor.tsx
@@ -0,0 +1,296 @@
+/**
+ * Right-pane editor for a single Redmine ticket.
+ *
+ * Pulls the selected ticket fresh from the backend (mirror) to get
+ * custom fields + relations, lets the user edit the primary fields and
+ * adds a "notes" comment. On save, delegates to ``updateRedmineTicketApi``
+ * and calls ``onSaved`` so the parent can refresh its mirror list.
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { useApiRequest } from '../../../hooks/useApi';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import {
+ RedmineFieldSchema,
+ RedmineTicket,
+ RedmineTicketUpdateBody,
+ getRedmineTicketApi,
+ updateRedmineTicketApi,
+} from '../../../api/redmineApi';
+import { getTrackerStyle } from './redmineTrackerColor';
+
+import styles from './RedmineViews.module.css';
+
+interface Props {
+ instanceId: string;
+ ticketId: number;
+ schema: RedmineFieldSchema | null;
+ baseUrl: string;
+ onSaved: (updated: RedmineTicket) => void;
+}
+
+export const RedmineTicketEditor: React.FC = ({
+ instanceId,
+ ticketId,
+ schema,
+ baseUrl,
+ onSaved,
+}) => {
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+
+ const [ticket, setTicket] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [successMsg, setSuccessMsg] = useState(null);
+
+ // Local edit state -- keys mirror the update body.
+ const [subject, setSubject] = useState('');
+ const [description, setDescription] = useState('');
+ const [trackerId, setTrackerId] = useState('');
+ const [statusId, setStatusId] = useState('');
+ const [priorityId, setPriorityId] = useState('');
+ const [assignedToId, setAssignedToId] = useState('');
+ const [parentIssueId, setParentIssueId] = useState('');
+ const [notes, setNotes] = useState('');
+ const [customFieldValues, setCustomFieldValues] = useState>({});
+
+ const _load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ setSuccessMsg(null);
+ try {
+ const t = await getRedmineTicketApi(request, instanceId, ticketId);
+ setTicket(t);
+ setSubject(t.subject || '');
+ setDescription(t.description || '');
+ setTrackerId(t.trackerId ?? '');
+ setStatusId(t.statusId ?? '');
+ setPriorityId(t.priorityId ?? '');
+ setAssignedToId(t.assignedToId ?? '');
+ setParentIssueId(t.parentId ? String(t.parentId) : '');
+ setNotes('');
+ const cfMap: Record = {};
+ for (const cf of t.customFields || []) {
+ cfMap[cf.id] = cf.value == null ? '' : String(cf.value);
+ }
+ setCustomFieldValues(cfMap);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || t('Ticket laden fehlgeschlagen'));
+ setTicket(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [request, instanceId, ticketId, t]);
+
+ useEffect(() => { _load(); }, [_load]);
+
+ const tracker = useMemo(() => {
+ if (!schema || trackerId === '') return null;
+ return schema.trackers.find(x => x.id === trackerId) || null;
+ }, [schema, trackerId]);
+
+ const _handleSave = useCallback(async () => {
+ if (!ticket) return;
+ setSaving(true);
+ setError(null);
+ setSuccessMsg(null);
+ const body: RedmineTicketUpdateBody = {};
+ if (subject !== ticket.subject) body.subject = subject;
+ if (description !== (ticket.description || '')) body.description = description;
+ if (trackerId !== '' && trackerId !== ticket.trackerId) body.trackerId = Number(trackerId);
+ if (statusId !== '' && statusId !== ticket.statusId) body.statusId = Number(statusId);
+ if (priorityId !== '' && priorityId !== ticket.priorityId) body.priorityId = Number(priorityId);
+ if (assignedToId !== '' && assignedToId !== ticket.assignedToId) body.assignedToId = Number(assignedToId);
+ const parentNum = parentIssueId.trim() === '' ? null : Number(parentIssueId);
+ if (parentNum !== null && !Number.isNaN(parentNum) && parentNum !== ticket.parentId) {
+ body.parentIssueId = parentNum;
+ }
+ if (notes.trim() !== '') body.notes = notes.trim();
+ const cfDiff: Record = {};
+ for (const cf of ticket.customFields || []) {
+ const current = cf.value == null ? '' : String(cf.value);
+ const next = customFieldValues[cf.id] ?? '';
+ if (next !== current) cfDiff[cf.id] = next;
+ }
+ if (Object.keys(cfDiff).length > 0) body.customFields = cfDiff;
+
+ if (Object.keys(body).length === 0) {
+ setSaving(false);
+ setSuccessMsg(t('Keine Aenderungen.'));
+ return;
+ }
+ try {
+ const updated = await updateRedmineTicketApi(request, instanceId, ticketId, body);
+ setTicket(updated);
+ setSuccessMsg(t('Ticket gespeichert.'));
+ setNotes('');
+ onSaved(updated);
+ } catch (e: any) {
+ setError(e?.response?.data?.detail || e?.message || t('Speichern fehlgeschlagen'));
+ } finally {
+ setSaving(false);
+ }
+ }, [
+ ticket, subject, description, trackerId, statusId, priorityId, assignedToId,
+ parentIssueId, notes, customFieldValues, request, instanceId, ticketId, onSaved, t,
+ ]);
+
+ const redmineUrl = baseUrl && ticket ? `${baseUrl.replace(/\/$/, '')}/issues/${ticket.id}` : null;
+
+ if (loading) {
+ return {t('Ticket wird geladen ...')}
;
+ }
+
+ if (!ticket) {
+ return (
+
+ {error &&
{error}
}
+
{t('Ticket nicht gefunden.')}
+
+ );
+ }
+
+ return (
+
+
+ {tracker && (() => {
+ const sty = getTrackerStyle(tracker.name);
+ return (
+
+ {tracker.name}
+
+ );
+ })()}
+
#{ticket.id}
+
{subject || t('(ohne Titel)')}
+ {redmineUrl && (
+
+ {t('In Redmine oeffnen')}
+
+ )}
+
+
+ {error &&
{error}
}
+ {successMsg &&
{successMsg}
}
+
+
+
+
setSubject(e.target.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setParentIssueId(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// A stable-ish color mapping for the most common tracker names. Unknown
+// trackers fall back to a neutral blue.
+export default RedmineTicketEditor;
diff --git a/src/pages/views/redmine/RedmineViews.module.css b/src/pages/views/redmine/RedmineViews.module.css
new file mode 100644
index 0000000..3d454c6
--- /dev/null
+++ b/src/pages/views/redmine/RedmineViews.module.css
@@ -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; }
diff --git a/src/pages/views/redmine/index.ts b/src/pages/views/redmine/index.ts
new file mode 100644
index 0000000..7bcad9a
--- /dev/null
+++ b/src/pages/views/redmine/index.ts
@@ -0,0 +1,3 @@
+export { RedmineSettingsView } from './RedmineSettingsView';
+export { RedmineStatsView } from './RedmineStatsView';
+export { RedmineBrowserView } from './RedmineBrowserView';
diff --git a/src/pages/views/redmine/redmineTrackerColor.ts b/src/pages/views/redmine/redmineTrackerColor.ts
new file mode 100644
index 0000000..c472b4a
--- /dev/null
+++ b/src/pages/views/redmine/redmineTrackerColor.ts
@@ -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();
+
+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 = (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));
+ });
+};
diff --git a/src/pages/views/redmine/redmineTreeLogic.ts b/src/pages/views/redmine/redmineTreeLogic.ts
new file mode 100644
index 0000000..f255d8e
--- /dev/null
+++ b/src/pages/views/redmine/redmineTreeLogic.ts
@@ -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();
+ 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,
+ allowedRelTypes: Set | null,
+): Map => {
+ const map = new Map();
+ 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,
+ visited: Set,
+): 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();
+ 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,
+): 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;
+};
diff --git a/src/pages/views/trustee/TrusteeDataTablesView.tsx b/src/pages/views/trustee/TrusteeDataTablesView.tsx
index cf86671..4c70226 100644
--- a/src/pages/views/trustee/TrusteeDataTablesView.tsx
+++ b/src/pages/views/trustee/TrusteeDataTablesView.tsx
@@ -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 = () => {
- {visibleTabs.map((tab) => (
-
diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
index 04735f5..874e74a 100644
--- a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
+++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
@@ -131,6 +131,8 @@ export const TrusteeDataTab: React.FC = ({
maxWidth: attr.maxWidth || 400,
fkSource: attr.fkSource,
fkDisplayField: attr.fkDisplayField,
+ frontendFormat: attr.frontendFormat,
+ frontendFormatLabels: attr.frontendFormatLabels,
}));
}, [attributes, hiddenColumns]);
diff --git a/src/utils/applyFrontendFormat.ts b/src/utils/applyFrontendFormat.ts
new file mode 100644
index 0000000..5e41e7a
--- /dev/null
+++ b/src/utils/applyFrontendFormat.ts
@@ -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 ∈ { 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 = {
+ 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;
+}
diff --git a/src/utils/attributeTypeMapper.ts b/src/utils/attributeTypeMapper.ts
index 658b304..f9e3517 100644
--- a/src/utils/attributeTypeMapper.ts
+++ b/src/utils/attributeTypeMapper.ts
@@ -8,7 +8,8 @@ export type AttributeType =
| 'select'
| 'multiselect'
| 'multilingual'
- | 'integer'
+ | 'integer'
+ | 'int'
| 'float'
| 'number'
| 'timestamp'