/** * Redmine Statistics View * * Default landing view for a Redmine feature instance. Reads aggregated * stats from the local mirror (fast, even at 20k+ tickets) and renders * KPIs + charts via ``FormGeneratorReport``. The built-in * ``dateRangeSelector`` mounts the shared ``PeriodPicker`` -- no extra * wiring needed. * * Buckets returned by the backend are mapped to ``ReportSection``s here * (frontend does the UI shape; backend stays storage-oriented). */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { RedmineFieldSchema, RedmineStats, getRedmineSchemaApi, getRedmineStatsApi, } from '../../../api/redmineApi'; import { FormGeneratorReport } from '../../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportDateRangeSelectorConfig, ReportFilterConfig, } from '../../../components/FormGenerator/FormGeneratorReport'; import { toIsoDate } from '../../../components/PeriodPicker'; import styles from './RedmineViews.module.css'; // ============================================================================ // Helpers -- map raw backend buckets to ReportSection[] // ============================================================================ // Format counts as integers ("Einheiten") -- prevents the chart components // from falling back to their default currency formatter (CHF). const _fmtUnits = (v: number): string => { if (!Number.isFinite(v)) return '0'; return Math.round(v).toLocaleString('de-CH'); }; const _buildSections = ( stats: RedmineStats, t: (key: string, vars?: Record) => string, ): ReportSection[] => { const sections: ReportSection[] = []; // ---- KPI tiles -------------------------------------------------------- sections.push({ type: 'kpiGrid', span: 'full', items: [ { label: t('Tickets gesamt'), value: stats.kpis.total }, { label: t('Offen'), value: stats.kpis.open, color: '#4A6FA5' }, { label: t('Geschlossen'), value: stats.kpis.closed, color: '#38A169' }, { label: t('Im Zeitraum erstellt'), value: stats.kpis.createdInPeriod }, { label: t('Im Zeitraum geschlossen'), value: stats.kpis.closedInPeriod }, { label: t('Ohne Userstory (Orphans)'), value: stats.kpis.orphans, color: stats.kpis.orphans > 0 ? '#C53030' : undefined, }, ], }); // ---- Snapshot chart: total tickets vs. open per bucket end ---------- // ``cumTotal`` and ``cumOpen`` are computed server-side and are SNAPSHOT // values (state at the end of each bucket), not flow numbers. The // difference between the two lines is the cumulative number of closed // tickets up to that point in time. if (stats.throughput.length > 0) { const snapshotData = stats.throughput.map(b => ({ date: b.label, total: b.cumTotal, open: b.cumOpen, })); sections.push({ type: 'lineChart', span: 'full', title: t('Bestand pro {bucket}: Total vs. Offen', { bucket: stats.bucket }), description: t('Snapshot am Ende jeder Periode: wie viele Tickets es zu diesem Zeitpunkt gibt (Total) und wie viele davon noch offen sind. Die Luecke zwischen den Linien sind die bis dahin geschlossenen Tickets.'), data: snapshotData, series: [ { key: 'total', label: t('Total'), color: '#4A6FA5' }, { key: 'open', label: t('Offen'), color: '#DD6B20' }, ], formatValue: _fmtUnits, }); } // ---- Status per tracker (stacked-like via horizontal bar per tracker) if (stats.statusByTracker.length > 0) { const statusKeys = new Set(); stats.statusByTracker.forEach(row => { Object.keys(row.countsByStatus).forEach(k => statusKeys.add(k)); }); const totals: Record = {}; stats.statusByTracker.forEach(row => { Object.entries(row.countsByStatus).forEach(([s, n]) => { totals[s] = (totals[s] || 0) + n; }); }); sections.push({ type: 'pieChart', span: 'half', title: t('Status-Verteilung (gesamt)'), donut: true, data: Object.entries(totals) .sort(([, a], [, b]) => b - a) .map(([status, count]) => ({ key: status, value: count })), formatValue: _fmtUnits, }); sections.push({ type: 'horizontalBar', span: 'half', title: t('Tickets pro Tracker'), data: stats.statusByTracker .slice() .sort((a, b) => b.total - a.total) .map(row => ({ key: row.trackerName, value: row.total, })), formatValue: _fmtUnits, }); } // ---- Top assignees --------------------------------------------------- if (stats.topAssignees.length > 0) { sections.push({ type: 'horizontalBar', span: 'half', title: t('Top 10 Zugewiesene (offene Tickets)'), description: t('Offene Tickets nach zugewiesener Person -- zeigt Auslastung.'), data: stats.topAssignees.map(a => ({ key: a.name, value: a.open, })), formatValue: _fmtUnits, }); } // ---- Relation distribution ------------------------------------------ if (stats.relationDistribution.length > 0) { sections.push({ type: 'pieChart', span: 'half', title: t('Beziehungsarten'), donut: true, data: stats.relationDistribution.map(r => ({ key: r.relationType, value: r.count, })), formatValue: _fmtUnits, }); } // ---- Backlog aging -------------------------------------------------- if (stats.backlogAging.length > 0) { sections.push({ type: 'barChart', span: 'full', title: t('Backlog-Alter (offene Tickets)'), description: t('Verteilung offener Tickets nach Alter -- hilft alte Leichen zu finden.'), data: stats.backlogAging.map(b => ({ key: b.label, value: b.count, })), color: '#DD6B20', formatValue: _fmtUnits, }); } return sections; }; // ============================================================================ // Main view // ============================================================================ type BucketSize = 'day' | 'week' | 'month'; // Translate the polymorphic ``ReportFilterState.filters`` value for one // multiselect key into a clean number[] and only call ``setter`` if the // list actually changed (prevents an infinite render loop when the // FormGenerator re-emits the same state). const _applyMultiselectFilter = ( raw: any, current: number[], setter: (next: number[]) => void, ): void => { let next: number[] | null = null; if (Array.isArray(raw)) { next = raw.map(v => Number(v)).filter(n => !Number.isNaN(n)); } else if (typeof raw === 'string' && raw !== '') { const n = Number(raw); if (!Number.isNaN(n)) next = [n]; } else if (!raw) { next = []; } if (next == null) return; if (next.length === current.length && next.every((v, i) => v === current[i])) return; setter(next); }; export const RedmineStatsView: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const instanceId = useInstanceId(); const [schema, setSchema] = useState(null); const [schemaError, setSchemaError] = useState(null); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dateFrom, setDateFrom] = useState(undefined); const [dateTo, setDateTo] = useState(undefined); const [bucket, setBucket] = useState('week'); const [trackerIds, setTrackerIds] = useState([]); const [categoryIds, setCategoryIds] = useState([]); const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); // Load schema once -- we need trackers for the filter dropdown. const _loadSchema = useCallback(async () => { if (!instanceId) return; try { const res = await getRedmineSchemaApi(request, instanceId); setSchema(res); } catch (e: any) { setSchemaError(e?.response?.data?.detail || e?.message || t('Schema-Laden fehlgeschlagen')); } }, [request, instanceId, t]); useEffect(() => { _loadSchema(); }, [_loadSchema]); // Load stats whenever the filters change. const _loadStats = useCallback(async () => { if (!instanceId) return; setLoading(true); setError(null); try { const res = await getRedmineStatsApi(request, instanceId, { dateFrom, dateTo, bucket, trackerIds: trackerIds.length > 0 ? trackerIds : undefined, categoryIds: categoryIds.length > 0 ? categoryIds : undefined, statusFilter, }); setStats(res); } catch (e: any) { setError(e?.response?.data?.detail || e?.message || t('Statistik-Laden fehlgeschlagen')); setStats(null); } finally { setLoading(false); } }, [request, instanceId, dateFrom, dateTo, bucket, trackerIds, categoryIds, statusFilter, t]); useEffect(() => { _loadStats(); }, [_loadStats]); // ---- FormGeneratorReport filter configuration ----------------------- const dateRangeSelector = useMemo(() => ({ enabled: true, direction: 'past', defaultPresetKind: 'thisQuarter', enabledPresets: [ 'allTime', '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) { // "Alle" = no date filter. Drop the sentinel range so the backend // aggregates over the full history instead of clamping to 1970--2999. if (filterState.periodValue.preset.kind === 'allTime') { setDateFrom(undefined); setDateTo(undefined); } else { setDateFrom(filterState.periodValue.fromDate); setDateTo(filterState.periodValue.toDate); } } else if (filterState.dateRange) { setDateFrom(toIsoDate(filterState.dateRange.from)); setDateTo(toIsoDate(filterState.dateRange.to)); } const f = filterState.filters || {}; if (typeof f.bucket === 'string' && f.bucket !== bucket) { setBucket(f.bucket as BucketSize); } if (typeof f.statusFilter === 'string' && f.statusFilter !== statusFilter) { const next = f.statusFilter as '*' | 'open' | 'closed'; if (next === '*' || next === 'open' || next === 'closed') { setStatusFilter(next); } } _applyMultiselectFilter(f.trackerIds, trackerIds, setTrackerIds); _applyMultiselectFilter(f.categoryIds, categoryIds, setCategoryIds); }, [bucket, statusFilter, trackerIds, categoryIds]); // ---- Derived report sections ---------------------------------------- const sections = useMemo(() => { if (!stats) return []; return _buildSections(stats, t); }, [stats, t]); if (!instanceId) { return
{t('Keine Feature-Instanz ausgewaehlt')}
; } return (

{t('Redmine -- Statistik')}

{t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')}

{schemaError &&
{schemaError}
} {error &&
{error}
}
); }; export default RedmineStatsView;