frontend_nyla/src/pages/views/redmine/RedmineStatsView.tsx
ValueOn AG fc2cce8732 fixes
2026-04-23 23:09:54 +02:00

403 lines
14 KiB
TypeScript

/**
* 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, 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 = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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;
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<ReportDateRangeSelectorConfig>(() => ({
enabled: true,
direction: 'past',
defaultPresetKind: 'thisQuarter',
enabledPresets: [
'allTime',
'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) {
// "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<ReportSection[]>(() => {
if (!stats) return [];
return _buildSections(stats, t);
}, [stats, t]);
if (!instanceId) {
return <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
}
return (
<div className={styles.pageWide}>
<h2 className={styles.heading}>{t('Redmine -- Statistik')}</h2>
<p className={styles.subheading}>
{t('Aggregiert aus dem lokalen Mirror. Filter werden serverseitig angewendet; Zeitraum steuert auch die "im Zeitraum"-KPIs.')}
</p>
{schemaError && <div className={styles.alertErr}>{schemaError}</div>}
{error && <div className={styles.alertErr}>{error}</div>}
<FormGeneratorReport
title={stats
? t('{total} Tickets ({open} offen, {closed} geschlossen)', {
total: stats.kpis.total,
open: stats.kpis.open,
closed: stats.kpis.closed,
})
: undefined}
sections={sections}
loading={loading}
noDataMessage={t('Keine Tickets im Mirror. Starte zuerst den Sync auf der Einstellungen-Seite.')}
dateRangeSelector={dateRangeSelector}
filters={filterConfigs}
onFilterChange={_handleFilterChange}
/>
</div>
);
};
export default RedmineStatsView;