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;
|
||||
}
|
||||
|
||||
// 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
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.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 />,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
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