From c70274071467e30815da8bfcc7b56eecc4a80fd2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 21 Apr 2026 21:30:15 +0200
Subject: [PATCH] redmine integrated and fixed
---
src/App.tsx | 4 +
src/api/redmineApi.ts | 11 +
.../views/redmine/RedmineBrowserView.tsx | 733 ++++++++++++++++--
src/pages/views/redmine/RedmineStatsView.tsx | 402 ++++++++--
.../views/redmine/RedmineTicketEditor.tsx | 296 +++++++
.../views/redmine/RedmineViews.module.css | 334 ++++++++
.../views/redmine/redmineTrackerColor.ts | 144 ++++
src/pages/views/redmine/redmineTreeLogic.ts | 262 +++++++
8 files changed, 2084 insertions(+), 102 deletions(-)
create mode 100644 src/pages/views/redmine/RedmineTicketEditor.tsx
create mode 100644 src/pages/views/redmine/redmineTrackerColor.ts
create mode 100644 src/pages/views/redmine/redmineTreeLogic.ts
diff --git a/src/App.tsx b/src/App.tsx
index a677a0d..aac8210 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -186,6 +186,10 @@ function App() {
} />
} />
+ {/* Redmine Feature Views */}
+ } />
+ } />
+
{/* Catch-all für unbekannte Sub-Pfade */}
} />
diff --git a/src/api/redmineApi.ts b/src/api/redmineApi.ts
index c89664a..39bc545 100644
--- a/src/api/redmineApi.ts
+++ b/src/api/redmineApi.ts
@@ -63,6 +63,7 @@ export interface RedmineFieldSchema {
statuses: RedmineFieldChoice[];
priorities: RedmineFieldChoice[];
users: RedmineFieldChoice[];
+ categories: RedmineFieldChoice[];
customFields: RedmineCustomFieldSchema[];
rootTrackerName: string;
rootTrackerId: number | null;
@@ -100,6 +101,8 @@ export interface RedmineTicket {
parentId?: number | null;
fixedVersionId?: number | null;
fixedVersionName?: string | null;
+ categoryId?: number | null;
+ categoryName?: string | null;
createdOn?: string | null;
updatedOn?: string | null;
customFields: RedmineCustomFieldValue[];
@@ -143,6 +146,8 @@ export interface RedmineStats {
dateTo?: string | null;
bucket: string;
trackerIds: number[];
+ categoryIds: number[];
+ statusFilter: string;
kpis: {
total: number;
open: number;
@@ -162,6 +167,8 @@ export interface RedmineStats {
label: string;
created: number;
closed: number;
+ cumTotal: number;
+ cumOpen: number;
}>;
topAssignees: Array<{
assignedToId?: number | null;
@@ -367,6 +374,8 @@ export interface RedmineStatsParams {
dateTo?: string;
bucket?: 'day' | 'week' | 'month';
trackerIds?: number[];
+ categoryIds?: number[];
+ statusFilter?: '*' | 'open' | 'closed';
}
export async function getRedmineStatsApi(
@@ -379,6 +388,8 @@ export async function getRedmineStatsApi(
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.bucket) queryParams.bucket = params.bucket;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
+ if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds;
+ if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter;
return await request({
url: `${_baseUrl(instanceId)}/stats`,
method: 'get',
diff --git a/src/pages/views/redmine/RedmineBrowserView.tsx b/src/pages/views/redmine/RedmineBrowserView.tsx
index f85a836..de00d61 100644
--- a/src/pages/views/redmine/RedmineBrowserView.tsx
+++ b/src/pages/views/redmine/RedmineBrowserView.tsx
@@ -1,101 +1,730 @@
/**
- * Redmine Ticket Browser (Phase 2 placeholder).
+ * Redmine Ticket Browser
*
- * Will render the tree-as-table layout from the HTML pilot, with
- * filters and a right-side editor pane. For now: simple flat list from
- * the local mirror so the wiring can be verified.
+ * 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, useEffect, useState } from 'react';
+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,
+ TreeNode,
+ 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);
- const _load = useCallback(async () => {
+ // 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: '*' });
+ const result = await listRedmineTicketsApi(request, instanceId, {
+ status: '*',
+ dateFrom: period?.fromDate,
+ dateTo: period?.toDate,
+ });
setTickets(result);
} catch (e: any) {
- setError(e?.message || t('Fehler beim Laden'));
+ setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen'));
+ setTickets([]);
} finally {
setLoading(false);
}
- }, [request, instanceId, t]);
+ }, [request, instanceId, period, t]);
- useEffect(() => { _load(); }, [_load]);
+ useEffect(() => { _loadMeta(); }, [_loadMeta]);
+ useEffect(() => { _loadTickets(); }, [_loadTickets]);
- if (loading) return
{t('Tickets werden geladen ...')}
;
+ // 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 _toggleAssignee = useCallback((id: number) => {
+ setSelectedAssigneeIds(prev => {
+ 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('Liest aus dem lokalen Mirror. Tree-Layout und Editor-Pane folgen im naechsten Schritt.')}
-
+
+
+
+
{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 || '—',
+ })}
+