redmine integration

This commit is contained in:
ValueOn AG 2026-04-21 18:14:26 +02:00
parent d771d4bc09
commit 0bdaf86153
9 changed files with 1141 additions and 0 deletions

View file

@ -92,6 +92,20 @@ api.interceptors.request.use(
config.headers['Accept-Language'] = appLanguage;
}
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
// resolve "now" for AI agents and user-visible time strings without
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
if (config.headers) {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone) {
config.headers['X-User-Timezone'] = browserTimezone;
}
} catch {
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
}
}
// Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl();

387
src/api/redmineApi.ts Normal file
View file

@ -0,0 +1,387 @@
/**
* Redmine API
*
* Frontend client for the Redmine feature backend.
* URL pattern: /api/redmine/{instanceId}/...
*/
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py
// ============================================================================
export interface RedmineConfigDto {
id?: string;
featureInstanceId: string;
mandateId?: string | null;
baseUrl: string;
projectId: string;
hasApiKey: boolean;
rootTrackerName: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds: number;
schemaCachedAt?: number | null;
isActive: boolean;
lastConnectedAt?: number | null;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorMessage?: string | null;
}
export interface RedmineConfigUpdateRequest {
baseUrl?: string;
projectId?: string;
apiKey?: string;
rootTrackerName?: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds?: number;
isActive?: boolean;
}
export interface RedmineFieldChoice {
id: number;
name: string;
isClosed?: boolean | null;
}
export interface RedmineCustomFieldSchema {
id: number;
name: string;
fieldFormat: string;
isRequired: boolean;
possibleValues: string[];
multiple: boolean;
defaultValue?: string | null;
}
export interface RedmineFieldSchema {
projectId: string;
projectName: string;
trackers: RedmineFieldChoice[];
statuses: RedmineFieldChoice[];
priorities: RedmineFieldChoice[];
users: RedmineFieldChoice[];
customFields: RedmineCustomFieldSchema[];
rootTrackerName: string;
rootTrackerId: number | null;
}
export interface RedmineRelation {
id: number;
issueId: number;
issueToId: number;
relationType: string;
delay?: number | null;
}
export interface RedmineCustomFieldValue {
id: number;
name: string;
value: any;
}
export interface RedmineTicket {
id: number;
subject: string;
description: string;
trackerId?: number | null;
trackerName?: string | null;
statusId?: number | null;
statusName?: string | null;
isClosed: boolean;
priorityId?: number | null;
priorityName?: string | null;
assignedToId?: number | null;
assignedToName?: string | null;
authorId?: number | null;
authorName?: string | null;
parentId?: number | null;
fixedVersionId?: number | null;
fixedVersionName?: string | null;
createdOn?: string | null;
updatedOn?: string | null;
customFields: RedmineCustomFieldValue[];
relations: RedmineRelation[];
}
export interface RedmineSyncResult {
instanceId: string;
full: boolean;
ticketsUpserted: number;
relationsUpserted: number;
durationMs: number;
lastSyncAt: number;
error?: string | null;
}
export interface RedmineSyncStatus {
instanceId: string;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncDurationMs?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorAt?: number | null;
lastSyncErrorMessage?: string | null;
mirroredTicketCount: number;
mirroredRelationCount: number;
}
export interface RedmineConnectionTestResult {
ok: boolean;
reason?: string;
message?: string;
status?: number;
user?: { id: number; name: string };
project?: { id: number; name: string };
}
export interface RedmineStats {
instanceId: string;
dateFrom?: string | null;
dateTo?: string | null;
bucket: string;
trackerIds: number[];
kpis: {
total: number;
open: number;
closed: number;
closedInPeriod: number;
createdInPeriod: number;
orphans: number;
};
statusByTracker: Array<{
trackerId?: number | null;
trackerName: string;
countsByStatus: Record<string, number>;
total: number;
}>;
throughput: Array<{
bucketKey: string;
label: string;
created: number;
closed: number;
}>;
topAssignees: Array<{
assignedToId?: number | null;
name: string;
open: number;
}>;
relationDistribution: Array<{ relationType: string; count: number }>;
backlogAging: Array<{
bucketKey: string;
label: string;
minDays: number;
maxDays?: number | null;
count: number;
}>;
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
// ============================================================================
// Config
// ============================================================================
export async function getRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
}
export async function updateRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineConfigUpdateRequest,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body });
}
export async function deleteRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<{ deleted: boolean }> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' });
}
export async function testRedmineConnectionApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConnectionTestResult> {
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
}
// ============================================================================
// Schema
// ============================================================================
export async function getRedmineSchemaApi(
request: ApiRequestFunction,
instanceId: string,
forceRefresh = false,
): Promise<RedmineFieldSchema> {
return await request({
url: `${_baseUrl(instanceId)}/schema`,
method: 'get',
params: forceRefresh ? { forceRefresh: true } : undefined,
});
}
// ============================================================================
// Sync
// ============================================================================
export async function runRedmineSyncApi(
request: ApiRequestFunction,
instanceId: string,
force = false,
): Promise<RedmineSyncResult> {
return await request({
url: `${_baseUrl(instanceId)}/sync`,
method: 'post',
params: force ? { force: true } : undefined,
});
}
export async function getRedmineSyncStatusApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineSyncStatus> {
return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' });
}
// ============================================================================
// Tickets
// ============================================================================
export interface ListTicketsParams {
trackerIds?: number[];
status?: 'open' | 'closed' | '*';
dateFrom?: string;
dateTo?: string;
assignedToId?: number;
}
export async function listRedmineTicketsApi(
request: ApiRequestFunction,
instanceId: string,
params: ListTicketsParams = {},
): Promise<RedmineTicket[]> {
const queryParams: Record<string, any> = {};
if (params.status) queryParams.status = params.status;
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'get',
params: queryParams,
});
}
export async function getRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'get',
});
}
export interface RedmineTicketUpdateBody {
subject?: string;
description?: string;
trackerId?: number;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
notes?: string;
customFields?: Record<number, any>;
}
export async function updateRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
body: RedmineTicketUpdateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'put',
data: body,
});
}
export interface RedmineTicketCreateBody {
subject: string;
trackerId: number;
description?: string;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
customFields?: Record<number, any>;
}
export async function createRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineTicketCreateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'post',
data: body,
});
}
export async function deleteRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
fallbackStatusId?: number,
): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'delete',
params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined,
});
}
// ============================================================================
// Stats
// ============================================================================
export interface RedmineStatsParams {
dateFrom?: string;
dateTo?: string;
bucket?: 'day' | 'week' | 'month';
trackerIds?: number[];
}
export async function getRedmineStatsApi(
request: ApiRequestFunction,
instanceId: string,
params: RedmineStatsParams = {},
): Promise<RedmineStats> {
const queryParams: Record<string, any> = {};
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
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;
return await request({
url: `${_baseUrl(instanceId)}/stats`,
method: 'get',
params: queryParams,
});
}

