redmine integration
This commit is contained in:
parent
d771d4bc09
commit
0bdaf86153
9 changed files with 1141 additions and 0 deletions
14
src/api.ts
14
src/api.ts
|
|
@ -92,6 +92,20 @@ api.interceptors.request.use(
|
||||||
config.headers['Accept-Language'] = appLanguage;
|
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)
|
// Add multi-tenant context headers from URL (if not already set)
|
||||||
// This ensures Feature-Instance roles are loaded for permission checks
|
// This ensures Feature-Instance roles are loaded for permission checks
|
||||||
const context = getContextFromUrl();
|
const context = getContextFromUrl();
|
||||||
|
|
|
||||||
387
src/api/redmineApi.ts
Normal file
387
src/api/redmineApi.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,12 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.feature.workspace.dashboard': <FaPlay />,
|
'page.feature.workspace.dashboard': <FaPlay />,
|
||||||
'page.feature.workspace.editor': <FaPlay />,
|
'page.feature.workspace.editor': <FaPlay />,
|
||||||
'feature.workspace': <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 />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ import { NeutralizationView } from './views/neutralization';
|
||||||
// CommCoach Views
|
// CommCoach Views
|
||||||
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
|
||||||
|
|
||||||
|
// Redmine Views
|
||||||
|
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -168,6 +171,11 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
dossier: CommcoachDossierView,
|
dossier: CommcoachDossierView,
|
||||||
settings: CommcoachSettingsView,
|
settings: CommcoachSettingsView,
|
||||||
},
|
},
|
||||||
|
redmine: {
|
||||||
|
stats: RedmineStatsView,
|
||||||
|
browser: RedmineBrowserView,
|
||||||
|
settings: RedmineSettingsView,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
101
src/pages/views/redmine/RedmineBrowserView.tsx
Normal file
101
src/pages/views/redmine/RedmineBrowserView.tsx
Normal 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;
|
||||||
349
src/pages/views/redmine/RedmineSettingsView.tsx
Normal file
349
src/pages/views/redmine/RedmineSettingsView.tsx
Normal 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;
|
||||||
93
src/pages/views/redmine/RedmineStatsView.tsx
Normal file
93
src/pages/views/redmine/RedmineStatsView.tsx
Normal 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;
|
||||||
180
src/pages/views/redmine/RedmineViews.module.css
Normal file
180
src/pages/views/redmine/RedmineViews.module.css
Normal 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;
|
||||||
|
}
|
||||||
3
src/pages/views/redmine/index.ts
Normal file
3
src/pages/views/redmine/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { RedmineSettingsView } from './RedmineSettingsView';
|
||||||
|
export { RedmineStatsView } from './RedmineStatsView';
|
||||||
|
export { RedmineBrowserView } from './RedmineBrowserView';
|
||||||
Loading…
Reference in a new issue