/** * Redmine Settings View * * Configure the Redmine connection for this feature instance: * - Base URL, Project ID, API Key, Root Tracker name * - "Verbindung testen" -- calls whoAmI + getProject and reports the result * - "Sync starten" -- pulls all (or only changed) tickets into the local mirror * * The user tests the feature directly here in Porta -- no pytest sandbox. */ import React, { useCallback, useEffect, useState } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { RedmineConfigDto, RedmineConnectionTestResult, RedmineSyncResult, RedmineSyncStatus, deleteRedmineConfigApi, getRedmineConfigApi, getRedmineSyncStatusApi, runRedmineSyncApi, testRedmineConnectionApi, updateRedmineConfigApi, } from '../../../api/redmineApi'; import styles from './RedmineViews.module.css'; const _formatTs = (ts?: number | null): string => { if (!ts) return '-'; try { return new Date(ts * 1000).toLocaleString(); } catch { return String(ts); } }; const _formatDuration = (ms?: number | null): string => { if (ms == null) return '-'; if (ms < 1000) return `${ms} ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(1)} s`; const m = s / 60; return `${m.toFixed(1)} min`; }; export const RedmineSettingsView: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const instanceId = useInstanceId(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [testResult, setTestResult] = useState(null); const [syncResult, setSyncResult] = useState(null); const [syncStatus, setSyncStatus] = useState(null); const [baseUrl, setBaseUrl] = useState(''); const [projectId, setProjectId] = useState(''); const [rootTrackerName, setRootTrackerName] = useState('Userstory'); const [apiKey, setApiKey] = useState(''); const _hydrate = useCallback((c: RedmineConfigDto) => { setConfig(c); setBaseUrl(c.baseUrl || ''); setProjectId(c.projectId || ''); setRootTrackerName(c.rootTrackerName || 'Userstory'); }, []); const _loadStatus = useCallback(async () => { if (!instanceId) return; try { const status = await getRedmineSyncStatusApi(request, instanceId); setSyncStatus(status); } catch { // status is optional; don't block the page on failure } }, [request, instanceId]); useEffect(() => { if (!instanceId) return; let cancelled = false; (async () => { setLoading(true); try { const cfg = await getRedmineConfigApi(request, instanceId); if (!cancelled) _hydrate(cfg); await _loadStatus(); } catch (e: any) { if (!cancelled) setError(e?.message || t('Fehler beim Laden')); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [request, instanceId, _hydrate, _loadStatus, t]); const _save = useCallback(async () => { if (!instanceId) return; setSaving(true); setError(null); setSuccess(null); try { const body: Record = { baseUrl: baseUrl.trim(), projectId: projectId.trim(), rootTrackerName: rootTrackerName.trim() || 'Userstory', isActive: true, }; if (apiKey.trim() !== '') body.apiKey = apiKey.trim(); const updated = await updateRedmineConfigApi(request, instanceId, body); _hydrate(updated); setApiKey(''); setSuccess(t('Einstellungen gespeichert.')); setTimeout(() => setSuccess(null), 3000); } catch (e: any) { setError(e?.message || t('Fehler beim Speichern.')); } finally { setSaving(false); } }, [request, instanceId, baseUrl, projectId, rootTrackerName, apiKey, _hydrate, t]); const _test = useCallback(async () => { if (!instanceId) return; setTesting(true); setError(null); setTestResult(null); try { const result = await testRedmineConnectionApi(request, instanceId); setTestResult(result); } catch (e: any) { setError(e?.message || t('Verbindungstest fehlgeschlagen.')); } finally { setTesting(false); } }, [request, instanceId, t]); const _runSync = useCallback(async (force: boolean) => { if (!instanceId) return; setSyncing(true); setError(null); setSuccess(null); setSyncResult(null); try { const result = await runRedmineSyncApi(request, instanceId, force); setSyncResult(result); await _loadStatus(); const cfg = await getRedmineConfigApi(request, instanceId); _hydrate(cfg); setSuccess( t('Sync erfolgreich.') + ` ${result.ticketsUpserted} ${t('Tickets')}, ${result.relationsUpserted} ${t('Beziehungen')}, ${_formatDuration(result.durationMs)}.`, ); } catch (e: any) { setError(e?.message || t('Sync fehlgeschlagen.')); } finally { setSyncing(false); } }, [request, instanceId, _loadStatus, _hydrate, t]); const _delete = useCallback(async () => { if (!instanceId) return; if (!window.confirm(t('Konfiguration wirklich loeschen? Der lokale Mirror bleibt erhalten.'))) return; setError(null); setSuccess(null); try { await deleteRedmineConfigApi(request, instanceId); setBaseUrl(''); setProjectId(''); setRootTrackerName('Userstory'); setApiKey(''); setConfig(null); setSuccess(t('Konfiguration geloescht.')); } catch (e: any) { setError(e?.message || t('Loeschen fehlgeschlagen.')); } }, [request, instanceId, t]); if (loading) { return
{t('Einstellungen werden geladen ...')}
; } const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId; const canSync = canTest; return (

{t('Redmine -- Einstellungen')}

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

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

{t('Verbindung')}

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

{t('Mirror-Sync')}

{t('Tickets werden in die lokale Datenbank gespiegelt, damit Statistik und Browser auch bei 20\u2019000+ Tickets schnell sind. Nach Aenderungen wird das Mirror-Bild automatisch nachgezogen.')}

{t('Letzter Sync')}:
{_formatTs(config?.lastSyncAt)}
{t('Letzter Full-Sync')}:
{_formatTs(config?.lastFullSyncAt)}
{t('Letzte Sync-Dauer')}:
{_formatDuration(syncStatus?.lastSyncDurationMs)}
{t('Tickets im Mirror')}:
{syncStatus?.mirroredTicketCount ?? '-'}
{t('Beziehungen im Mirror')}:
{syncStatus?.mirroredRelationCount ?? '-'}
{config?.lastSyncErrorMessage && ( <>
{t('Letzter Fehler')}:
{config.lastSyncErrorMessage}
)}
{syncResult && (
{syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:{' '} {syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '} {t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}.
)} {!canSync && (
{t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')}
)}
); }; export default RedmineSettingsView;