View file

@ -142,6 +142,12 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.feature.workspace.dashboard': <FaPlay />,
'page.feature.workspace.editor': <FaPlay />,
'feature.workspace': <FaPlay />,
// Feature pages - Redmine
'feature.redmine': <FaClipboardList />,
'page.feature.redmine.stats': <FaChartBar />,
'page.feature.redmine.browser': <FaProjectDiagram />,
'page.feature.redmine.settings': <FaCog />,
};
// =============================================================================

View file

@ -48,6 +48,9 @@ import { NeutralizationView } from './views/neutralization';
// CommCoach Views
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
// Redmine Views
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
import styles from './FeatureView.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@ -168,6 +171,11 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
dossier: CommcoachDossierView,
settings: CommcoachSettingsView,
},
redmine: {
stats: RedmineStatsView,
browser: RedmineBrowserView,
settings: RedmineSettingsView,
},
};
// =============================================================================

View file

@ -0,0 +1,101 @@
/**
* Redmine Ticket Browser (Phase 2 placeholder).
*
* 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.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
RedmineTicket,
listRedmineTicketsApi,
} from '../../../api/redmineApi';
import styles from './RedmineViews.module.css';
export const RedmineBrowserView: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const instanceId = useInstanceId();
const [tickets, setTickets] = useState<RedmineTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const _load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const result = await listRedmineTicketsApi(request, instanceId, { status: '*' });
setTickets(result);
} catch (e: any) {
setError(e?.message || t('Fehler beim Laden'));
} finally {
setLoading(false);
}
}, [request, instanceId, t]);
useEffect(() => { _load(); }, [_load]);
if (loading) return <div className={styles.loading}>{t('Tickets werden geladen ...')}</div>;
return (
<div className={styles.page}>
<h2 className={styles.heading}>{t('Redmine -- Ticket-Browser')}</h2>
<p className={styles.subheading}>
{t('Liest aus dem lokalen Mirror. Tree-Layout und Editor-Pane folgen im naechsten Schritt.')}
</p>
{error && <div className={styles.alertErr}>{error}</div>}
{tickets.length === 0 ? (
<div className={styles.placeholder}>
{t('Keine Tickets im Mirror. Bitte zuerst in den Einstellungen "Sync starten".')}
</div>
) : (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
{tickets.length} {t('Tickets')}
</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border-color, #e2e8f0)' }}>
<th style={{ padding: '0.4rem' }}>ID</th>
<th style={{ padding: '0.4rem' }}>{t('Tracker')}</th>
<th style={{ padding: '0.4rem' }}>{t('Titel')}</th>
<th style={{ padding: '0.4rem' }}>Status</th>
<th style={{ padding: '0.4rem' }}>{t('Zuweisung')}</th>
<th style={{ padding: '0.4rem' }}>{t('Geaendert')}</th>
</tr>
</thead>
<tbody>
{tickets.slice(0, 200).map(ticket => (
<tr key={ticket.id} style={{ borderBottom: '1px solid var(--border-color, #f0f0f0)' }}>
<td style={{ padding: '0.4rem' }}>#{ticket.id}</td>
<td style={{ padding: '0.4rem' }}>{ticket.trackerName || '-'}</td>
<td style={{ padding: '0.4rem' }}>{ticket.subject}</td>
<td style={{ padding: '0.4rem' }}>{ticket.statusName || '-'}</td>
<td style={{ padding: '0.4rem' }}>{ticket.assignedToName || '-'}</td>
<td style={{ padding: '0.4rem' }}>{ticket.updatedOn?.slice(0, 10) || '-'}</td>
</tr>
))}
</tbody>
</table>
{tickets.length > 200 && (
<p className={styles.hint}>
{t('(Anzeige auf 200 begrenzt -- Tree-Layout folgt.)')}
</p>
)}
</div>
)}
</div>
);
};
export default RedmineBrowserView;

View file

@ -0,0 +1,349 @@
/**
* Redmine Settings View
*
* Configure the Redmine connection for this feature instance:
* - Base URL, Project ID, API Key, Root Tracker name
* - "Verbindung testen" -- calls whoAmI + getProject and reports the result
* - "Sync starten" -- pulls all (or only changed) tickets into the local mirror
*
* The user tests the feature directly here in Porta -- no pytest sandbox.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
RedmineConfigDto,
RedmineConnectionTestResult,
RedmineSyncResult,
RedmineSyncStatus,
deleteRedmineConfigApi,
getRedmineConfigApi,
getRedmineSyncStatusApi,
runRedmineSyncApi,
testRedmineConnectionApi,
updateRedmineConfigApi,
} from '../../../api/redmineApi';
import styles from './RedmineViews.module.css';
const _formatTs = (ts?: number | null): string => {
if (!ts) return '-';
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return String(ts);
}
};
const _formatDuration = (ms?: number | null): string => {
if (ms == null) return '-';
if (ms < 1000) return `${ms} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
const m = s / 60;
return `${m.toFixed(1)} min`;
};
export const RedmineSettingsView: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const instanceId = useInstanceId();
const [config, setConfig] = useState<RedmineConfigDto | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [testResult, setTestResult] = useState<RedmineConnectionTestResult | null>(null);
const [syncResult, setSyncResult] = useState<RedmineSyncResult | null>(null);
const [syncStatus, setSyncStatus] = useState<RedmineSyncStatus | null>(null);
const [baseUrl, setBaseUrl] = useState('');
const [projectId, setProjectId] = useState('');
const [rootTrackerName, setRootTrackerName] = useState('Userstory');
const [apiKey, setApiKey] = useState('');
const _hydrate = useCallback((c: RedmineConfigDto) => {
setConfig(c);
setBaseUrl(c.baseUrl || '');
setProjectId(c.projectId || '');
setRootTrackerName(c.rootTrackerName || 'Userstory');
}, []);
const _loadStatus = useCallback(async () => {
if (!instanceId) return;
try {
const status = await getRedmineSyncStatusApi(request, instanceId);
setSyncStatus(status);
} catch {
// status is optional; don't block the page on failure
}
}, [request, instanceId]);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const cfg = await getRedmineConfigApi(request, instanceId);
if (!cancelled) _hydrate(cfg);
await _loadStatus();
} catch (e: any) {
if (!cancelled) setError(e?.message || t('Fehler beim Laden'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [request, instanceId, _hydrate, _loadStatus, t]);
const _save = useCallback(async () => {
if (!instanceId) return;
setSaving(true);
setError(null);
setSuccess(null);
try {
const body: Record<string, any> = {
baseUrl: baseUrl.trim(),
projectId: projectId.trim(),
rootTrackerName: rootTrackerName.trim() || 'Userstory',
isActive: true,
};
if (apiKey.trim() !== '') body.apiKey = apiKey.trim();
const updated = await updateRedmineConfigApi(request, instanceId, body);
_hydrate(updated);
setApiKey('');
setSuccess(t('Einstellungen gespeichert.'));
setTimeout(() => setSuccess(null), 3000);
} catch (e: any) {
setError(e?.message || t('Fehler beim Speichern.'));
} finally {
setSaving(false);
}
}, [request, instanceId, baseUrl, projectId, rootTrackerName, apiKey, _hydrate, t]);
const _test = useCallback(async () => {
if (!instanceId) return;
setTesting(true);
setError(null);
setTestResult(null);
try {
const result = await testRedmineConnectionApi(request, instanceId);
setTestResult(result);
} catch (e: any) {
setError(e?.message || t('Verbindungstest fehlgeschlagen.'));
} finally {
setTesting(false);
}
}, [request, instanceId, t]);
const _runSync = useCallback(async (force: boolean) => {
if (!instanceId) return;
setSyncing(true);
setError(null);
setSuccess(null);
setSyncResult(null);
try {
const result = await runRedmineSyncApi(request, instanceId, force);
setSyncResult(result);
await _loadStatus();
const cfg = await getRedmineConfigApi(request, instanceId);
_hydrate(cfg);
setSuccess(
t('Sync erfolgreich.') +
` ${result.ticketsUpserted} ${t('Tickets')}, ${result.relationsUpserted} ${t('Beziehungen')}, ${_formatDuration(result.durationMs)}.`,
);
} catch (e: any) {
setError(e?.message || t('Sync fehlgeschlagen.'));
} finally {
setSyncing(false);
}
}, [request, instanceId, _loadStatus, _hydrate, t]);
const _delete = useCallback(async () => {
if (!instanceId) return;
if (!window.confirm(t('Konfiguration wirklich loeschen? Der lokale Mirror bleibt erhalten.'))) return;
setError(null);
setSuccess(null);
try {
await deleteRedmineConfigApi(request, instanceId);
setBaseUrl('');
setProjectId('');
setRootTrackerName('Userstory');
setApiKey('');
setConfig(null);
setSuccess(t('Konfiguration geloescht.'));
} catch (e: any) {
setError(e?.message || t('Loeschen fehlgeschlagen.'));
}
}, [request, instanceId, t]);
if (loading) {
return <div className={styles.loading}>{t('Einstellungen werden geladen ...')}</div>;
}
const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId;
const canSync = canTest;
return (
<div className={styles.page}>
<h2 className={styles.heading}>{t('Redmine -- Einstellungen')}</h2>
<p className={styles.subheading}>
{t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')}
</p>
{error && <div className={styles.alertErr}>{error}</div>}
{success && <div className={styles.alertOk}>{success}</div>}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t('Verbindung')}</h3>
<div className={styles.field}>
<label className={styles.label}>{t('Basis-URL')}</label>
<input
type="text"
className={styles.input}
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="https://redmine.example.com"
spellCheck={false}
/>
<div className={styles.hint}>{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}</div>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('Projekt-ID oder -Slug')}</label>
<input
type="text"
className={styles.input}
value={projectId}
onChange={e => setProjectId(e.target.value)}
placeholder="logobject-mars"
spellCheck={false}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('Wurzel-Tracker (Name)')}</label>
<input
type="text"
className={styles.input}
value={rootTrackerName}
onChange={e => setRootTrackerName(e.target.value)}
placeholder="Userstory"
spellCheck={false}
/>
<div className={styles.hint}>
{t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')}
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('API-Key')}</label>
<input
type="password"
className={styles.input}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder={config?.hasApiKey ? t('(gesetzt -- leer lassen, um nicht zu aendern)') : t('Redmine API Access Key')}
spellCheck={false}
autoComplete="new-password"
/>
<div className={styles.hint}>
{t('Wird verschluesselt gespeichert. Status: ')}
{config?.hasApiKey ? <strong>{t('gesetzt')}</strong> : <em>{t('nicht gesetzt')}</em>}
</div>
</div>
<div className={styles.row}>
<button className={styles.btn} onClick={_save} disabled={saving}>
{saving ? t('Speichere ...') : t('Speichern')}
</button>
<button className={styles.btnSecondary} onClick={_test} disabled={!canTest || testing}>
{testing ? t('Teste ...') : t('Verbindung testen')}
</button>
{config?.id && (
<button className={styles.btnDanger} onClick={_delete}>
{t('Konfiguration loeschen')}
</button>
)}
</div>
{testResult && (
<div style={{ marginTop: '0.85rem' }}>
{testResult.ok ? (
<div className={styles.alertOk}>
<strong>{t('Verbindung OK')}.</strong>{' '}
{testResult.user?.name && <>{t('Angemeldet als')} <strong>{testResult.user.name}</strong>. </>}
{testResult.project?.name && <>{t('Projekt')}: <strong>{testResult.project.name}</strong>.</>}
</div>
) : (
<div className={styles.alertErr}>
<strong>{t('Verbindung fehlgeschlagen')}.</strong>{' '}
{testResult.message || testResult.reason || ''}
{testResult.status ? ` (HTTP ${testResult.status})` : ''}
</div>
)}
</div>
)}
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t('Mirror-Sync')}</h3>
<p className={styles.hint} style={{ marginBottom: '0.85rem' }}>
{t('Tickets werden in die lokale Datenbank gespiegelt, damit Statistik und Browser auch bei 20\u2019000+ Tickets schnell sind. Nach Aenderungen wird das Mirror-Bild automatisch nachgezogen.')}
</p>
<div className={styles.kvGrid} style={{ marginBottom: '0.85rem' }}>
<div className={styles.kvLabel}>{t('Letzter Sync')}:</div>
<div className={styles.kvValue}>{_formatTs(config?.lastSyncAt)}</div>
<div className={styles.kvLabel}>{t('Letzter Full-Sync')}:</div>
<div className={styles.kvValue}>{_formatTs(config?.lastFullSyncAt)}</div>
<div className={styles.kvLabel}>{t('Letzte Sync-Dauer')}:</div>
<div className={styles.kvValue}>{_formatDuration(syncStatus?.lastSyncDurationMs)}</div>
<div className={styles.kvLabel}>{t('Tickets im Mirror')}:</div>
<div className={styles.kvValue}>{syncStatus?.mirroredTicketCount ?? '-'}</div>
<div className={styles.kvLabel}>{t('Beziehungen im Mirror')}:</div>
<div className={styles.kvValue}>{syncStatus?.mirroredRelationCount ?? '-'}</div>
{config?.lastSyncErrorMessage && (
<>
<div className={styles.kvLabel}>{t('Letzter Fehler')}:</div>
<div className={styles.kvValue} style={{ color: '#c53030' }}>{config.lastSyncErrorMessage}</div>
</>
)}
</div>
<div className={styles.row}>
<button className={styles.btn} onClick={() => _runSync(false)} disabled={!canSync || syncing}>
{syncing ? t('Synchronisiere ...') : t('Sync starten (inkrementell)')}
</button>
<button className={styles.btnSecondary} onClick={() => _runSync(true)} disabled={!canSync || syncing}>
{t('Full-Sync (alle Tickets)')}
</button>
</div>
{syncResult && (
<div className={styles.alertInfo} style={{ marginTop: '0.85rem' }}>
<strong>{syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:</strong>{' '}
{syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '}
{t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}.
</div>
)}
{!canSync && (
<div className={styles.hint} style={{ marginTop: '0.85rem' }}>
{t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')}
</div>
)}
</div>
</div>
);
};
export default RedmineSettingsView;

View file

@ -0,0 +1,93 @@
/**
* Redmine Statistics View (Phase 2 placeholder).
*
* 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.
*/
import React, { useCallback, useEffect, 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 styles from './RedmineViews.module.css';
export const RedmineStatsView: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const instanceId = useInstanceId();
const [stats, setStats] = useState<RedmineStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const _load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const result = await getRedmineStatsApi(request, instanceId, { bucket: 'week' });
setStats(result);
} catch (e: any) {
setError(e?.message || t('Fehler beim Laden'));
} finally {
setLoading(false);
}
}, [request, instanceId, t]);
useEffect(() => { _load(); }, [_load]);
if (loading) return <div className={styles.loading}>{t('Statistik wird geladen ...')}</div>;
return (
<div className={styles.page}>
<h2 className={styles.heading}>{t('Redmine -- Statistik')}</h2>
<p className={styles.subheading}>
{t('Aggregiert aus dem lokalen Mirror. PeriodPicker und FormGeneratorReport folgen im naechsten Schritt.')}
</p>
{error && <div className={styles.alertErr}>{error}</div>}
{stats && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t('KPIs (gesamter Mirror)')}</h3>
<div className={styles.kvGrid}>
<div className={styles.kvLabel}>{t('Tickets gesamt')}:</div>
<div className={styles.kvValue}>{stats.kpis.total}</div>
<div className={styles.kvLabel}>{t('Offen')}:</div>
<div className={styles.kvValue}>{stats.kpis.open}</div>
<div className={styles.kvLabel}>{t('Geschlossen')}:</div>
<div className={styles.kvValue}>{stats.kpis.closed}</div>
<div className={styles.kvLabel}>{t('Im Zeitraum erstellt')}:</div>
<div className={styles.kvValue}>{stats.kpis.createdInPeriod}</div>
<div className={styles.kvLabel}>{t('Im Zeitraum geschlossen')}:</div>
<div className={styles.kvValue}>{stats.kpis.closedInPeriod}</div>
<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>
);
};
export default RedmineStatsView;

View file

@ -0,0 +1,180 @@
.page {
padding: 1.25rem;
max-width: 960px;
margin: 0 auto;
font-family: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.heading {
font-size: 1.35rem;
font-weight: 600;
margin: 0 0 1.25rem;
color: var(--text-primary, #1a202c);
}
.subheading {
font-size: 0.95rem;
color: var(--text-secondary, #4a5568);
margin: -0.75rem 0 1.25rem;
}
.section {
margin-bottom: 1.75rem;
padding: 1rem 1.25rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.85rem;
color: var(--text-primary, #1a202c);
}
.field {
margin-bottom: 0.85rem;
}
.label {
display: block;
font-size: 0.82rem;
font-weight: 500;
margin-bottom: 0.3rem;
color: var(--text-primary, #1a202c);
}
.hint {
font-size: 0.78rem;
color: var(--text-secondary, #718096);
margin-top: 0.25rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 6px;
font-size: 0.9rem;
background: #fff;
color: var(--text-primary, #1a202c);
font-family: inherit;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--primary-color, #4A6FA5);
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.18);
}
.row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: flex-end;
}
.btn {
padding: 0.55rem 1.1rem;
background: var(--primary-color, #4A6FA5);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
font-family: inherit;
transition: filter 0.15s;
}
.btn:hover:not(:disabled) { filter: brightness(1.08); }
.btn:disabled {
background: var(--color-medium-gray, #cbd5e0);
color: var(--text-secondary, #718096);
cursor: not-allowed;
}
.btnSecondary {
composes: btn;
background: #fff;
color: var(--primary-color, #4A6FA5);
border: 1px solid var(--primary-color, #4A6FA5);
}
.btnSecondary:hover:not(:disabled) {
background: rgba(74, 111, 165, 0.06);
filter: none;
}
.btnDanger {
composes: btn;
background: #C53030;
}
.btnDanger:hover:not(:disabled) { filter: brightness(1.08); }
.alertOk {
padding: 0.55rem 0.85rem;
background: #e6fffa;
color: #2c7a7b;
border: 1px solid #b2f5ea;
border-radius: 6px;
margin-bottom: 0.85rem;
font-size: 0.85rem;
}
.alertErr {
padding: 0.55rem 0.85rem;
background: #fff5f5;
color: #c53030;
border: 1px solid #fed7d7;
border-radius: 6px;
margin-bottom: 0.85rem;
font-size: 0.85rem;
}
.alertInfo {
padding: 0.55rem 0.85rem;
background: #ebf8ff;
color: #2c5282;
border: 1px solid #bee3f8;
border-radius: 6px;
margin-bottom: 0.85rem;
font-size: 0.85rem;
}
.kvGrid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
font-size: 0.85rem;
color: var(--text-secondary, #4a5568);
}
.kvLabel {
font-weight: 500;
color: var(--text-secondary, #4a5568);
}
.kvValue {
color: var(--text-primary, #1a202c);
font-variant-numeric: tabular-nums;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #718096);
}
.placeholder {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #718096);
background: var(--bg-card, #fff);
border: 1px dashed var(--border-color, #e2e8f0);
border-radius: 8px;
}

View file

@ -0,0 +1,3 @@
export { RedmineSettingsView } from './RedmineSettingsView';
export { RedmineStatsView } from './RedmineStatsView';
export { RedmineBrowserView } from './RedmineBrowserView';