ui-nyla/src/pages/views/redmine/RedmineSettingsView.tsx
2026-04-21 18:14:26 +02:00

349 lines
13 KiB
TypeScript

/**
* 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;