diff --git a/src/App.tsx b/src/App.tsx index a677a0d..aac8210 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -186,6 +186,10 @@ function App() { } /> } /> + {/* Redmine Feature Views */} + } /> + } /> + {/* Catch-all für unbekannte Sub-Pfade */} } /> diff --git a/src/api/redmineApi.ts b/src/api/redmineApi.ts index c89664a..39bc545 100644 --- a/src/api/redmineApi.ts +++ b/src/api/redmineApi.ts @@ -63,6 +63,7 @@ export interface RedmineFieldSchema { statuses: RedmineFieldChoice[]; priorities: RedmineFieldChoice[]; users: RedmineFieldChoice[]; + categories: RedmineFieldChoice[]; customFields: RedmineCustomFieldSchema[]; rootTrackerName: string; rootTrackerId: number | null; @@ -100,6 +101,8 @@ export interface RedmineTicket { parentId?: number | null; fixedVersionId?: number | null; fixedVersionName?: string | null; + categoryId?: number | null; + categoryName?: string | null; createdOn?: string | null; updatedOn?: string | null; customFields: RedmineCustomFieldValue[]; @@ -143,6 +146,8 @@ export interface RedmineStats { dateTo?: string | null; bucket: string; trackerIds: number[]; + categoryIds: number[]; + statusFilter: string; kpis: { total: number; open: number; @@ -162,6 +167,8 @@ export interface RedmineStats { label: string; created: number; closed: number; + cumTotal: number; + cumOpen: number; }>; topAssignees: Array<{ assignedToId?: number | null; @@ -367,6 +374,8 @@ export interface RedmineStatsParams { dateTo?: string; bucket?: 'day' | 'week' | 'month'; trackerIds?: number[]; + categoryIds?: number[]; + statusFilter?: '*' | 'open' | 'closed'; } export async function getRedmineStatsApi( @@ -379,6 +388,8 @@ export async function getRedmineStatsApi( if (params.dateTo) queryParams.dateTo = params.dateTo; if (params.bucket) queryParams.bucket = params.bucket; if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds; + if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds; + if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter; return await request({ url: `${_baseUrl(instanceId)}/stats`, method: 'get', diff --git a/src/pages/views/redmine/RedmineBrowserView.tsx b/src/pages/views/redmine/RedmineBrowserView.tsx index f85a836..de00d61 100644 --- a/src/pages/views/redmine/RedmineBrowserView.tsx +++ b/src/pages/views/redmine/RedmineBrowserView.tsx @@ -1,101 +1,730 @@ /** - * Redmine Ticket Browser (Phase 2 placeholder). + * Redmine Ticket Browser * - * Will render the tree-as-table layout from the HTML pilot, with - * filters and a right-side editor pane. For now: simple flat list from - * the local mirror so the wiring can be verified. + * Split view: tree-as-table on the left (roots = configured root + * tracker + virtual "Orphan" root), editor pane on the right. All reads + * hit the local mirror; saves go through ``updateRedmineTicketApi`` + * which updates Redmine and then refreshes the mirror. + * + * Filters are applied client-side because the mirror already fits in + * memory (2-20k tickets is fine for a sub-200ms filter pass). */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { + RedmineConfigDto, + RedmineFieldSchema, RedmineTicket, + getRedmineConfigApi, + getRedmineSchemaApi, listRedmineTicketsApi, } from '../../../api/redmineApi'; +import { PeriodPicker, PeriodValue } from '../../../components/PeriodPicker'; + +import { + Forest, + FlatRow, + ORPHAN_ROOT_ID, + TreeNode, + buildForest, + collectAllIds, + flattenForest, +} from './redmineTreeLogic'; +import { getTrackerStyle, sortByTrackerOrder } from './redmineTrackerColor'; +import RedmineTicketEditor from './RedmineTicketEditor'; import styles from './RedmineViews.module.css'; +// ============================================================================ +// Relation type options -- Redmine's fixed vocabulary plus our synthetic +// "parent" edge (inherited from ``parent_id``). +// ============================================================================ +const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'parent', label: 'parent_id' }, + { value: 'relates', label: 'relates' }, + { value: 'duplicates', label: 'duplicates' }, + { value: 'duplicated', label: 'duplicated' }, + { value: 'blocks', label: 'blocks' }, + { value: 'blocked', label: 'blocked' }, + { value: 'precedes', label: 'precedes' }, + { value: 'follows', label: 'follows' }, + { value: 'copied_to', label: 'copied_to' }, + { value: 'copied_from', label: 'copied_from' }, +]; + +// ============================================================================ +// Closed-state lookup -- the mirror's ``isClosed`` field can be stale or +// missing when the schema cache wasn't yet hydrated at sync time. We trust +// the live schema (``schema.statuses[*].isClosed``) as the source of truth +// and fall back to the ticket's own flag. +// ============================================================================ +const _isTicketClosed = ( + ticket: RedmineTicket, + schemaStatusClosedById: Map, +): boolean => { + if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) { + return schemaStatusClosedById.get(ticket.statusId) === true; + } + return !!ticket.isClosed; +}; + export const RedmineBrowserView: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const instanceId = useInstanceId(); + const [config, setConfig] = useState(null); + const [schema, setSchema] = useState(null); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const _load = useCallback(async () => { + // Filters + const [period, setPeriod] = useState(null); + // Tracker filter is *subtractive*: the set holds tracker ids that are + // currently SHOWN. ``null`` means "uninitialised" -- once the schema is + // loaded we seed the set with all tracker ids so every chip starts active. + // Clicking a chip removes it from the set -> tickets of that tracker + // disappear from the list. + const [selectedTrackerIds, setSelectedTrackerIds] = useState | null>(null); + const [selectedAssigneeIds, setSelectedAssigneeIds] = useState>(new Set()); + const [selectedRelTypes, setSelectedRelTypes] = useState>( + new Set(RELATION_TYPE_OPTIONS.map(r => r.value)), + ); + const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); + // Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic + // value ``__none__`` matches tickets with no sprint assigned. + const [selectedSprints, setSelectedSprints] = useState>(new Set()); + + // UI state + const [expanded, setExpanded] = useState>(new Set()); + const [selectedId, setSelectedId] = useState(null); + + const rootTrackerId = schema?.rootTrackerId ?? null; + + // Seed the tracker filter once the schema is available: every tracker + // starts SELECTED so the user sees everything by default; clicking a chip + // removes that tracker from the visible set. + useEffect(() => { + if (selectedTrackerIds == null && schema) { + setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id))); + } + }, [schema, selectedTrackerIds]); + + // Map statusId -> isClosed, taken from the live schema. Used by the status + // filter so it works even if the mirror's per-ticket ``isClosed`` is stale. + const schemaStatusClosedById = useMemo(() => { + const m = new Map(); + if (schema) { + for (const s of schema.statuses) { + if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed); + } + } + return m; + }, [schema]); + + // Distinct sprints (fixed_version) seen across all loaded tickets. Drives + // the sprint filter dropdown. Sorted alphabetically for stable UI. + const sprintOptions = useMemo(() => { + const m = new Map(); + let hasNone = false; + for (const tk of tickets) { + const name = tk.fixedVersionName?.trim(); + if (name) { + m.set(name, name); + } else { + hasNone = true; + } + } + const opts = Array.from(m.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') }); + return opts; + }, [tickets, t]); + + // Load config + schema once. + const _loadMeta = useCallback(async () => { + if (!instanceId) return; + try { + const [c, s] = await Promise.all([ + getRedmineConfigApi(request, instanceId), + getRedmineSchemaApi(request, instanceId), + ]); + setConfig(c); + setSchema(s); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen')); + } + }, [request, instanceId, t]); + + // Load tickets from mirror whenever the period window changes (backend can + // pre-filter by updatedOn to shrink the payload). + const _loadTickets = useCallback(async () => { if (!instanceId) return; setLoading(true); setError(null); try { - const result = await listRedmineTicketsApi(request, instanceId, { status: '*' }); + const result = await listRedmineTicketsApi(request, instanceId, { + status: '*', + dateFrom: period?.fromDate, + dateTo: period?.toDate, + }); setTickets(result); } catch (e: any) { - setError(e?.message || t('Fehler beim Laden')); + setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen')); + setTickets([]); } finally { setLoading(false); } - }, [request, instanceId, t]); + }, [request, instanceId, period, t]); - useEffect(() => { _load(); }, [_load]); + useEffect(() => { _loadMeta(); }, [_loadMeta]); + useEffect(() => { _loadTickets(); }, [_loadTickets]); - if (loading) return
{t('Tickets werden geladen ...')}
; + // Client-side filter pass (tracker / assignee / status). Root-tracker + // tickets are always kept so the tree has roots even if their own tracker + // is deselected -- otherwise the whole forest collapses. + // ``deferredFilters`` lets React keep the filter chips snappy while the + // potentially expensive tree rebuild happens in the background. The + // chips update immediately (urgent state) but the tree picks up the new + // values one tick later, which removes the "click feels frozen" lag. + const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds); + const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds); + const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes); + const deferredStatusFilter = useDeferredValue(statusFilter); + const deferredSelectedSprints = useDeferredValue(selectedSprints); + + const filteredTickets = useMemo(() => { + const trackerSet = deferredSelectedTrackerIds; + const assigneeSet = deferredSelectedAssigneeIds; + const sprintSet = deferredSelectedSprints; + const status = deferredStatusFilter; + return tickets.filter(ticket => { + const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId; + if (!isRoot && trackerSet != null && ticket.trackerId != null) { + if (!trackerSet.has(ticket.trackerId)) return false; + } + if (assigneeSet.size > 0) { + if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false; + } + if (status !== '*') { + const closed = _isTicketClosed(ticket, schemaStatusClosedById); + if (status === 'open' && closed) return false; + if (status === 'closed' && !closed) return false; + } + if (sprintSet.size > 0) { + const sprintKey = ticket.fixedVersionName?.trim() || '__none__'; + if (!sprintSet.has(sprintKey)) return false; + } + return true; + }); + }, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]); + + // Convert the rel-type set to an array once per change instead of on every + // ``buildForest`` call (Array.from() in the deps would re-run the memo + // every render because the array identity is fresh each time). + const allowedRelTypesArr = useMemo( + () => Array.from(deferredSelectedRelTypes), + [deferredSelectedRelTypes], + ); + + const forest: Forest = useMemo(() => { + return buildForest(filteredTickets, { + rootTrackerId, + allowedRelTypes: allowedRelTypesArr, + }); + }, [filteredTickets, rootTrackerId, allowedRelTypesArr]); + + const flatRows: FlatRow[] = useMemo( + () => flattenForest(forest.trees, expanded), + [forest.trees, expanded], + ); + + const ticketsById = useMemo(() => { + const m = new Map(); + for (const ticket of tickets) m.set(ticket.id, ticket); + return m; + }, [tickets]); + + // One-shot initial expansion: the very first time we render a non-empty + // forest, expand the root nodes so the user sees the overview. After that + // the user owns the expand state -- collapsing all must STAY collapsed. + const _didInitExpand = useRef(false); + useEffect(() => { + if (!_didInitExpand.current && forest.trees.length > 0) { + _didInitExpand.current = true; + setExpanded(new Set(forest.trees.map(tr => tr.id))); + } + }, [forest.trees]); + + const _toggleExpand = useCallback((id: number) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _expandAll = useCallback(() => { + setExpanded(new Set(collectAllIds(forest.trees))); + }, [forest.trees]); + + const _collapseAll = useCallback(() => { + setExpanded(new Set()); + }, []); + + const _resetFilters = useCallback(() => { + setPeriod(null); + // "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set. + setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null); + setSelectedAssigneeIds(new Set()); + setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value))); + setStatusFilter('*'); + setSelectedSprints(new Set()); + }, [schema]); + + const _toggleSprint = useCallback((value: string) => { + setSelectedSprints(prev => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); else next.add(value); + return next; + }); + }, []); + + const _toggleTracker = useCallback((id: number) => { + setSelectedTrackerIds(prev => { + // ``prev`` is null only before schema loads -- the chip wouldn't be + // clickable in that state, but stay defensive. + const next = new Set(prev ?? []); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _toggleAssignee = useCallback((id: number) => { + setSelectedAssigneeIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _toggleRelType = useCallback((rt: string) => { + setSelectedRelTypes(prev => { + const next = new Set(prev); + if (next.has(rt)) next.delete(rt); else next.add(rt); + return next; + }); + }, []); + + const _handleTicketSaved = useCallback((updated: RedmineTicket) => { + setTickets(prev => prev.map(x => (x.id === updated.id ? updated : x))); + }, []); + + if (!instanceId) { + return
{t('Keine Feature-Instanz ausgewaehlt')}
; + } return ( -
-

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

-

- {t('Liest aus dem lokalen Mirror. Tree-Layout und Editor-Pane folgen im naechsten Schritt.')} -

+
+
+
+

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

+

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

+
+
+ {t('{count} von {total} Tickets sichtbar', { + count: filteredTickets.length, + total: tickets.length, + })} +
+
{error &&
{error}
} - {tickets.length === 0 ? ( -
- {t('Keine Tickets im Mirror. Bitte zuerst in den Einstellungen "Sync starten".')} +
+
+ + setPeriod(next)} + direction="past" + defaultPreset={{ kind: 'lastQuarter' }} + enabledPresets={[ + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', + ]} + placeholder={t('Alle Zeiten')} + />
- ) : ( -
-

- {tickets.length} {t('Tickets')} -

- - - - - - - - - - - - - {tickets.slice(0, 200).map(ticket => ( - - - - - - - - + +
+ + +
+ + {schema && schema.trackers.length > 0 && ( +
+ +
+ {sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => { + const isRoot = tr.id === rootTrackerId; + // Active iff in the visible set (or root). selectedTrackerIds + // is null only during the brief window before the schema seed + // effect runs -- treat as "all visible". + const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id); + const sty = getTrackerStyle(tr.name); + return ( + + ); + })} +
+
+ )} + + {schema && schema.users.length > 0 && ( +
+ +
-
ID{t('Tracker')}{t('Titel')}Status{t('Zuweisung')}{t('Geaendert')}
#{ticket.id}{ticket.trackerName || '-'}{ticket.subject}{ticket.statusName || '-'}{ticket.assignedToName || '-'}{ticket.updatedOn?.slice(0, 10) || '-'}
- {tickets.length > 200 && ( -

- {t('(Anzeige auf 200 begrenzt -- Tree-Layout folgt.)')} -

+ +
+ )} + + {sprintOptions.length > 0 && ( +
+ +
+ {sprintOptions.map(sp => { + const active = selectedSprints.has(sp.value); + return ( + + ); + })} +
+ {selectedSprints.size === 0 && ( + + {t('keine Auswahl = alle Sprints')} + + )} +
+ )} + +
+ +
+ {RELATION_TYPE_OPTIONS.map(rt => { + const active = selectedRelTypes.has(rt.value); + return ( + + ); + })} +
+
+ +
+ + +
+ +
+ {/* Left: tree */} +
+
+ + {t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', { + rows: flatRows.length, + orphans: forest.orphanCount, + })} + +
+ + +
+
+
+ {loading ? ( +
{t('Baum wird aufgebaut ...')}
+ ) : flatRows.length === 0 ? ( +
+ {t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')} +
+ ) : ( +
+
+
{t('Ticket')}
+
Status
+
{t('Prio')}
+
{t('Zuweisung')}
+
{t('Geaendert')}
+
{t('Beziehung')}
+
+ {flatRows.map(row => ( + + ))} +
+ )} +
+
+ + {/* Right: editor */} +
+ {selectedId == null ? ( +
{t('Ticket links auswaehlen')}
+ ) : selectedId === ORPHAN_ROOT_ID ? ( +
+ {t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', { + count: forest.orphanCount, + })} +
+ ) : ( + )}
- )} +
); }; +// ============================================================================ +// TreeRow -- a single grid row, handling its own indent painting. +// ============================================================================ + +interface TreeRowProps { + row: FlatRow; + ticket: RedmineTicket | null; + selected: boolean; + expanded: boolean; + onToggle: (id: number) => void; + onSelect: (id: number) => void; + rootTrackerId: number | null; + schemaStatusClosedById: Map; +} + +const _TreeRowImpl: React.FC = ({ + row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById, +}) => { + const { node, depth, indentLines, hasChildren } = row; + const isOrphanRoot = node.id === ORPHAN_ROOT_ID; + const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId; + const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false; + + const rowClass = [ + styles.treeRow, + selected ? styles.selected : '', + isOrphanRoot ? styles.orphan : '', + ].filter(Boolean).join(' '); + + const _handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggle(node.id); + }; + + const _handleSelect = () => onSelect(node.id); + + return ( +
+
+ {Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => ( + + ))} + {depth > 0 && ( + + + + )} + {hasChildren ? ( + + ) : ( + + )} + { + const sty = getTrackerStyle(ticket.trackerName); + return { + background: sty.bg, + color: sty.fg, + border: `1px solid ${sty.border}`, + }; + })() + : undefined + } + > + {isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')} + + + {isOrphanRoot ? '—' : `#${ticket?.id}`} + + + {isOrphanRoot + ? `Tickets ohne Verbindung zu einer User Story (${node.children.length})` + : (ticket?.subject || '(ohne Titel)')} + +
+
+ {isOrphanRoot ? + : ticket?.statusName + ? {ticket.statusName} + : } +
+
{isOrphanRoot ? : (ticket?.priorityName || '—')}
+
{isOrphanRoot ? : (ticket?.assignedToName || )}
+
+ {isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')} +
+
+ {isOrphanRoot + ? virtuell + : isRootTracker && depth === 0 + ? Root + : node.relType + ? {node.dir === 'in' ? '←' : '→'} {node.relType} + : } +
+
+ ); +}; + +// Custom equality: skip re-rendering rows whose visible inputs are identical. +// ``row`` and ``ticket`` references are stable across re-renders unless the +// underlying tree was rebuilt or the ticket itself changed -- much cheaper +// than re-painting 2k DOM nodes on every filter chip click. +const TreeRow = React.memo(_TreeRowImpl, (prev, next) => { + return ( + prev.row === next.row + && prev.ticket === next.ticket + && prev.selected === next.selected + && prev.expanded === next.expanded + && prev.onToggle === next.onToggle + && prev.onSelect === next.onSelect + && prev.rootTrackerId === next.rootTrackerId + && prev.schemaStatusClosedById === next.schemaStatusClosedById + ); +}); + export default RedmineBrowserView; diff --git a/src/pages/views/redmine/RedmineStatsView.tsx b/src/pages/views/redmine/RedmineStatsView.tsx index 1f423ce..e8cbfb7 100644 --- a/src/pages/views/redmine/RedmineStatsView.tsx +++ b/src/pages/views/redmine/RedmineStatsView.tsx @@ -1,91 +1,393 @@ /** - * Redmine Statistics View (Phase 2 placeholder). + * Redmine Statistics View * - * Will render a ``FormGeneratorReport`` driven by ``getRedmineStatsApi`` - * with a ``PeriodPicker`` and tracker-filter. For now: shows the raw - * KPIs so the wiring can be verified against the local mirror. + * Default landing view for a Redmine feature instance. Reads aggregated + * stats from the local mirror (fast, even at 20k+ tickets) and renders + * KPIs + charts via ``FormGeneratorReport``. The built-in + * ``dateRangeSelector`` mounts the shared ``PeriodPicker`` -- no extra + * wiring needed. + * + * Buckets returned by the backend are mapped to ``ReportSection``s here + * (frontend does the UI shape; backend stays storage-oriented). */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useLanguage } from '../../../providers/language/LanguageContext'; -import { 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'; +// ============================================================================ +// Helpers -- map raw backend buckets to ReportSection[] +// ============================================================================ + +// Format counts as integers ("Einheiten") -- prevents the chart components +// from falling back to their default currency formatter (CHF). +const _fmtUnits = (v: number): string => { + if (!Number.isFinite(v)) return '0'; + return Math.round(v).toLocaleString('de-CH'); +}; + +const _buildSections = ( + stats: RedmineStats, + t: (key: string, vars?: Record) => string, +): ReportSection[] => { + const sections: ReportSection[] = []; + + // ---- KPI tiles -------------------------------------------------------- + sections.push({ + type: 'kpiGrid', + span: 'full', + items: [ + { label: t('Tickets gesamt'), value: stats.kpis.total }, + { label: t('Offen'), value: stats.kpis.open, color: '#4A6FA5' }, + { label: t('Geschlossen'), value: stats.kpis.closed, color: '#38A169' }, + { label: t('Im Zeitraum erstellt'), value: stats.kpis.createdInPeriod }, + { label: t('Im Zeitraum geschlossen'), value: stats.kpis.closedInPeriod }, + { + label: t('Ohne Userstory (Orphans)'), + value: stats.kpis.orphans, + color: stats.kpis.orphans > 0 ? '#C53030' : undefined, + }, + ], + }); + + // ---- Snapshot chart: total tickets vs. open per bucket end ---------- + // ``cumTotal`` and ``cumOpen`` are computed server-side and are SNAPSHOT + // values (state at the end of each bucket), not flow numbers. The + // difference between the two lines is the cumulative number of closed + // tickets up to that point in time. + if (stats.throughput.length > 0) { + const snapshotData = stats.throughput.map(b => ({ + date: b.label, + total: b.cumTotal, + open: b.cumOpen, + })); + sections.push({ + type: 'lineChart', + span: 'full', + title: t('Bestand pro {bucket}: Total vs. Offen', { bucket: stats.bucket }), + description: t('Snapshot am Ende jeder Periode: wie viele Tickets es zu diesem Zeitpunkt gibt (Total) und wie viele davon noch offen sind. Die Luecke zwischen den Linien sind die bis dahin geschlossenen Tickets.'), + data: snapshotData, + series: [ + { key: 'total', label: t('Total'), color: '#4A6FA5' }, + { key: 'open', label: t('Offen'), color: '#DD6B20' }, + ], + formatValue: _fmtUnits, + }); + } + + // ---- Status per tracker (stacked-like via horizontal bar per tracker) + if (stats.statusByTracker.length > 0) { + const statusKeys = new Set(); + stats.statusByTracker.forEach(row => { + Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k)); + }); + const totals: Record = {}; + stats.statusByTracker.forEach(row => { + Object.entries(row.countsByStatus).forEach(([s, n]) => { + totals[s] = (totals[s] || 0) + n; + }); + }); + sections.push({ + type: 'pieChart', + span: 'half', + title: t('Status-Verteilung (gesamt)'), + donut: true, + data: Object.entries(totals) + .sort(([, a], [, b]) => b - a) + .map(([status, count]) => ({ key: status, value: count })), + formatValue: _fmtUnits, + }); + + sections.push({ + type: 'horizontalBar', + span: 'half', + title: t('Tickets pro Tracker'), + data: stats.statusByTracker + .slice() + .sort((a, b) => b.total - a.total) + .map(row => ({ + key: row.trackerName, + value: row.total, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Top assignees --------------------------------------------------- + if (stats.topAssignees.length > 0) { + sections.push({ + type: 'horizontalBar', + span: 'half', + title: t('Top 10 Zugewiesene (offene Tickets)'), + description: t('Offene Tickets nach zugewiesener Person -- zeigt Auslastung.'), + data: stats.topAssignees.map(a => ({ + key: a.name, + value: a.open, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Relation distribution ------------------------------------------ + if (stats.relationDistribution.length > 0) { + sections.push({ + type: 'pieChart', + span: 'half', + title: t('Beziehungsarten'), + donut: true, + data: stats.relationDistribution.map(r => ({ + key: r.relationType, + value: r.count, + })), + formatValue: _fmtUnits, + }); + } + + // ---- Backlog aging -------------------------------------------------- + if (stats.backlogAging.length > 0) { + sections.push({ + type: 'barChart', + span: 'full', + title: t('Backlog-Alter (offene Tickets)'), + description: t('Verteilung offener Tickets nach Alter -- hilft alte Leichen zu finden.'), + data: stats.backlogAging.map(b => ({ + key: b.label, + value: b.count, + })), + color: '#DD6B20', + formatValue: _fmtUnits, + }); + } + + return sections; +}; + +// ============================================================================ +// Main view +// ============================================================================ + +type BucketSize = 'day' | 'week' | 'month'; + +// Translate the polymorphic ``ReportFilterState.filters`` value for one +// multiselect key into a clean number[] and only call ``setter`` if the +// list actually changed (prevents an infinite render loop when the +// FormGenerator re-emits the same state). +const _applyMultiselectFilter = ( + raw: any, + current: number[], + setter: (next: number[]) => void, +): void => { + let next: number[] | null = null; + if (Array.isArray(raw)) { + next = raw.map(v => Number(v)).filter(n => !Number.isNaN(n)); + } else if (typeof raw === 'string' && raw !== '') { + const n = Number(raw); + if (!Number.isNaN(n)) next = [n]; + } else if (!raw) { + next = []; + } + if (next == null) return; + if (next.length === current.length && next.every((v, i) => v === current[i])) return; + setter(next); +}; + export const RedmineStatsView: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const instanceId = useInstanceId(); + const [schema, setSchema] = useState(null); + const [schemaError, setSchemaError] = useState(null); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const _load = useCallback(async () => { + const [dateFrom, setDateFrom] = useState(undefined); + const [dateTo, setDateTo] = useState(undefined); + const [bucket, setBucket] = useState('week'); + const [trackerIds, setTrackerIds] = useState([]); + const [categoryIds, setCategoryIds] = useState([]); + const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); + + // Load schema once -- we need trackers for the filter dropdown. + const _loadSchema = useCallback(async () => { + if (!instanceId) return; + try { + const res = await getRedmineSchemaApi(request, instanceId); + setSchema(res); + } catch (e: any) { + setSchemaError(e?.response?.data?.detail || e?.message || t('Schema-Laden fehlgeschlagen')); + } + }, [request, instanceId, t]); + + useEffect(() => { _loadSchema(); }, [_loadSchema]); + + // Load stats whenever the filters change. + const _loadStats = useCallback(async () => { if (!instanceId) return; setLoading(true); setError(null); try { - const result = await getRedmineStatsApi(request, instanceId, { bucket: 'week' }); - setStats(result); + const res = await getRedmineStatsApi(request, instanceId, { + dateFrom, + dateTo, + bucket, + trackerIds: trackerIds.length > 0 ? trackerIds : undefined, + categoryIds: categoryIds.length > 0 ? categoryIds : undefined, + statusFilter, + }); + setStats(res); } catch (e: any) { - setError(e?.message || t('Fehler beim Laden')); + setError(e?.response?.data?.detail || e?.message || t('Statistik-Laden fehlgeschlagen')); + setStats(null); } finally { setLoading(false); } - }, [request, instanceId, t]); + }, [request, instanceId, dateFrom, dateTo, bucket, trackerIds, categoryIds, statusFilter, t]); - useEffect(() => { _load(); }, [_load]); + useEffect(() => { _loadStats(); }, [_loadStats]); - if (loading) return
{t('Statistik wird geladen ...')}
; + // ---- FormGeneratorReport filter configuration ----------------------- + const dateRangeSelector = useMemo(() => ({ + enabled: true, + direction: 'past', + defaultPresetKind: 'thisQuarter', + enabledPresets: [ + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', + ], + }), []); + + const filterConfigs = useMemo(() => { + const configs: ReportFilterConfig[] = [ + { + key: 'bucket', + label: t('Gruppierung'), + type: 'select', + defaultValue: bucket, + options: [ + { value: 'day', label: t('Tag') }, + { value: 'week', label: t('Woche') }, + { value: 'month', label: t('Monat') }, + ], + }, + { + key: 'statusFilter', + label: t('Status'), + type: 'select', + defaultValue: statusFilter, + options: [ + { value: '*', label: t('Alle') }, + { value: 'open', label: t('Nur offen') }, + { value: 'closed', label: t('Nur geschlossen') }, + ], + }, + ]; + if (schema && schema.trackers.length > 0) { + configs.push({ + key: 'trackerIds', + label: t('Tracker'), + type: 'multiselect', + options: schema.trackers.map(tr => ({ + value: String(tr.id), + label: tr.name, + })), + placeholder: t('Alle Tracker'), + }); + } + if (schema && schema.categories.length > 0) { + configs.push({ + key: 'categoryIds', + label: t('Kategorie'), + type: 'multiselect', + options: schema.categories.map(cat => ({ + value: String(cat.id), + label: cat.name, + })), + placeholder: t('Alle Kategorien'), + }); + } + return configs; + }, [t, bucket, statusFilter, schema]); + + const _handleFilterChange = useCallback((filterState: ReportFilterState) => { + if (filterState.periodValue) { + setDateFrom(filterState.periodValue.fromDate); + setDateTo(filterState.periodValue.toDate); + } else if (filterState.dateRange) { + setDateFrom(toIsoDate(filterState.dateRange.from)); + setDateTo(toIsoDate(filterState.dateRange.to)); + } + + const f = filterState.filters || {}; + if (typeof f.bucket === 'string' && f.bucket !== bucket) { + setBucket(f.bucket as BucketSize); + } + if (typeof f.statusFilter === 'string' && f.statusFilter !== statusFilter) { + const next = f.statusFilter as '*' | 'open' | 'closed'; + if (next === '*' || next === 'open' || next === 'closed') { + setStatusFilter(next); + } + } + _applyMultiselectFilter(f.trackerIds, trackerIds, setTrackerIds); + _applyMultiselectFilter(f.categoryIds, categoryIds, setCategoryIds); + }, [bucket, statusFilter, trackerIds, categoryIds]); + + // ---- Derived report sections ---------------------------------------- + const sections = useMemo(() => { + if (!stats) return []; + return _buildSections(stats, t); + }, [stats, t]); + + if (!instanceId) { + return
{t('Keine Feature-Instanz ausgewaehlt')}
; + } return ( -
+

{t('Redmine -- Statistik')}

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

+ {schemaError &&
{schemaError}
} {error &&
{error}
} - {stats && ( -
-

{t('KPIs (gesamter Mirror)')}

-
-
{t('Tickets gesamt')}:
-
{stats.kpis.total}
-
{t('Offen')}:
-
{stats.kpis.open}
-
{t('Geschlossen')}:
-
{stats.kpis.closed}
-
{t('Im Zeitraum erstellt')}:
-
{stats.kpis.createdInPeriod}
-
{t('Im Zeitraum geschlossen')}:
-
{stats.kpis.closedInPeriod}
-
{t('Orphans (ohne Userstory)')}:
-
{stats.kpis.orphans}
-
-
- )} - - {stats && stats.statusByTracker.length > 0 && ( -
-

{t('Status pro Tracker')}

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

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

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