feat(ui): Settings-Tab fuer die Ticketsystem-Anbindung

This commit is contained in:
mika kuns
2026-08-27 13:29:49 +02:00
parent f0a3a186dd
commit 020f93d55e
5 changed files with 158 additions and 2 deletions
+11 -1
View File
@@ -14,7 +14,8 @@
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Dateien & Prompts", "categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Berichte" "categoryReports": "Berichte",
"categoryTickets": "Ticketsystem"
}, },
"repoHint": { "repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.", "text": "Wirkt erst mit verknüpftem Repo.",
@@ -132,6 +133,14 @@
"configSection": "KONFIGURATION", "configSection": "KONFIGURATION",
"saveButton": "Konfiguration speichern" "saveButton": "Konfiguration speichern"
}, },
"tickets": {
"baseUrl": "API-Base-URL",
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
"token": "Personal Access Token",
"tokenSet": "gespeichert",
"testConnection": "Verbindung testen",
"clearToken": "Token entfernen"
},
"inherit": { "inherit": {
"inheritedFromList": "geerbt · Liste", "inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global", "inheritedFromGlobal": "geerbt · Global",
@@ -726,6 +735,7 @@
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" },
"usageMonitor": { "usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
+11 -1
View File
@@ -14,7 +14,8 @@
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Files & Prompts", "categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Reports" "categoryReports": "Reports",
"categoryTickets": "Ticket system"
}, },
"repoHint": { "repoHint": {
"text": "Only takes effect once a repo is linked.", "text": "Only takes effect once a repo is linked.",
@@ -132,6 +133,14 @@
"configSection": "CONFIGURATION", "configSection": "CONFIGURATION",
"saveButton": "Save config" "saveButton": "Save config"
}, },
"tickets": {
"baseUrl": "API base URL",
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
"token": "Personal access token",
"tokenSet": "stored",
"testConnection": "Test connection",
"clearToken": "Remove token"
},
"inherit": { "inherit": {
"inheritedFromList": "inherited · List", "inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global", "inheritedFromGlobal": "inherited · Global",
@@ -726,6 +735,7 @@
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
"weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" },
"usageMonitor": { "usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}", "loadFailed": "Couldn't load usage data: {0}",
@@ -0,0 +1,92 @@
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class TicketSettingsTabViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
[ObservableProperty] private string _apiBaseUrl = "";
[ObservableProperty] private string _token = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TokenPlaceholder))]
private bool _tokenSet;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
// Never shows the stored token — TicketSettingsDto only ever carries the bool.
public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : "";
public TicketSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public async Task LoadAsync()
{
var dto = await _worker.GetTicketSettingsAsync();
if (dto is null)
{
StatusMessage = Loc.T("vm.tickets.workerOffline");
return;
}
ApiBaseUrl = dto.ApiBaseUrl ?? "";
TokenSet = dto.TokenSet;
}
public async Task SaveAsync()
{
await _worker.SetTicketApiBaseUrlAsync(ApiBaseUrl);
if (!string.IsNullOrWhiteSpace(Token))
{
await _worker.SetTicketTokenAsync(Token);
Token = "";
TokenSet = true;
}
}
[RelayCommand]
private async Task TestConnection()
{
IsBusy = true;
StatusMessage = "";
try
{
await SaveAsync();
var result = await _worker.TestTicketConnectionAsync();
if (result is null || !result.Ok)
{
StatusMessage = result?.Error ?? Loc.T("vm.tickets.workerOffline");
return;
}
var missing = RequiredScopes.Where(s => !result.Scopes.Contains(s)).ToList();
StatusMessage = missing.Count == 0
? Loc.T("vm.tickets.connectionOk", result.UserName!)
: Loc.T("vm.tickets.connectionMissingScopes", result.UserName!, string.Join(", ", missing));
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ClearToken()
{
await _worker.ClearTicketTokenAsync();
Token = "";
TokenSet = false;
StatusMessage = Loc.T("vm.tickets.tokenCleared");
}
// Ohne diese drei Scopes scheitert entweder der Import oder die Statusrueckmeldung —
// der mit Abstand haeufigste Konfigurationsfehler, deshalb wird er beim Testen benannt
// statt nur geloggt.
private static readonly string[] RequiredScopes =
[
"pat:projects:read",
"pat:board:read",
"pat:tickets:write",
];
}
@@ -26,6 +26,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public PrimeClaudeTabViewModel Prime { get; } public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; } public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; } public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false; public bool ShowOnlineInbox => false;
@@ -106,6 +107,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Prime = prime; Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService); OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker);
_categories = new List<SettingsCategory> _categories = new List<SettingsCategory>
{ {
@@ -117,6 +119,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
new("SessionSkills", new LocalizedString(localizer, "settings.sidebar.categorySkills"), "Erweitert", true, true), new("SessionSkills", new LocalizedString(localizer, "settings.sidebar.categorySkills"), "Erweitert", true, true),
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true), new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false), new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", true, false),
}; };
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList(); BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList(); ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
@@ -167,6 +170,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.LoadAsync(); await Prime.LoadAsync();
await OnlineInbox.LoadAsync(); await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(dto?.SessionSkills); await SessionSkills.LoadAsync(dto?.SessionSkills);
await Tickets.LoadAsync();
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling); General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
@@ -213,6 +217,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
await Tickets.SaveAsync();
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); } catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
@@ -690,6 +690,45 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<!-- Ticketsystem: Base-URL + PAT for the Bandel ticket integration. Empty base URL
disables the feature entirely, so this tab intentionally never forces a value. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryTickets}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/>
<TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.baseUrlHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.token}"/>
<TextBox Text="{Binding Tickets.Token, Mode=TwoWay}"
PasswordChar="•"
PlaceholderText="{Binding Tickets.TokenPlaceholder}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn"
Content="{loc:Tr settings.tickets.testConnection}"
Command="{Binding Tickets.TestConnectionCommand}"
IsEnabled="{Binding !Tickets.IsBusy}"/>
<Button Classes="btn danger"
Content="{loc:Tr settings.tickets.clearToken}"
Command="{Binding Tickets.ClearTokenCommand}"
IsEnabled="{Binding !Tickets.IsBusy}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding Tickets.StatusMessage}"
IsVisible="{Binding Tickets.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
</DockPanel> </DockPanel>