403 lines
14 KiB
TypeScript
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;
|