redmine integrated and fixed
This commit is contained in:
parent
0bdaf86153
commit
c702740714
8 changed files with 2084 additions and 102 deletions
|
|
@ -186,6 +186,10 @@ function App() {
|
||||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||||
|
|
||||||
|
{/* Redmine Feature Views */}
|
||||||
|
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||||
|
<Route path="browser" element={<FeatureViewPage view="browser" />} />
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export interface RedmineFieldSchema {
|
||||||
statuses: RedmineFieldChoice[];
|
statuses: RedmineFieldChoice[];
|
||||||
priorities: RedmineFieldChoice[];
|
priorities: RedmineFieldChoice[];
|
||||||
users: RedmineFieldChoice[];
|
users: RedmineFieldChoice[];
|
||||||
|
categories: RedmineFieldChoice[];
|
||||||
customFields: RedmineCustomFieldSchema[];
|
customFields: RedmineCustomFieldSchema[];
|
||||||
rootTrackerName: string;
|
rootTrackerName: string;
|
||||||
rootTrackerId: number | null;
|
rootTrackerId: number | null;
|
||||||
|
|
@ -100,6 +101,8 @@ export interface RedmineTicket {
|
||||||
parentId?: number | null;
|
parentId?: number | null;
|
||||||
fixedVersionId?: number | null;
|
fixedVersionId?: number | null;
|
||||||
fixedVersionName?: string | null;
|
fixedVersionName?: string | null;
|
||||||
|
categoryId?: number | null;
|
||||||
|
categoryName?: string | null;
|
||||||
createdOn?: string | null;
|
createdOn?: string | null;
|
||||||
updatedOn?: string | null;
|
updatedOn?: string | null;
|
||||||
customFields: RedmineCustomFieldValue[];
|
customFields: RedmineCustomFieldValue[];
|
||||||
|
|
@ -143,6 +146,8 @@ export interface RedmineStats {
|
||||||
dateTo?: string | null;
|
dateTo?: string | null;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
trackerIds: number[];
|
trackerIds: number[];
|
||||||
|
categoryIds: number[];
|
||||||
|
statusFilter: string;
|
||||||
kpis: {
|
kpis: {
|
||||||
total: number;
|
total: number;
|
||||||
open: number;
|
open: number;
|
||||||
|
|
@ -162,6 +167,8 @@ export interface RedmineStats {
|
||||||
label: string;
|
label: string;
|
||||||
created: number;
|
created: number;
|
||||||
closed: number;
|
closed: number;
|
||||||
|
cumTotal: number;
|
||||||
|
cumOpen: number;
|
||||||
}>;
|
}>;
|
||||||
topAssignees: Array<{
|
topAssignees: Array<{
|
||||||
assignedToId?: number | null;
|
assignedToId?: number | null;
|
||||||
|
|
@ -367,6 +374,8 @@ export interface RedmineStatsParams {
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
bucket?: 'day' | 'week' | 'month';
|
bucket?: 'day' | 'week' | 'month';
|
||||||
trackerIds?: number[];
|
trackerIds?: number[];
|
||||||
|
categoryIds?: number[];
|
||||||
|
statusFilter?: '*' | 'open' | 'closed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRedmineStatsApi(
|
export async function getRedmineStatsApi(
|
||||||
|
|
@ -379,6 +388,8 @@ export async function getRedmineStatsApi(
|
||||||
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
||||||
if (params.bucket) queryParams.bucket = params.bucket;
|
if (params.bucket) queryParams.bucket = params.bucket;
|
||||||
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
|
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({
|
return await request({
|
||||||
url: `${_baseUrl(instanceId)}/stats`,
|
url: `${_baseUrl(instanceId)}/stats`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Split view: tree-as-table on the left (roots = configured root
|
||||||
* filters and a right-side editor pane. For now: simple flat list from
|
* tracker + virtual "Orphan" root), editor pane on the right. All reads
|
||||||
* the local mirror so the wiring can be verified.
|
* 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 { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import {
|
import {
|
||||||
|
RedmineConfigDto,
|
||||||
|
RedmineFieldSchema,
|
||||||
RedmineTicket,
|
RedmineTicket,
|
||||||
|
getRedmineConfigApi,
|
||||||
|
getRedmineSchemaApi,
|
||||||
listRedmineTicketsApi,
|
listRedmineTicketsApi,
|
||||||
} from '../../../api/redmineApi';
|
} 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';
|
import styles from './RedmineViews.module.css';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Relation type options -- Redmine's fixed vocabulary plus our synthetic
|
||||||
|
// "parent" edge (inherited from ``parent_id``).
|
||||||
|
// ============================================================================
|
||||||
|
const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'parent', label: 'parent_id' },
|
||||||
|
{ value: 'relates', label: 'relates' },
|
||||||
|
{ value: 'duplicates', label: 'duplicates' },
|
||||||
|
{ value: 'duplicated', label: 'duplicated' },
|
||||||
|
{ value: 'blocks', label: 'blocks' },
|
||||||
|
{ value: 'blocked', label: 'blocked' },
|
||||||
|
{ value: 'precedes', label: 'precedes' },
|
||||||
|
{ value: 'follows', label: 'follows' },
|
||||||
|
{ value: 'copied_to', label: 'copied_to' },
|
||||||
|
{ value: 'copied_from', label: 'copied_from' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Closed-state lookup -- the mirror's ``isClosed`` field can be stale or
|
||||||
|
// missing when the schema cache wasn't yet hydrated at sync time. We trust
|
||||||
|
// the live schema (``schema.statuses[*].isClosed``) as the source of truth
|
||||||
|
// and fall back to the ticket's own flag.
|
||||||
|
// ============================================================================
|
||||||
|
const _isTicketClosed = (
|
||||||
|
ticket: RedmineTicket,
|
||||||
|
schemaStatusClosedById: Map<number, boolean>,
|
||||||
|
): boolean => {
|
||||||
|
if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) {
|
||||||
|
return schemaStatusClosedById.get(ticket.statusId) === true;
|
||||||
|
}
|
||||||
|
return !!ticket.isClosed;
|
||||||
|
};
|
||||||
|
|
||||||
export const RedmineBrowserView: React.FC = () => {
|
export const RedmineBrowserView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<RedmineConfigDto | null>(null);
|
||||||
|
const [schema, setSchema] = useState<RedmineFieldSchema | null>(null);
|
||||||
const [tickets, setTickets] = useState<RedmineTicket[]>([]);
|
const [tickets, setTickets] = useState<RedmineTicket[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const _load = useCallback(async () => {
|
// Filters
|
||||||
|
const [period, setPeriod] = useState<PeriodValue | null>(null);
|
||||||
|
// Tracker filter is *subtractive*: the set holds tracker ids that are
|
||||||
|
// currently SHOWN. ``null`` means "uninitialised" -- once the schema is
|
||||||
|
// loaded we seed the set with all tracker ids so every chip starts active.
|
||||||
|
// Clicking a chip removes it from the set -> tickets of that tracker
|
||||||
|
// disappear from the list.
|
||||||
|
const [selectedTrackerIds, setSelectedTrackerIds] = useState<Set<number> | null>(null);
|
||||||
|
const [selectedAssigneeIds, setSelectedAssigneeIds] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedRelTypes, setSelectedRelTypes] = useState<Set<string>>(
|
||||||
|
new Set(RELATION_TYPE_OPTIONS.map(r => r.value)),
|
||||||
|
);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*');
|
||||||
|
// Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic
|
||||||
|
// value ``__none__`` matches tickets with no sprint assigned.
|
||||||
|
const [selectedSprints, setSelectedSprints] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const rootTrackerId = schema?.rootTrackerId ?? null;
|
||||||
|
|
||||||
|
// Seed the tracker filter once the schema is available: every tracker
|
||||||
|
// starts SELECTED so the user sees everything by default; clicking a chip
|
||||||
|
// removes that tracker from the visible set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTrackerIds == null && schema) {
|
||||||
|
setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id)));
|
||||||
|
}
|
||||||
|
}, [schema, selectedTrackerIds]);
|
||||||
|
|
||||||
|
// Map statusId -> isClosed, taken from the live schema. Used by the status
|
||||||
|
// filter so it works even if the mirror's per-ticket ``isClosed`` is stale.
|
||||||
|
const schemaStatusClosedById = useMemo(() => {
|
||||||
|
const m = new Map<number, boolean>();
|
||||||
|
if (schema) {
|
||||||
|
for (const s of schema.statuses) {
|
||||||
|
if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
// Distinct sprints (fixed_version) seen across all loaded tickets. Drives
|
||||||
|
// the sprint filter dropdown. Sorted alphabetically for stable UI.
|
||||||
|
const sprintOptions = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
let hasNone = false;
|
||||||
|
for (const tk of tickets) {
|
||||||
|
const name = tk.fixedVersionName?.trim();
|
||||||
|
if (name) {
|
||||||
|
m.set(name, name);
|
||||||
|
} else {
|
||||||
|
hasNone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const opts = Array.from(m.entries())
|
||||||
|
.map(([value, label]) => ({ value, label }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') });
|
||||||
|
return opts;
|
||||||
|
}, [tickets, t]);
|
||||||
|
|
||||||
|
// Load config + schema once.
|
||||||
|
const _loadMeta = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const [c, s] = await Promise.all([
|
||||||
|
getRedmineConfigApi(request, instanceId),
|
||||||
|
getRedmineSchemaApi(request, instanceId),
|
||||||
|
]);
|
||||||
|
setConfig(c);
|
||||||
|
setSchema(s);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen'));
|
||||||
|
}
|
||||||
|
}, [request, instanceId, t]);
|
||||||
|
|
||||||
|
// Load tickets from mirror whenever the period window changes (backend can
|
||||||
|
// pre-filter by updatedOn to shrink the payload).
|
||||||
|
const _loadTickets = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await listRedmineTicketsApi(request, instanceId, { status: '*' });
|
const result = await listRedmineTicketsApi(request, instanceId, {
|
||||||
|
status: '*',
|
||||||
|
dateFrom: period?.fromDate,
|
||||||
|
dateTo: period?.toDate,
|
||||||
|
});
|
||||||
setTickets(result);
|
setTickets(result);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t('Fehler beim Laden'));
|
setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen'));
|
||||||
|
setTickets([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, t]);
|
}, [request, instanceId, period, t]);
|
||||||
|
|
||||||
useEffect(() => { _load(); }, [_load]);
|
useEffect(() => { _loadMeta(); }, [_loadMeta]);
|
||||||
|
useEffect(() => { _loadTickets(); }, [_loadTickets]);
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>{t('Tickets werden geladen ...')}</div>;
|
// Client-side filter pass (tracker / assignee / status). Root-tracker
|
||||||
|
// tickets are always kept so the tree has roots even if their own tracker
|
||||||
|
// is deselected -- otherwise the whole forest collapses.
|
||||||
|
// ``deferredFilters`` lets React keep the filter chips snappy while the
|
||||||
|
// potentially expensive tree rebuild happens in the background. The
|
||||||
|
// chips update immediately (urgent state) but the tree picks up the new
|
||||||
|
// values one tick later, which removes the "click feels frozen" lag.
|
||||||
|
const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds);
|
||||||
|
const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds);
|
||||||
|
const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes);
|
||||||
|
const deferredStatusFilter = useDeferredValue(statusFilter);
|
||||||
|
const deferredSelectedSprints = useDeferredValue(selectedSprints);
|
||||||
|
|
||||||
|
const filteredTickets = useMemo(() => {
|
||||||
|
const trackerSet = deferredSelectedTrackerIds;
|
||||||
|
const assigneeSet = deferredSelectedAssigneeIds;
|
||||||
|
const sprintSet = deferredSelectedSprints;
|
||||||
|
const status = deferredStatusFilter;
|
||||||
|
return tickets.filter(ticket => {
|
||||||
|
const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId;
|
||||||
|
if (!isRoot && trackerSet != null && ticket.trackerId != null) {
|
||||||
|
if (!trackerSet.has(ticket.trackerId)) return false;
|
||||||
|
}
|
||||||
|
if (assigneeSet.size > 0) {
|
||||||
|
if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false;
|
||||||
|
}
|
||||||
|
if (status !== '*') {
|
||||||
|
const closed = _isTicketClosed(ticket, schemaStatusClosedById);
|
||||||
|
if (status === 'open' && closed) return false;
|
||||||
|
if (status === 'closed' && !closed) return false;
|
||||||
|
}
|
||||||
|
if (sprintSet.size > 0) {
|
||||||
|
const sprintKey = ticket.fixedVersionName?.trim() || '__none__';
|
||||||
|
if (!sprintSet.has(sprintKey)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]);
|
||||||
|
|
||||||
|
// Convert the rel-type set to an array once per change instead of on every
|
||||||
|
// ``buildForest`` call (Array.from() in the deps would re-run the memo
|
||||||
|
// every render because the array identity is fresh each time).
|
||||||
|
const allowedRelTypesArr = useMemo(
|
||||||
|
() => Array.from(deferredSelectedRelTypes),
|
||||||
|
[deferredSelectedRelTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const forest: Forest = useMemo(() => {
|
||||||
|
return buildForest(filteredTickets, {
|
||||||
|
rootTrackerId,
|
||||||
|
allowedRelTypes: allowedRelTypesArr,
|
||||||
|
});
|
||||||
|
}, [filteredTickets, rootTrackerId, allowedRelTypesArr]);
|
||||||
|
|
||||||
|
const flatRows: FlatRow[] = useMemo(
|
||||||
|
() => flattenForest(forest.trees, expanded),
|
||||||
|
[forest.trees, expanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ticketsById = useMemo(() => {
|
||||||
|
const m = new Map<number, RedmineTicket>();
|
||||||
|
for (const ticket of tickets) m.set(ticket.id, ticket);
|
||||||
|
return m;
|
||||||
|
}, [tickets]);
|
||||||
|
|
||||||
|
// One-shot initial expansion: the very first time we render a non-empty
|
||||||
|
// forest, expand the root nodes so the user sees the overview. After that
|
||||||
|
// the user owns the expand state -- collapsing all must STAY collapsed.
|
||||||
|
const _didInitExpand = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_didInitExpand.current && forest.trees.length > 0) {
|
||||||
|
_didInitExpand.current = true;
|
||||||
|
setExpanded(new Set(forest.trees.map(tr => tr.id)));
|
||||||
|
}
|
||||||
|
}, [forest.trees]);
|
||||||
|
|
||||||
|
const _toggleExpand = useCallback((id: number) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _expandAll = useCallback(() => {
|
||||||
|
setExpanded(new Set(collectAllIds(forest.trees)));
|
||||||
|
}, [forest.trees]);
|
||||||
|
|
||||||
|
const _collapseAll = useCallback(() => {
|
||||||
|
setExpanded(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _resetFilters = useCallback(() => {
|
||||||
|
setPeriod(null);
|
||||||
|
// "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set.
|
||||||
|
setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null);
|
||||||
|
setSelectedAssigneeIds(new Set());
|
||||||
|
setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value)));
|
||||||
|
setStatusFilter('*');
|
||||||
|
setSelectedSprints(new Set());
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
const _toggleSprint = useCallback((value: string) => {
|
||||||
|
setSelectedSprints(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(value)) next.delete(value); else next.add(value);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _toggleTracker = useCallback((id: number) => {
|
||||||
|
setSelectedTrackerIds(prev => {
|
||||||
|
// ``prev`` is null only before schema loads -- the chip wouldn't be
|
||||||
|
// clickable in that state, but stay defensive.
|
||||||
|
const next = new Set(prev ?? []);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _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 <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.browserPage}>
|
||||||
<h2 className={styles.heading}>{t('Redmine -- Ticket-Browser')}</h2>
|
<div className={styles.browserHeader}>
|
||||||
<p className={styles.subheading}>
|
<div>
|
||||||
{t('Liest aus dem lokalen Mirror. Tree-Layout und Editor-Pane folgen im naechsten Schritt.')}
|
<h2 className={styles.heading} style={{ margin: 0 }}>{t('Redmine -- Ticket-Browser')}</h2>
|
||||||
</p>
|
<p className={styles.subheading} style={{ margin: 0 }}>
|
||||||
|
{t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', {
|
||||||
|
name: schema?.rootTrackerName || config?.rootTrackerName || '—',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #718096)' }}>
|
||||||
|
{t('{count} von {total} Tickets sichtbar', {
|
||||||
|
count: filteredTickets.length,
|
||||||
|
total: tickets.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className={styles.alertErr}>{error}</div>}
|
{error && <div className={styles.alertErr}>{error}</div>}
|
||||||
|
|
||||||
{tickets.length === 0 ? (
|
<div className={styles.browserFilters}>
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.filterGroup}>
|
||||||
{t('Keine Tickets im Mirror. Bitte zuerst in den Einstellungen "Sync starten".')}
|
<label>{t('Zeitraum (letzte Aenderung)')}</label>
|
||||||
|
<PeriodPicker
|
||||||
|
value={period}
|
||||||
|
onChange={(next) => setPeriod(next)}
|
||||||
|
direction="past"
|
||||||
|
defaultPreset={{ kind: 'lastQuarter' }}
|
||||||
|
enabledPresets={[
|
||||||
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||||
|
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||||
|
]}
|
||||||
|
placeholder={t('Alle Zeiten')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className={styles.section}>
|
<div className={styles.filterGroup}>
|
||||||
<h3 className={styles.sectionTitle}>
|
<label>Status</label>
|
||||||
{tickets.length} {t('Tickets')}
|
<select
|
||||||
</h3>
|
className={styles.select}
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
value={statusFilter}
|
||||||
<thead>
|
onChange={e => setStatusFilter(e.target.value as any)}
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border-color, #e2e8f0)' }}>
|
>
|
||||||
<th style={{ padding: '0.4rem' }}>ID</th>
|
<option value="*">{t('Alle')}</option>
|
||||||
<th style={{ padding: '0.4rem' }}>{t('Tracker')}</th>
|
<option value="open">{t('Nur offen')}</option>
|
||||||
<th style={{ padding: '0.4rem' }}>{t('Titel')}</th>
|
<option value="closed">{t('Nur geschlossen')}</option>
|
||||||
<th style={{ padding: '0.4rem' }}>Status</th>
|
</select>
|
||||||
<th style={{ padding: '0.4rem' }}>{t('Zuweisung')}</th>
|
</div>
|
||||||
<th style={{ padding: '0.4rem' }}>{t('Geaendert')}</th>
|
|
||||||
</tr>
|
{schema && schema.trackers.length > 0 && (
|
||||||
</thead>
|
<div className={styles.filterGroup} style={{ minWidth: 240 }}>
|
||||||
<tbody>
|
<label>{t('Tracker (Klick blendet aus -- Root bleibt immer aktiv)')}</label>
|
||||||
{tickets.slice(0, 200).map(ticket => (
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
<tr key={ticket.id} style={{ borderBottom: '1px solid var(--border-color, #f0f0f0)' }}>
|
{sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => {
|
||||||
<td style={{ padding: '0.4rem' }}>#{ticket.id}</td>
|
const isRoot = tr.id === rootTrackerId;
|
||||||
<td style={{ padding: '0.4rem' }}>{ticket.trackerName || '-'}</td>
|
// Active iff in the visible set (or root). selectedTrackerIds
|
||||||
<td style={{ padding: '0.4rem' }}>{ticket.subject}</td>
|
// is null only during the brief window before the schema seed
|
||||||
<td style={{ padding: '0.4rem' }}>{ticket.statusName || '-'}</td>
|
// effect runs -- treat as "all visible".
|
||||||
<td style={{ padding: '0.4rem' }}>{ticket.assignedToName || '-'}</td>
|
const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id);
|
||||||
<td style={{ padding: '0.4rem' }}>{ticket.updatedOn?.slice(0, 10) || '-'}</td>
|
const sty = getTrackerStyle(tr.name);
|
||||||
</tr>
|
return (
|
||||||
|
<button
|
||||||
|
key={tr.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isRoot && _toggleTracker(tr.id)}
|
||||||
|
style={{
|
||||||
|
padding: '2px 9px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `1px solid ${active ? sty.border : 'var(--border-color, #e2e8f0)'}`,
|
||||||
|
background: active ? sty.bg : '#fff',
|
||||||
|
color: active ? sty.fg : 'var(--text-secondary, #4a5568)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
cursor: isRoot ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: isRoot ? 0.9 : 1,
|
||||||
|
textDecoration: !active ? 'line-through' : 'none',
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
isRoot
|
||||||
|
? t('Root-Tracker -- immer aktiv')
|
||||||
|
: active
|
||||||
|
? t('Klicken, um {name} auszublenden', { name: tr.name })
|
||||||
|
: t('Klicken, um {name} wieder anzuzeigen', { name: tr.name })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRoot ? `⚓ ${tr.name}` : tr.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schema && schema.users.length > 0 && (
|
||||||
|
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
|
||||||
|
<label>{t('Zuweisung')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
multiple
|
||||||
|
size={Math.min(4, schema.users.length)}
|
||||||
|
value={Array.from(selectedAssigneeIds).map(String)}
|
||||||
|
onChange={e => {
|
||||||
|
const ids = Array.from(e.target.selectedOptions).map(o => Number(o.value));
|
||||||
|
setSelectedAssigneeIds(new Set(ids));
|
||||||
|
}}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
{schema.users.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name}</option>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
</div>
|
||||||
{tickets.length > 200 && (
|
)}
|
||||||
<p className={styles.hint}>
|
|
||||||
{t('(Anzeige auf 200 begrenzt -- Tree-Layout folgt.)')}
|
{sprintOptions.length > 0 && (
|
||||||
</p>
|
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
|
||||||
|
<label>{t('Sprint (Zielversion)')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 88, overflow: 'auto' }}>
|
||||||
|
{sprintOptions.map(sp => {
|
||||||
|
const active = selectedSprints.has(sp.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sp.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => _toggleSprint(sp.value)}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
|
||||||
|
background: active ? '#4A6FA5' : '#fff',
|
||||||
|
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
title={sp.label}
|
||||||
|
>
|
||||||
|
{sp.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selectedSprints.size === 0 && (
|
||||||
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary, #718096)' }}>
|
||||||
|
{t('keine Auswahl = alle Sprints')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.filterGroup} style={{ minWidth: 260 }}>
|
||||||
|
<label>{t('Beziehungsarten')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{RELATION_TYPE_OPTIONS.map(rt => {
|
||||||
|
const active = selectedRelTypes.has(rt.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={rt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => _toggleRelType(rt.value)}
|
||||||
|
title={t('Beziehungstyp ein-/ausschalten -- nur aktive Typen werden im Baum verfolgt')}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
|
||||||
|
background: active ? '#4A6FA5' : '#fff',
|
||||||
|
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<button className={styles.btnSecondary} onClick={_resetFilters}>
|
||||||
|
{t('Filter zuruecksetzen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.browserBody}>
|
||||||
|
{/* Left: tree */}
|
||||||
|
<div className={styles.browserTreeContainer}>
|
||||||
|
<div className={styles.browserToolbar}>
|
||||||
|
<span>
|
||||||
|
{t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', {
|
||||||
|
rows: flatRows.length,
|
||||||
|
orphans: forest.orphanCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
<button className={styles.linkBtn} onClick={_expandAll} disabled={flatRows.length === 0}>
|
||||||
|
{t('Alle ausklappen')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.linkBtn} onClick={_collapseAll} disabled={flatRows.length === 0}>
|
||||||
|
{t('Einklappen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.treeScroll}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>{t('Baum wird aufgebaut ...')}</div>
|
||||||
|
) : flatRows.length === 0 ? (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
{t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.treeGrid}>
|
||||||
|
<div className={styles.treeHeader}>
|
||||||
|
<div>{t('Ticket')}</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>{t('Prio')}</div>
|
||||||
|
<div>{t('Zuweisung')}</div>
|
||||||
|
<div>{t('Geaendert')}</div>
|
||||||
|
<div>{t('Beziehung')}</div>
|
||||||
|
</div>
|
||||||
|
{flatRows.map(row => (
|
||||||
|
<TreeRow
|
||||||
|
key={row.node.id}
|
||||||
|
row={row}
|
||||||
|
ticket={ticketsById.get(row.node.id) || null}
|
||||||
|
selected={selectedId === row.node.id}
|
||||||
|
expanded={expanded.has(row.node.id)}
|
||||||
|
onToggle={_toggleExpand}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
rootTrackerId={rootTrackerId}
|
||||||
|
schemaStatusClosedById={schemaStatusClosedById}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: editor */}
|
||||||
|
<div className={styles.browserEditorContainer}>
|
||||||
|
{selectedId == null ? (
|
||||||
|
<div className={styles.browserToolbar}>{t('Ticket links auswaehlen')}</div>
|
||||||
|
) : selectedId === ORPHAN_ROOT_ID ? (
|
||||||
|
<div className={styles.browserToolbar}>
|
||||||
|
{t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', {
|
||||||
|
count: forest.orphanCount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RedmineTicketEditor
|
||||||
|
instanceId={instanceId}
|
||||||
|
ticketId={selectedId}
|
||||||
|
schema={schema}
|
||||||
|
baseUrl={config?.baseUrl || ''}
|
||||||
|
onSaved={_handleTicketSaved}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TreeRow -- a single grid row, handling its own indent painting.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TreeRowProps {
|
||||||
|
row: FlatRow;
|
||||||
|
ticket: RedmineTicket | null;
|
||||||
|
selected: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
rootTrackerId: number | null;
|
||||||
|
schemaStatusClosedById: Map<number, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _TreeRowImpl: React.FC<TreeRowProps> = ({
|
||||||
|
row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById,
|
||||||
|
}) => {
|
||||||
|
const { node, depth, indentLines, hasChildren } = row;
|
||||||
|
const isOrphanRoot = node.id === ORPHAN_ROOT_ID;
|
||||||
|
const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId;
|
||||||
|
const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false;
|
||||||
|
|
||||||
|
const rowClass = [
|
||||||
|
styles.treeRow,
|
||||||
|
selected ? styles.selected : '',
|
||||||
|
isOrphanRoot ? styles.orphan : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const _handleToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleSelect = () => onSelect(node.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rowClass} onClick={_handleSelect}>
|
||||||
|
<div className={styles.treeCell}>
|
||||||
|
{Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`${styles.indent}${indentLines[i] ? ` ${styles.line ?? 'line'}` : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{depth > 0 && (
|
||||||
|
<span className={`${styles.indentElbow}${row.isLast ? '' : ` ${styles.mid ?? 'mid'}`}`}>
|
||||||
|
<span className={styles.elbowBar} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button className={styles.treeToggle} onClick={_handleToggle} title={expanded ? 'Einklappen' : 'Ausklappen'}>
|
||||||
|
{expanded ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={styles.treeTogglePlaceholder} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`${styles.trackerPill} ${isOrphanRoot ? styles.orphanPill : ''}`}
|
||||||
|
style={
|
||||||
|
!isOrphanRoot && ticket?.trackerName
|
||||||
|
? (() => {
|
||||||
|
const sty = getTrackerStyle(ticket.trackerName);
|
||||||
|
return {
|
||||||
|
background: sty.bg,
|
||||||
|
color: sty.fg,
|
||||||
|
border: `1px solid ${sty.border}`,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.ticketId}>
|
||||||
|
{isOrphanRoot ? '—' : `#${ticket?.id}`}
|
||||||
|
</span>
|
||||||
|
<span className={styles.ticketSubject} title={isOrphanRoot ? '' : (ticket?.subject || '')}>
|
||||||
|
{isOrphanRoot
|
||||||
|
? `Tickets ohne Verbindung zu einer User Story (${node.children.length})`
|
||||||
|
: (ticket?.subject || '(ohne Titel)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isOrphanRoot ? <span className={styles.muted}>—</span>
|
||||||
|
: ticket?.statusName
|
||||||
|
? <span className={`${styles.statusPill}${isClosed ? ` ${styles.closed ?? 'closed'}` : ''}`}>{ticket.statusName}</span>
|
||||||
|
: <span className={styles.muted}>—</span>}
|
||||||
|
</div>
|
||||||
|
<div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.priorityName || '—')}</div>
|
||||||
|
<div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.assignedToName || <span className={styles.muted}>—</span>)}</div>
|
||||||
|
<div style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isOrphanRoot
|
||||||
|
? <span className={styles.relBadge}>virtuell</span>
|
||||||
|
: isRootTracker && depth === 0
|
||||||
|
? <span className={`${styles.relBadge} ${styles.root ?? 'root'}`}>Root</span>
|
||||||
|
: node.relType
|
||||||
|
? <span className={styles.relBadge}>{node.dir === 'in' ? '←' : '→'} {node.relType}</span>
|
||||||
|
: <span className={styles.muted}>—</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom equality: skip re-rendering rows whose visible inputs are identical.
|
||||||
|
// ``row`` and ``ticket`` references are stable across re-renders unless the
|
||||||
|
// underlying tree was rebuilt or the ticket itself changed -- much cheaper
|
||||||
|
// than re-painting 2k DOM nodes on every filter chip click.
|
||||||
|
const TreeRow = React.memo(_TreeRowImpl, (prev, next) => {
|
||||||
|
return (
|
||||||
|
prev.row === next.row
|
||||||
|
&& prev.ticket === next.ticket
|
||||||
|
&& prev.selected === next.selected
|
||||||
|
&& prev.expanded === next.expanded
|
||||||
|
&& prev.onToggle === next.onToggle
|
||||||
|
&& prev.onSelect === next.onSelect
|
||||||
|
&& prev.rootTrackerId === next.rootTrackerId
|
||||||
|
&& prev.schemaStatusClosedById === next.schemaStatusClosedById
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default RedmineBrowserView;
|
export default RedmineBrowserView;
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,393 @@
|
||||||
/**
|
/**
|
||||||
* Redmine Statistics View (Phase 2 placeholder).
|
* Redmine Statistics View
|
||||||
*
|
*
|
||||||
* Will render a ``FormGeneratorReport`` driven by ``getRedmineStatsApi``
|
* Default landing view for a Redmine feature instance. Reads aggregated
|
||||||
* with a ``PeriodPicker`` and tracker-filter. For now: shows the raw
|
* stats from the local mirror (fast, even at 20k+ tickets) and renders
|
||||||
* KPIs so the wiring can be verified against the local mirror.
|
* 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, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { RedmineStats, getRedmineStatsApi } from '../../../api/redmineApi';
|
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';
|
import styles from './RedmineViews.module.css';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers -- map raw backend buckets to ReportSection[]
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Format counts as integers ("Einheiten") -- prevents the chart components
|
||||||
|
// from falling back to their default currency formatter (CHF).
|
||||||
|
const _fmtUnits = (v: number): string => {
|
||||||
|
if (!Number.isFinite(v)) return '0';
|
||||||
|
return Math.round(v).toLocaleString('de-CH');
|
||||||
|
};
|
||||||
|
|
||||||
|
const _buildSections = (
|
||||||
|
stats: RedmineStats,
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string,
|
||||||
|
): ReportSection[] => {
|
||||||
|
const sections: ReportSection[] = [];
|
||||||
|
|
||||||
|
// ---- KPI tiles --------------------------------------------------------
|
||||||
|
sections.push({
|
||||||
|
type: 'kpiGrid',
|
||||||
|
span: 'full',
|
||||||
|
items: [
|
||||||
|
{ label: t('Tickets gesamt'), value: stats.kpis.total },
|
||||||
|
{ label: t('Offen'), value: stats.kpis.open, color: '#4A6FA5' },
|
||||||
|
{ label: t('Geschlossen'), value: stats.kpis.closed, color: '#38A169' },
|
||||||
|
{ label: t('Im Zeitraum erstellt'), value: stats.kpis.createdInPeriod },
|
||||||
|
{ label: t('Im Zeitraum geschlossen'), value: stats.kpis.closedInPeriod },
|
||||||
|
{
|
||||||
|
label: t('Ohne Userstory (Orphans)'),
|
||||||
|
value: stats.kpis.orphans,
|
||||||
|
color: stats.kpis.orphans > 0 ? '#C53030' : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Snapshot chart: total tickets vs. open per bucket end ----------
|
||||||
|
// ``cumTotal`` and ``cumOpen`` are computed server-side and are SNAPSHOT
|
||||||
|
// values (state at the end of each bucket), not flow numbers. The
|
||||||
|
// difference between the two lines is the cumulative number of closed
|
||||||
|
// tickets up to that point in time.
|
||||||
|
if (stats.throughput.length > 0) {
|
||||||
|
const snapshotData = stats.throughput.map(b => ({
|
||||||
|
date: b.label,
|
||||||
|
total: b.cumTotal,
|
||||||
|
open: b.cumOpen,
|
||||||
|
}));
|
||||||
|
sections.push({
|
||||||
|
type: 'lineChart',
|
||||||
|
span: 'full',
|
||||||
|
title: t('Bestand pro {bucket}: Total vs. Offen', { bucket: stats.bucket }),
|
||||||
|
description: t('Snapshot am Ende jeder Periode: wie viele Tickets es zu diesem Zeitpunkt gibt (Total) und wie viele davon noch offen sind. Die Luecke zwischen den Linien sind die bis dahin geschlossenen Tickets.'),
|
||||||
|
data: snapshotData,
|
||||||
|
series: [
|
||||||
|
{ key: 'total', label: t('Total'), color: '#4A6FA5' },
|
||||||
|
{ key: 'open', label: t('Offen'), color: '#DD6B20' },
|
||||||
|
],
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status per tracker (stacked-like via horizontal bar per tracker)
|
||||||
|
if (stats.statusByTracker.length > 0) {
|
||||||
|
const statusKeys = new Set<string>();
|
||||||
|
stats.statusByTracker.forEach(row => {
|
||||||
|
Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k));
|
||||||
|
});
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
stats.statusByTracker.forEach(row => {
|
||||||
|
Object.entries(row.countsByStatus).forEach(([s, n]) => {
|
||||||
|
totals[s] = (totals[s] || 0) + n;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
sections.push({
|
||||||
|
type: 'pieChart',
|
||||||
|
span: 'half',
|
||||||
|
title: t('Status-Verteilung (gesamt)'),
|
||||||
|
donut: true,
|
||||||
|
data: Object.entries(totals)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([status, count]) => ({ key: status, value: count })),
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
type: 'horizontalBar',
|
||||||
|
span: 'half',
|
||||||
|
title: t('Tickets pro Tracker'),
|
||||||
|
data: stats.statusByTracker
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map(row => ({
|
||||||
|
key: row.trackerName,
|
||||||
|
value: row.total,
|
||||||
|
})),
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Top assignees ---------------------------------------------------
|
||||||
|
if (stats.topAssignees.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
type: 'horizontalBar',
|
||||||
|
span: 'half',
|
||||||
|
title: t('Top 10 Zugewiesene (offene Tickets)'),
|
||||||
|
description: t('Offene Tickets nach zugewiesener Person -- zeigt Auslastung.'),
|
||||||
|
data: stats.topAssignees.map(a => ({
|
||||||
|
key: a.name,
|
||||||
|
value: a.open,
|
||||||
|
})),
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Relation distribution ------------------------------------------
|
||||||
|
if (stats.relationDistribution.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
type: 'pieChart',
|
||||||
|
span: 'half',
|
||||||
|
title: t('Beziehungsarten'),
|
||||||
|
donut: true,
|
||||||
|
data: stats.relationDistribution.map(r => ({
|
||||||
|
key: r.relationType,
|
||||||
|
value: r.count,
|
||||||
|
})),
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backlog aging --------------------------------------------------
|
||||||
|
if (stats.backlogAging.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
type: 'barChart',
|
||||||
|
span: 'full',
|
||||||
|
title: t('Backlog-Alter (offene Tickets)'),
|
||||||
|
description: t('Verteilung offener Tickets nach Alter -- hilft alte Leichen zu finden.'),
|
||||||
|
data: stats.backlogAging.map(b => ({
|
||||||
|
key: b.label,
|
||||||
|
value: b.count,
|
||||||
|
})),
|
||||||
|
color: '#DD6B20',
|
||||||
|
formatValue: _fmtUnits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main view
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type BucketSize = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
// Translate the polymorphic ``ReportFilterState.filters`` value for one
|
||||||
|
// multiselect key into a clean number[] and only call ``setter`` if the
|
||||||
|
// list actually changed (prevents an infinite render loop when the
|
||||||
|
// FormGenerator re-emits the same state).
|
||||||
|
const _applyMultiselectFilter = (
|
||||||
|
raw: any,
|
||||||
|
current: number[],
|
||||||
|
setter: (next: number[]) => void,
|
||||||
|
): void => {
|
||||||
|
let next: number[] | null = null;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
next = raw.map(v => Number(v)).filter(n => !Number.isNaN(n));
|
||||||
|
} else if (typeof raw === 'string' && raw !== '') {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isNaN(n)) next = [n];
|
||||||
|
} else if (!raw) {
|
||||||
|
next = [];
|
||||||
|
}
|
||||||
|
if (next == null) return;
|
||||||
|
if (next.length === current.length && next.every((v, i) => v === current[i])) return;
|
||||||
|
setter(next);
|
||||||
|
};
|
||||||
|
|
||||||
export const RedmineStatsView: React.FC = () => {
|
export const RedmineStatsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const [schema, setSchema] = useState<RedmineFieldSchema | null>(null);
|
||||||
|
const [schemaError, setSchemaError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [stats, setStats] = useState<RedmineStats | null>(null);
|
const [stats, setStats] = useState<RedmineStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const _load = useCallback(async () => {
|
const [dateFrom, setDateFrom] = useState<string | undefined>(undefined);
|
||||||
|
const [dateTo, setDateTo] = useState<string | undefined>(undefined);
|
||||||
|
const [bucket, setBucket] = useState<BucketSize>('week');
|
||||||
|
const [trackerIds, setTrackerIds] = useState<number[]>([]);
|
||||||
|
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*');
|
||||||
|
|
||||||
|
// Load schema once -- we need trackers for the filter dropdown.
|
||||||
|
const _loadSchema = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const res = await getRedmineSchemaApi(request, instanceId);
|
||||||
|
setSchema(res);
|
||||||
|
} catch (e: any) {
|
||||||
|
setSchemaError(e?.response?.data?.detail || e?.message || t('Schema-Laden fehlgeschlagen'));
|
||||||
|
}
|
||||||
|
}, [request, instanceId, t]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadSchema(); }, [_loadSchema]);
|
||||||
|
|
||||||
|
// Load stats whenever the filters change.
|
||||||
|
const _loadStats = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await getRedmineStatsApi(request, instanceId, { bucket: 'week' });
|
const res = await getRedmineStatsApi(request, instanceId, {
|
||||||
setStats(result);
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
bucket,
|
||||||
|
trackerIds: trackerIds.length > 0 ? trackerIds : undefined,
|
||||||
|
categoryIds: categoryIds.length > 0 ? categoryIds : undefined,
|
||||||
|
statusFilter,
|
||||||
|
});
|
||||||
|
setStats(res);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t('Fehler beim Laden'));
|
setError(e?.response?.data?.detail || e?.message || t('Statistik-Laden fehlgeschlagen'));
|
||||||
|
setStats(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, t]);
|
}, [request, instanceId, dateFrom, dateTo, bucket, trackerIds, categoryIds, statusFilter, t]);
|
||||||
|
|
||||||
useEffect(() => { _load(); }, [_load]);
|
useEffect(() => { _loadStats(); }, [_loadStats]);
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>{t('Statistik wird geladen ...')}</div>;
|
// ---- FormGeneratorReport filter configuration -----------------------
|
||||||
|
const dateRangeSelector = useMemo<ReportDateRangeSelectorConfig>(() => ({
|
||||||
|
enabled: true,
|
||||||
|
direction: 'past',
|
||||||
|
defaultPresetKind: 'thisQuarter',
|
||||||
|
enabledPresets: [
|
||||||
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||||
|
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||||
|
],
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const filterConfigs = useMemo<ReportFilterConfig[]>(() => {
|
||||||
|
const configs: ReportFilterConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'bucket',
|
||||||
|
label: t('Gruppierung'),
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: bucket,
|
||||||
|
options: [
|
||||||
|
{ value: 'day', label: t('Tag') },
|
||||||
|
{ value: 'week', label: t('Woche') },
|
||||||
|
{ value: 'month', label: t('Monat') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statusFilter',
|
||||||
|
label: t('Status'),
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: statusFilter,
|
||||||
|
options: [
|
||||||
|
{ value: '*', label: t('Alle') },
|
||||||
|
{ value: 'open', label: t('Nur offen') },
|
||||||
|
{ value: 'closed', label: t('Nur geschlossen') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (schema && schema.trackers.length > 0) {
|
||||||
|
configs.push({
|
||||||
|
key: 'trackerIds',
|
||||||
|
label: t('Tracker'),
|
||||||
|
type: 'multiselect',
|
||||||
|
options: schema.trackers.map(tr => ({
|
||||||
|
value: String(tr.id),
|
||||||
|
label: tr.name,
|
||||||
|
})),
|
||||||
|
placeholder: t('Alle Tracker'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (schema && schema.categories.length > 0) {
|
||||||
|
configs.push({
|
||||||
|
key: 'categoryIds',
|
||||||
|
label: t('Kategorie'),
|
||||||
|
type: 'multiselect',
|
||||||
|
options: schema.categories.map(cat => ({
|
||||||
|
value: String(cat.id),
|
||||||
|
label: cat.name,
|
||||||
|
})),
|
||||||
|
placeholder: t('Alle Kategorien'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return configs;
|
||||||
|
}, [t, bucket, statusFilter, schema]);
|
||||||
|
|
||||||
|
const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||||
|
if (filterState.periodValue) {
|
||||||
|
setDateFrom(filterState.periodValue.fromDate);
|
||||||
|
setDateTo(filterState.periodValue.toDate);
|
||||||
|
} else if (filterState.dateRange) {
|
||||||
|
setDateFrom(toIsoDate(filterState.dateRange.from));
|
||||||
|
setDateTo(toIsoDate(filterState.dateRange.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = filterState.filters || {};
|
||||||
|
if (typeof f.bucket === 'string' && f.bucket !== bucket) {
|
||||||
|
setBucket(f.bucket as BucketSize);
|
||||||
|
}
|
||||||
|
if (typeof f.statusFilter === 'string' && f.statusFilter !== statusFilter) {
|
||||||
|
const next = f.statusFilter as '*' | 'open' | 'closed';
|
||||||
|
if (next === '*' || next === 'open' || next === 'closed') {
|
||||||
|
setStatusFilter(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_applyMultiselectFilter(f.trackerIds, trackerIds, setTrackerIds);
|
||||||
|
_applyMultiselectFilter(f.categoryIds, categoryIds, setCategoryIds);
|
||||||
|
}, [bucket, statusFilter, trackerIds, categoryIds]);
|
||||||
|
|
||||||
|
// ---- Derived report sections ----------------------------------------
|
||||||
|
const sections = useMemo<ReportSection[]>(() => {
|
||||||
|
if (!stats) return [];
|
||||||
|
return _buildSections(stats, t);
|
||||||
|
}, [stats, t]);
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.pageWide}>
|
||||||
<h2 className={styles.heading}>{t('Redmine -- Statistik')}</h2>
|
<h2 className={styles.heading}>{t('Redmine -- Statistik')}</h2>
|
||||||
<p className={styles.subheading}>
|
<p className={styles.subheading}>
|
||||||
{t('Aggregiert aus dem lokalen Mirror. PeriodPicker und FormGeneratorReport folgen im naechsten Schritt.')}
|
{t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{schemaError && <div className={styles.alertErr}>{schemaError}</div>}
|
||||||
{error && <div className={styles.alertErr}>{error}</div>}
|
{error && <div className={styles.alertErr}>{error}</div>}
|
||||||
|
|
||||||
{stats && (
|
<FormGeneratorReport
|
||||||
<div className={styles.section}>
|
title={stats
|
||||||
<h3 className={styles.sectionTitle}>{t('KPIs (gesamter Mirror)')}</h3>
|
? t('{total} Tickets ({open} offen, {closed} geschlossen)', {
|
||||||
<div className={styles.kvGrid}>
|
total: stats.kpis.total,
|
||||||
<div className={styles.kvLabel}>{t('Tickets gesamt')}:</div>
|
open: stats.kpis.open,
|
||||||
<div className={styles.kvValue}>{stats.kpis.total}</div>
|
closed: stats.kpis.closed,
|
||||||
<div className={styles.kvLabel}>{t('Offen')}:</div>
|
})
|
||||||
<div className={styles.kvValue}>{stats.kpis.open}</div>
|
: undefined}
|
||||||
<div className={styles.kvLabel}>{t('Geschlossen')}:</div>
|
sections={sections}
|
||||||
<div className={styles.kvValue}>{stats.kpis.closed}</div>
|
loading={loading}
|
||||||
<div className={styles.kvLabel}>{t('Im Zeitraum erstellt')}:</div>
|
noDataMessage={t('Keine Tickets im Mirror. Starte zuerst den Sync auf der Einstellungen-Seite.')}
|
||||||
<div className={styles.kvValue}>{stats.kpis.createdInPeriod}</div>
|
dateRangeSelector={dateRangeSelector}
|
||||||
<div className={styles.kvLabel}>{t('Im Zeitraum geschlossen')}:</div>
|
filters={filterConfigs}
|
||||||
<div className={styles.kvValue}>{stats.kpis.closedInPeriod}</div>
|
onFilterChange={_handleFilterChange}
|
||||||
<div className={styles.kvLabel}>{t('Orphans (ohne Userstory)')}:</div>
|
/>
|
||||||
<div className={styles.kvValue}>{stats.kpis.orphans}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stats && stats.statusByTracker.length > 0 && (
|
|
||||||
<div className={styles.section}>
|
|
||||||
<h3 className={styles.sectionTitle}>{t('Status pro Tracker')}</h3>
|
|
||||||
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
|
|
||||||
{stats.statusByTracker.map(entry => (
|
|
||||||
<li key={`${entry.trackerId}-${entry.trackerName}`}>
|
|
||||||
<strong>{entry.trackerName}</strong> ({entry.total}):{' '}
|
|
||||||
{Object.entries(entry.countsByStatus)
|
|
||||||
.map(([s, n]) => `${s}: ${n}`)
|
|
||||||
.join(', ')}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
296
src/pages/views/redmine/RedmineTicketEditor.tsx
Normal file
296
src/pages/views/redmine/RedmineTicketEditor.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
/**
|
||||||
|
* Right-pane editor for a single Redmine ticket.
|
||||||
|
*
|
||||||
|
* Pulls the selected ticket fresh from the backend (mirror) to get
|
||||||
|
* custom fields + relations, lets the user edit the primary fields and
|
||||||
|
* adds a "notes" comment. On save, delegates to ``updateRedmineTicketApi``
|
||||||
|
* and calls ``onSaved`` so the parent can refresh its mirror list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
RedmineFieldSchema,
|
||||||
|
RedmineTicket,
|
||||||
|
RedmineTicketUpdateBody,
|
||||||
|
getRedmineTicketApi,
|
||||||
|
updateRedmineTicketApi,
|
||||||
|
} from '../../../api/redmineApi';
|
||||||
|
import { getTrackerStyle } from './redmineTrackerColor';
|
||||||
|
|
||||||
|
import styles from './RedmineViews.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
ticketId: number;
|
||||||
|
schema: RedmineFieldSchema | null;
|
||||||
|
baseUrl: string;
|
||||||
|
onSaved: (updated: RedmineTicket) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RedmineTicketEditor: React.FC<Props> = ({
|
||||||
|
instanceId,
|
||||||
|
ticketId,
|
||||||
|
schema,
|
||||||
|
baseUrl,
|
||||||
|
onSaved,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
|
const [ticket, setTicket] = useState<RedmineTicket | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Local edit state -- keys mirror the update body.
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [trackerId, setTrackerId] = useState<number | ''>('');
|
||||||
|
const [statusId, setStatusId] = useState<number | ''>('');
|
||||||
|
const [priorityId, setPriorityId] = useState<number | ''>('');
|
||||||
|
const [assignedToId, setAssignedToId] = useState<number | ''>('');
|
||||||
|
const [parentIssueId, setParentIssueId] = useState<string>('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [customFieldValues, setCustomFieldValues] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const _load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
const t = await getRedmineTicketApi(request, instanceId, ticketId);
|
||||||
|
setTicket(t);
|
||||||
|
setSubject(t.subject || '');
|
||||||
|
setDescription(t.description || '');
|
||||||
|
setTrackerId(t.trackerId ?? '');
|
||||||
|
setStatusId(t.statusId ?? '');
|
||||||
|
setPriorityId(t.priorityId ?? '');
|
||||||
|
setAssignedToId(t.assignedToId ?? '');
|
||||||
|
setParentIssueId(t.parentId ? String(t.parentId) : '');
|
||||||
|
setNotes('');
|
||||||
|
const cfMap: Record<number, string> = {};
|
||||||
|
for (const cf of t.customFields || []) {
|
||||||
|
cfMap[cf.id] = cf.value == null ? '' : String(cf.value);
|
||||||
|
}
|
||||||
|
setCustomFieldValues(cfMap);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || t('Ticket laden fehlgeschlagen'));
|
||||||
|
setTicket(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, ticketId, t]);
|
||||||
|
|
||||||
|
useEffect(() => { _load(); }, [_load]);
|
||||||
|
|
||||||
|
const tracker = useMemo(() => {
|
||||||
|
if (!schema || trackerId === '') return null;
|
||||||
|
return schema.trackers.find(x => x.id === trackerId) || null;
|
||||||
|
}, [schema, trackerId]);
|
||||||
|
|
||||||
|
const _handleSave = useCallback(async () => {
|
||||||
|
if (!ticket) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
const body: RedmineTicketUpdateBody = {};
|
||||||
|
if (subject !== ticket.subject) body.subject = subject;
|
||||||
|
if (description !== (ticket.description || '')) body.description = description;
|
||||||
|
if (trackerId !== '' && trackerId !== ticket.trackerId) body.trackerId = Number(trackerId);
|
||||||
|
if (statusId !== '' && statusId !== ticket.statusId) body.statusId = Number(statusId);
|
||||||
|
if (priorityId !== '' && priorityId !== ticket.priorityId) body.priorityId = Number(priorityId);
|
||||||
|
if (assignedToId !== '' && assignedToId !== ticket.assignedToId) body.assignedToId = Number(assignedToId);
|
||||||
|
const parentNum = parentIssueId.trim() === '' ? null : Number(parentIssueId);
|
||||||
|
if (parentNum !== null && !Number.isNaN(parentNum) && parentNum !== ticket.parentId) {
|
||||||
|
body.parentIssueId = parentNum;
|
||||||
|
}
|
||||||
|
if (notes.trim() !== '') body.notes = notes.trim();
|
||||||
|
const cfDiff: Record<number, any> = {};
|
||||||
|
for (const cf of ticket.customFields || []) {
|
||||||
|
const current = cf.value == null ? '' : String(cf.value);
|
||||||
|
const next = customFieldValues[cf.id] ?? '';
|
||||||
|
if (next !== current) cfDiff[cf.id] = next;
|
||||||
|
}
|
||||||
|
if (Object.keys(cfDiff).length > 0) body.customFields = cfDiff;
|
||||||
|
|
||||||
|
if (Object.keys(body).length === 0) {
|
||||||
|
setSaving(false);
|
||||||
|
setSuccessMsg(t('Keine Aenderungen.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateRedmineTicketApi(request, instanceId, ticketId, body);
|
||||||
|
setTicket(updated);
|
||||||
|
setSuccessMsg(t('Ticket gespeichert.'));
|
||||||
|
setNotes('');
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || t('Speichern fehlgeschlagen'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
ticket, subject, description, trackerId, statusId, priorityId, assignedToId,
|
||||||
|
parentIssueId, notes, customFieldValues, request, instanceId, ticketId, onSaved, t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const redmineUrl = baseUrl && ticket ? `${baseUrl.replace(/\/$/, '')}/issues/${ticket.id}` : null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.loading}>{t('Ticket wird geladen ...')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return (
|
||||||
|
<div className={styles.editorScroll}>
|
||||||
|
{error && <div className={styles.alertErr}>{error}</div>}
|
||||||
|
<div className={styles.placeholder}>{t('Ticket nicht gefunden.')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.editorScroll}>
|
||||||
|
<div className={styles.editorHeader}>
|
||||||
|
{tracker && (() => {
|
||||||
|
const sty = getTrackerStyle(tracker.name);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={styles.trackerPill}
|
||||||
|
style={{ background: sty.bg, color: sty.fg, border: `1px solid ${sty.border}` }}
|
||||||
|
>
|
||||||
|
{tracker.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className={styles.ticketId}>#{ticket.id}</span>
|
||||||
|
<h3>{subject || t('(ohne Titel)')}</h3>
|
||||||
|
{redmineUrl && (
|
||||||
|
<a
|
||||||
|
href={redmineUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={styles.btnSecondary}
|
||||||
|
style={{ textDecoration: 'none', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{t('In Redmine oeffnen')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.alertErr}>{error}</div>}
|
||||||
|
{successMsg && <div className={styles.alertOk}>{successMsg}</div>}
|
||||||
|
|
||||||
|
<div className={styles.editorGrid}>
|
||||||
|
<label>{t('Titel')}</label>
|
||||||
|
<input className={styles.input} value={subject} onChange={e => setSubject(e.target.value)} />
|
||||||
|
|
||||||
|
<label>{t('Tracker')}</label>
|
||||||
|
<select className={styles.select} value={trackerId} onChange={e => setTrackerId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||||
|
<option value="">{t('(unveraendert)')}</option>
|
||||||
|
{schema?.trackers.map(tr => (
|
||||||
|
<option key={tr.id} value={tr.id}>{tr.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>Status</label>
|
||||||
|
<select className={styles.select} value={statusId} onChange={e => setStatusId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||||
|
<option value="">{t('(unveraendert)')}</option>
|
||||||
|
{schema?.statuses.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}{s.isClosed ? ' (geschlossen)' : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>{t('Prioritaet')}</label>
|
||||||
|
<select className={styles.select} value={priorityId} onChange={e => setPriorityId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||||
|
<option value="">{t('(unveraendert)')}</option>
|
||||||
|
{schema?.priorities.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>{t('Zuweisung')}</label>
|
||||||
|
<select className={styles.select} value={assignedToId} onChange={e => setAssignedToId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||||
|
<option value="">{t('(nicht zugewiesen)')}</option>
|
||||||
|
{schema?.users.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>{t('Uebergeordnet')}</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="number"
|
||||||
|
value={parentIssueId}
|
||||||
|
placeholder={t('Ticket-ID oder leer')}
|
||||||
|
onChange={e => setParentIssueId(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className={styles.fullRow} style={{ paddingTop: '0.45rem' }}>{t('Beschreibung')}</label>
|
||||||
|
<textarea
|
||||||
|
className={`${styles.textarea} ${styles.fullRow}`}
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticket.customFields.length > 0 && (
|
||||||
|
<div className={styles.fullRow} style={{ marginTop: '0.4rem', fontSize: '0.82rem', fontWeight: 600, color: 'var(--text-primary, #1a202c)' }}>
|
||||||
|
{t('Benutzerdefinierte Felder')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ticket.customFields.map(cf => (
|
||||||
|
<React.Fragment key={cf.id}>
|
||||||
|
<label>{cf.name}</label>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={customFieldValues[cf.id] ?? ''}
|
||||||
|
onChange={e => setCustomFieldValues(prev => ({ ...prev, [cf.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<label className={styles.fullRow} style={{ paddingTop: '0.75rem' }}>{t('Kommentar (wird an Redmine geschickt)')}</label>
|
||||||
|
<textarea
|
||||||
|
className={`${styles.textarea} ${styles.fullRow}`}
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder={t('Optional -- wird beim Speichern als Journal-Entry angelegt')}
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticket.relations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<label>{t('Beziehungen')}</label>
|
||||||
|
<ul className={styles.relationList}>
|
||||||
|
{ticket.relations.map(r => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.relationType}</strong>:{' '}
|
||||||
|
{r.issueId === ticket.id
|
||||||
|
? `#${r.issueId} -> #${r.issueToId}`
|
||||||
|
: `#${r.issueId} -> #${r.issueToId}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button className={styles.btnSecondary} onClick={_load} disabled={saving}>
|
||||||
|
{t('Zuruecksetzen')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.btn} onClick={_handleSave} disabled={saving}>
|
||||||
|
{saving ? t('Speichere ...') : t('Speichern')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A stable-ish color mapping for the most common tracker names. Unknown
|
||||||
|
// trackers fall back to a neutral blue.
|
||||||
|
export default RedmineTicketEditor;
|
||||||
|
|
@ -5,6 +5,17 @@
|
||||||
font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
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 {
|
.heading {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -178,3 +189,326 @@
|
||||||
border: 1px dashed var(--border-color, #e2e8f0);
|
border: 1px dashed var(--border-color, #e2e8f0);
|
||||||
border-radius: 8px;
|
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; }
|
||||||
|
|
|
||||||
144
src/pages/views/redmine/redmineTrackerColor.ts
Normal file
144
src/pages/views/redmine/redmineTrackerColor.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* Tracker color palette for the Redmine views.
|
||||||
|
*
|
||||||
|
* Single source of truth so the Browser-View, Editor and any future
|
||||||
|
* consumer agree on colors. Returns both background AND foreground so that
|
||||||
|
* white/light pills stay readable.
|
||||||
|
*
|
||||||
|
* Matching is substring-based on a lowercased tracker name. Order matters:
|
||||||
|
* the first rule that matches wins. Add new tracker names by extending the
|
||||||
|
* ``_RULES`` table -- never duplicate the matching logic in callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TrackerStyle {
|
||||||
|
/** Pill background color. */
|
||||||
|
bg: string;
|
||||||
|
/** Pill foreground (text) color, contrast-safe over ``bg``. */
|
||||||
|
fg: string;
|
||||||
|
/** Border color -- needed mainly for the white pill so it's visible. */
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _DARK_BLUE = '#2C5282'; // Userstory
|
||||||
|
const _LIGHT_BLUE = '#63B3ED'; // Feature
|
||||||
|
const _DARK_YELLOW = '#B7791F'; // Acc. Criteria
|
||||||
|
const _LIGHT_YELLOW = '#FAF089'; // Testcase
|
||||||
|
const _YELLOW = '#ECC94B'; // Change Request (mid yellow)
|
||||||
|
const _GRAY = '#A0AEC0'; // Support, Development, Planning, ...
|
||||||
|
|
||||||
|
const _DARK_TEXT = '#1A202C';
|
||||||
|
const _LIGHT_TEXT = '#FFFFFF';
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
/** Lowercase substrings; matches if ANY substring is in the tracker name. */
|
||||||
|
match: string[];
|
||||||
|
style: TrackerStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _RULES: Rule[] = [
|
||||||
|
// Userstory -- dark blue, white text.
|
||||||
|
{
|
||||||
|
match: ['userstory', 'user story', 'user-story'],
|
||||||
|
style: { bg: _DARK_BLUE, fg: _LIGHT_TEXT, border: _DARK_BLUE },
|
||||||
|
},
|
||||||
|
// Feature -- light blue, dark text (for contrast on the lighter shade).
|
||||||
|
{
|
||||||
|
match: ['feature'],
|
||||||
|
style: { bg: _LIGHT_BLUE, fg: _DARK_TEXT, border: _LIGHT_BLUE },
|
||||||
|
},
|
||||||
|
// Acc. Criteria -- mid yellow, dark text.
|
||||||
|
{
|
||||||
|
match: ['acc.', 'acceptance', 'akzeptanz', 'krit'],
|
||||||
|
style: { bg: _YELLOW, fg: _DARK_TEXT, border: _YELLOW },
|
||||||
|
},
|
||||||
|
// Testcase -- light yellow, dark text.
|
||||||
|
{
|
||||||
|
match: ['testcase', 'test case', 'test-case'],
|
||||||
|
style: { bg: _LIGHT_YELLOW, fg: _DARK_TEXT, border: '#ECC94B' },
|
||||||
|
},
|
||||||
|
// Change Request -- dark yellow (amber), white text for contrast.
|
||||||
|
{
|
||||||
|
match: ['change request', 'change-request', 'changerequest'],
|
||||||
|
style: { bg: _DARK_YELLOW, fg: _LIGHT_TEXT, border: _DARK_YELLOW },
|
||||||
|
},
|
||||||
|
// Gray bucket: explicit list of "auxiliary" trackers.
|
||||||
|
{
|
||||||
|
match: [
|
||||||
|
'support',
|
||||||
|
'development',
|
||||||
|
'planning',
|
||||||
|
'aut bug', // "Automatic Bug" / similar -- explicit user request
|
||||||
|
'notes',
|
||||||
|
'interface',
|
||||||
|
'sales consulting',
|
||||||
|
],
|
||||||
|
style: { bg: _GRAY, fg: _LIGHT_TEXT, border: _GRAY },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const _DEFAULT_STYLE: TrackerStyle = { bg: _GRAY, fg: _LIGHT_TEXT, border: _GRAY };
|
||||||
|
|
||||||
|
// Memo: tracker names are a tiny, fixed vocabulary; computing the substring
|
||||||
|
// match for every chip / pill / row on every render adds up. We cache by
|
||||||
|
// lowercased key so the rule-walk runs once per tracker name per session.
|
||||||
|
const _styleCache = new Map<string, TrackerStyle>();
|
||||||
|
|
||||||
|
export const getTrackerStyle = (name: string | null | undefined): TrackerStyle => {
|
||||||
|
const key = (name || '').toLowerCase();
|
||||||
|
if (!key) return _DEFAULT_STYLE;
|
||||||
|
const cached = _styleCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
let resolved: TrackerStyle = _DEFAULT_STYLE;
|
||||||
|
for (const rule of _RULES) {
|
||||||
|
if (rule.match.some(m => key.includes(m))) {
|
||||||
|
resolved = rule.style;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_styleCache.set(key, resolved);
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Convenience: return only the background color (most common usage). */
|
||||||
|
export const getTrackerBg = (name: string | null | undefined): string => getTrackerStyle(name).bg;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display order
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The UI shows tracker filter chips in a fixed conceptual order so the team
|
||||||
|
// always sees the hierarchy at a glance:
|
||||||
|
// Userstory > Feature > Acc. Criteria > Testcase > Change Request >
|
||||||
|
// everything else (A-Z).
|
||||||
|
// Trackers that don't match a known prefix fall to the end and are sorted
|
||||||
|
// alphabetically among themselves.
|
||||||
|
|
||||||
|
const _ORDER_RULES: Array<{ rank: number; match: string[] }> = [
|
||||||
|
{ rank: 0, match: ['userstory', 'user story', 'user-story'] },
|
||||||
|
{ rank: 1, match: ['feature'] },
|
||||||
|
{ rank: 2, match: ['acc.', 'acceptance', 'akzeptanz', 'krit'] },
|
||||||
|
{ rank: 3, match: ['testcase', 'test case', 'test-case'] },
|
||||||
|
{ rank: 4, match: ['change request', 'change-request', 'changerequest'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Sort rank for a tracker name. Lower number = appears earlier. Unknown
|
||||||
|
* trackers get a high rank and are sorted alphabetically among themselves. */
|
||||||
|
export const getTrackerSortRank = (name: string | null | undefined): number => {
|
||||||
|
const key = (name || '').toLowerCase();
|
||||||
|
if (!key) return 1000;
|
||||||
|
for (const r of _ORDER_RULES) {
|
||||||
|
if (r.match.some(m => key.includes(m))) return r.rank;
|
||||||
|
}
|
||||||
|
return 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stable sort of any object array by its tracker-name accessor, applying
|
||||||
|
* ``getTrackerSortRank`` first and then alphabetical tie-break. */
|
||||||
|
export const sortByTrackerOrder = <T>(items: T[], nameOf: (item: T) => string): T[] => {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const ra = getTrackerSortRank(nameOf(a));
|
||||||
|
const rb = getTrackerSortRank(nameOf(b));
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return nameOf(a).localeCompare(nameOf(b));
|
||||||
|
});
|
||||||
|
};
|
||||||
262
src/pages/views/redmine/redmineTreeLogic.ts
Normal file
262
src/pages/views/redmine/redmineTreeLogic.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
/**
|
||||||
|
* Pure tree-building helpers for the Redmine Browser view.
|
||||||
|
*
|
||||||
|
* Algorithmic contract (kept deliberately simple so the UI stays predictable):
|
||||||
|
*
|
||||||
|
* 1. Tickets that are NOT in the input ``tickets`` list (i.e. filtered out
|
||||||
|
* by the caller) take no part in the relation graph -- they vanish
|
||||||
|
* completely. No edges, no adjacency entries.
|
||||||
|
*
|
||||||
|
* 2. Roots = tickets whose tracker matches ``rootTrackerId`` (typically
|
||||||
|
* "Userstory"). Roots are seeded into the BFS queue in ascending id
|
||||||
|
* order so output is stable across re-renders.
|
||||||
|
*
|
||||||
|
* 3. Construction is a **multi-source breadth-first search**: ALL roots
|
||||||
|
* are seeded into the queue together. The algorithm then walks
|
||||||
|
* strictly level-by-level across the entire forest:
|
||||||
|
*
|
||||||
|
* Level 0: every User Story
|
||||||
|
* Level 1: every ticket directly related to ANY User Story
|
||||||
|
* Level 2: every ticket related to a level-1 ticket, ...
|
||||||
|
*
|
||||||
|
* Consequence: a ticket reachable from two roots ends up under the
|
||||||
|
* one that finds it FIRST at the shallowest distance. A Feature
|
||||||
|
* directly related to a User Story is therefore guaranteed to sit
|
||||||
|
* at depth 1 under that US -- it can never end up nested deeper
|
||||||
|
* under an Acceptance Criteria just because another root reached
|
||||||
|
* the AC earlier.
|
||||||
|
*
|
||||||
|
* 4. Each ticket appears AT MOST ONCE in the entire forest (global
|
||||||
|
* ``visited`` set). Once placed, neither the same nor a different
|
||||||
|
* relation can pull it elsewhere. Cycles are therefore impossible.
|
||||||
|
*
|
||||||
|
* 5. Tickets that no User Story ever reaches end up under the synthetic
|
||||||
|
* ``ORPHAN_ROOT_ID``, built with the same multi-source BFS but
|
||||||
|
* seeded from the orphan candidates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RedmineTicket } from '../../../api/redmineApi';
|
||||||
|
|
||||||
|
const _MAX_NODES = 20000; // hard safety cap across the whole forest
|
||||||
|
|
||||||
|
export const ORPHAN_ROOT_ID = -1;
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
/** Redmine issue id, or ``ORPHAN_ROOT_ID`` for the virtual orphan root. */
|
||||||
|
id: number;
|
||||||
|
/** Relation type connecting this node to its parent (``null`` for roots). */
|
||||||
|
relType: string | null;
|
||||||
|
/** ``out`` = this ticket is the target of the parent's outgoing edge,
|
||||||
|
* ``in`` = this ticket is the source (the edge points back at the parent). */
|
||||||
|
dir: 'out' | 'in' | null;
|
||||||
|
children: TreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Edge {
|
||||||
|
fromId: number;
|
||||||
|
toId: number;
|
||||||
|
relType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Neighbor {
|
||||||
|
id: number;
|
||||||
|
relType: string;
|
||||||
|
dir: 'out' | 'in';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edge + adjacency construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _relationsToEdges = (tickets: RedmineTicket[]): Edge[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
for (const t of tickets) {
|
||||||
|
if (t.parentId != null) {
|
||||||
|
const key = `p:${t.parentId}:${t.id}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
edges.push({ fromId: t.parentId, toId: t.id, relType: 'parent' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of t.relations || []) {
|
||||||
|
const key = `r:${r.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
edges.push({ fromId: r.issueId, toId: r.issueToId, relType: r.relationType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _buildAdjacency = (
|
||||||
|
edges: Edge[],
|
||||||
|
visibleIds: Set<number>,
|
||||||
|
allowedRelTypes: Set<string> | null,
|
||||||
|
): Map<number, Neighbor[]> => {
|
||||||
|
const map = new Map<number, Neighbor[]>();
|
||||||
|
const _add = (k: number, n: Neighbor) => {
|
||||||
|
const arr = map.get(k);
|
||||||
|
if (arr) arr.push(n); else map.set(k, [n]);
|
||||||
|
};
|
||||||
|
for (const e of edges) {
|
||||||
|
// Filter rule #1: a filtered-out ticket on EITHER side drops the edge.
|
||||||
|
if (!visibleIds.has(e.fromId)) continue;
|
||||||
|
if (!visibleIds.has(e.toId)) continue;
|
||||||
|
if (allowedRelTypes && !allowedRelTypes.has(e.relType)) continue;
|
||||||
|
_add(e.fromId, { id: e.toId, relType: e.relType, dir: 'out' });
|
||||||
|
_add(e.toId, { id: e.fromId, relType: e.relType, dir: 'in' });
|
||||||
|
}
|
||||||
|
// Stable neighbour order so re-renders produce the same tree.
|
||||||
|
for (const [k, arr] of map) {
|
||||||
|
arr.sort((a, b) => a.id - b.id);
|
||||||
|
map.set(k, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-source BFS over a set of seed roots, sharing one ``visited`` set.
|
||||||
|
// Returns the seed roots in their input order, with their full BFS subtrees
|
||||||
|
// attached. The BFS interleaves roots level by level, so a ticket reachable
|
||||||
|
// from multiple roots is attached to the first root that reaches it at the
|
||||||
|
// shallowest distance.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _bfsForest = (
|
||||||
|
seedIds: number[],
|
||||||
|
adjacency: Map<number, Neighbor[]>,
|
||||||
|
visited: Set<number>,
|
||||||
|
): TreeNode[] => {
|
||||||
|
const roots: TreeNode[] = [];
|
||||||
|
const queue: TreeNode[] = [];
|
||||||
|
// Seed every root first -- THIS is what makes it multi-source.
|
||||||
|
for (const id of seedIds) {
|
||||||
|
if (visited.has(id)) continue; // a previous seed already absorbed it
|
||||||
|
visited.add(id);
|
||||||
|
const node: TreeNode = { id, relType: null, dir: null, children: [] };
|
||||||
|
roots.push(node);
|
||||||
|
queue.push(node);
|
||||||
|
}
|
||||||
|
// Standard FIFO BFS. Because roots were enqueued in order, the first
|
||||||
|
// root that can claim a neighbour at level 1 wins it.
|
||||||
|
while (queue.length > 0) {
|
||||||
|
if (visited.size >= _MAX_NODES) break;
|
||||||
|
const node = queue.shift()!;
|
||||||
|
const neighbors = adjacency.get(node.id) || [];
|
||||||
|
for (const n of neighbors) {
|
||||||
|
if (visited.has(n.id)) continue; // already placed elsewhere -> skip
|
||||||
|
visited.add(n.id);
|
||||||
|
const child: TreeNode = { id: n.id, relType: n.relType, dir: n.dir, children: [] };
|
||||||
|
node.children.push(child);
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BuildForestOptions {
|
||||||
|
rootTrackerId: number | null;
|
||||||
|
/** Only edges whose ``relType`` is in this list contribute to the tree.
|
||||||
|
* ``undefined`` / empty array means "all relation types allowed". */
|
||||||
|
allowedRelTypes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Forest {
|
||||||
|
trees: TreeNode[];
|
||||||
|
reachableCount: number;
|
||||||
|
orphanCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildForest = (
|
||||||
|
tickets: RedmineTicket[],
|
||||||
|
opts: BuildForestOptions,
|
||||||
|
): Forest => {
|
||||||
|
const { rootTrackerId } = opts;
|
||||||
|
const allowedRelTypes = opts.allowedRelTypes && opts.allowedRelTypes.length > 0
|
||||||
|
? new Set(opts.allowedRelTypes)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const visibleIds = new Set(tickets.map(t => t.id));
|
||||||
|
const edges = _relationsToEdges(tickets);
|
||||||
|
const adjacency = _buildAdjacency(edges, visibleIds, allowedRelTypes);
|
||||||
|
|
||||||
|
// Root seeds in stable order (by id ASC). Multi-source BFS will then
|
||||||
|
// interleave their expansion level by level.
|
||||||
|
const rootSeeds = (rootTrackerId != null
|
||||||
|
? tickets.filter(t => t.trackerId === rootTrackerId)
|
||||||
|
: []
|
||||||
|
).slice().sort((a, b) => a.id - b.id).map(t => t.id);
|
||||||
|
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const trees: TreeNode[] = _bfsForest(rootSeeds, adjacency, visited);
|
||||||
|
const reachableCount = visited.size;
|
||||||
|
|
||||||
|
// Orphans = tickets the root BFS never reached. They form their own
|
||||||
|
// multi-source BFS forest under the synthetic Orphan node.
|
||||||
|
const orphanSeeds = tickets
|
||||||
|
.filter(t => !visited.has(t.id))
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map(t => t.id);
|
||||||
|
const orphanCount = orphanSeeds.length;
|
||||||
|
const orphanChildren = _bfsForest(orphanSeeds, adjacency, visited);
|
||||||
|
|
||||||
|
if (orphanChildren.length > 0) {
|
||||||
|
trees.push({
|
||||||
|
id: ORPHAN_ROOT_ID,
|
||||||
|
relType: null,
|
||||||
|
dir: null,
|
||||||
|
children: orphanChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trees, reachableCount, orphanCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flatten + expand-all helpers (unchanged contract)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FlatRow {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
/** Per-ancestor: ``true`` if that ancestor has a following sibling. */
|
||||||
|
indentLines: boolean[];
|
||||||
|
isLast: boolean;
|
||||||
|
hasChildren: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flattenForest = (
|
||||||
|
trees: TreeNode[],
|
||||||
|
expanded: Set<number>,
|
||||||
|
): FlatRow[] => {
|
||||||
|
const rows: FlatRow[] = [];
|
||||||
|
const _walk = (node: TreeNode, depth: number, lines: boolean[], isLast: boolean) => {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
rows.push({ node, depth, indentLines: lines, isLast, hasChildren });
|
||||||
|
if (!hasChildren) return;
|
||||||
|
if (!expanded.has(node.id)) return;
|
||||||
|
node.children.forEach((child, idx) => {
|
||||||
|
const childIsLast = idx === node.children.length - 1;
|
||||||
|
_walk(child, depth + 1, [...lines, !isLast], childIsLast);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
trees.forEach((root, idx) => {
|
||||||
|
_walk(root, 0, [], idx === trees.length - 1);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectAllIds = (trees: TreeNode[]): number[] => {
|
||||||
|
const acc: number[] = [];
|
||||||
|
const _w = (n: TreeNode) => {
|
||||||
|
if (n.children.length > 0) acc.push(n.id);
|
||||||
|
n.children.forEach(_w);
|
||||||
|
};
|
||||||
|
trees.forEach(_w);
|
||||||
|
return acc;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue