diff --git a/src/api.ts b/src/api.ts index 69ac956..8771019 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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(); diff --git a/src/api/redmineApi.ts b/src/api/redmineApi.ts new file mode 100644 index 0000000..c89664a --- /dev/null +++ b/src/api/redmineApi.ts @@ -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 | 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 | 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; + 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) => Promise; + +const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`; + +// ============================================================================ +// Config +// ============================================================================ + +export async function getRedmineConfigApi( + request: ApiRequestFunction, + instanceId: string, +): Promise { + return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' }); +} + +export async function updateRedmineConfigApi( + request: ApiRequestFunction, + instanceId: string, + body: RedmineConfigUpdateRequest, +): Promise { + 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 { + return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' }); +} + +// ============================================================================ +// Schema +// ============================================================================ + +export async function getRedmineSchemaApi( + request: ApiRequestFunction, + instanceId: string, + forceRefresh = false, +): Promise { + 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 { + return await request({ + url: `${_baseUrl(instanceId)}/sync`, + method: 'post', + params: force ? { force: true } : undefined, + }); +} + +export async function getRedmineSyncStatusApi( + request: ApiRequestFunction, + instanceId: string, +): Promise { + 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 { + const queryParams: Record = {}; + 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 { + 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; +} + +export async function updateRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + issueId: number, + body: RedmineTicketUpdateBody, +): Promise { + 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; +} + +export async function createRedmineTicketApi( + request: ApiRequestFunction, + instanceId: string, + body: RedmineTicketCreateBody, +): Promise { + 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 { + const queryParams: Record = {}; + 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, + }); +} diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index eca461c..f3c18d8 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -142,6 +142,12 @@ export const PAGE_ICONS: Record = { 'page.feature.workspace.dashboard': , 'page.feature.workspace.editor': , 'feature.workspace': , + + // Feature pages - Redmine + 'feature.redmine': , + 'page.feature.redmine.stats': , + 'page.feature.redmine.browser': , + 'page.feature.redmine.settings': , }; // ============================================================================= diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 03c4b45..7fb75e3 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -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> = { dossier: CommcoachDossierView, settings: CommcoachSettingsView, }, + redmine: { + stats: RedmineStatsView, + browser: RedmineBrowserView, + settings: RedmineSettingsView, + }, }; // ============================================================================= diff --git a/src/pages/views/redmine/RedmineBrowserView.tsx b/src/pages/views/redmine/RedmineBrowserView.tsx new file mode 100644 index 0000000..f85a836 --- /dev/null +++ b/src/pages/views/redmine/RedmineBrowserView.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
{t('Tickets werden geladen ...')}
; + + return ( +
+

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

+

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

+ + {error &&
{error}
} + + {tickets.length === 0 ? ( +
+ {t('Keine Tickets im Mirror. Bitte zuerst in den Einstellungen "Sync starten".')} +
+ ) : ( +
+

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

+ + + + + + + + + + + + + {tickets.slice(0, 200).map(ticket => ( + + + + + + + + + ))} + +
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.)')} +

+ )} +
+ )} +
+ ); +}; + +export default RedmineBrowserView; diff --git a/src/pages/views/redmine/RedmineSettingsView.tsx b/src/pages/views/redmine/RedmineSettingsView.tsx new file mode 100644 index 0000000..d0ad5e0 --- /dev/null +++ b/src/pages/views/redmine/RedmineSettingsView.tsx @@ -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(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(null); + const [success, setSuccess] = useState(null); + const [testResult, setTestResult] = useState(null); + const [syncResult, setSyncResult] = useState(null); + const [syncStatus, setSyncStatus] = useState(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 = { + 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
{t('Einstellungen werden geladen ...')}
; + } + + const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId; + const canSync = canTest; + + return ( +
+

{t('Redmine -- Einstellungen')}

+

+ {t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')} +

+ + {error &&
{error}
} + {success &&
{success}
} + +
+

{t('Verbindung')}

+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://redmine.example.com" + spellCheck={false} + /> +
{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}
+
+ +
+ + setProjectId(e.target.value)} + placeholder="logobject-mars" + spellCheck={false} + /> +
+ +
+ + setRootTrackerName(e.target.value)} + placeholder="Userstory" + spellCheck={false} + /> +
+ {t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')} +
+
+ +
+ + 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" + /> +
+ {t('Wird verschluesselt gespeichert. Status: ')} + {config?.hasApiKey ? {t('gesetzt')} : {t('nicht gesetzt')}} +
+
+ +
+ + + {config?.id && ( + + )} +
+ + {testResult && ( +
+ {testResult.ok ? ( +
+ {t('Verbindung OK')}.{' '} + {testResult.user?.name && <>{t('Angemeldet als')} {testResult.user.name}. } + {testResult.project?.name && <>{t('Projekt')}: {testResult.project.name}.} +
+ ) : ( +
+ {t('Verbindung fehlgeschlagen')}.{' '} + {testResult.message || testResult.reason || ''} + {testResult.status ? ` (HTTP ${testResult.status})` : ''} +
+ )} +
+ )} +
+ +
+

{t('Mirror-Sync')}

+

+ {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.')} +

+ +
+
{t('Letzter Sync')}:
+
{_formatTs(config?.lastSyncAt)}
+
{t('Letzter Full-Sync')}:
+
{_formatTs(config?.lastFullSyncAt)}
+
{t('Letzte Sync-Dauer')}:
+
{_formatDuration(syncStatus?.lastSyncDurationMs)}
+
{t('Tickets im Mirror')}:
+
{syncStatus?.mirroredTicketCount ?? '-'}
+
{t('Beziehungen im Mirror')}:
+
{syncStatus?.mirroredRelationCount ?? '-'}
+ {config?.lastSyncErrorMessage && ( + <> +
{t('Letzter Fehler')}:
+
{config.lastSyncErrorMessage}
+ + )} +
+ +
+ + +
+ + {syncResult && ( +
+ {syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:{' '} + {syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '} + {t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}. +
+ )} + + {!canSync && ( +
+ {t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')} +
+ )} +
+
+ ); +}; + +export default RedmineSettingsView; diff --git a/src/pages/views/redmine/RedmineStatsView.tsx b/src/pages/views/redmine/RedmineStatsView.tsx new file mode 100644 index 0000000..1f423ce --- /dev/null +++ b/src/pages/views/redmine/RedmineStatsView.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
{t('Statistik wird geladen ...')}
; + + return ( +
+

{t('Redmine -- Statistik')}

+

+ {t('Aggregiert aus dem lokalen Mirror. PeriodPicker und FormGeneratorReport folgen im naechsten Schritt.')} +

+ + {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(', ')} +
  • + ))} +
+
+ )} +
+ ); +}; + +export default RedmineStatsView; diff --git a/src/pages/views/redmine/RedmineViews.module.css b/src/pages/views/redmine/RedmineViews.module.css new file mode 100644 index 0000000..aa00c95 --- /dev/null +++ b/src/pages/views/redmine/RedmineViews.module.css @@ -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; +} diff --git a/src/pages/views/redmine/index.ts b/src/pages/views/redmine/index.ts new file mode 100644 index 0000000..7bcad9a --- /dev/null +++ b/src/pages/views/redmine/index.ts @@ -0,0 +1,3 @@ +export { RedmineSettingsView } from './RedmineSettingsView'; +export { RedmineStatsView } from './RedmineStatsView'; +export { RedmineBrowserView } from './RedmineBrowserView';