using System.Collections.ObjectModel; using ClaudeDo.Data; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels.Modals.Settings; /// One fallback-list choice; Id null = no fallback. public sealed record FallbackListOption(string? Id, string Name); public sealed partial class TicketSettingsTabViewModel : ViewModelBase { /// /// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI, wie bei /// SettingsModalViewModel.ShowOnlineInbox. Er versteckt drei Einstiegspunkte: diesen /// Settings-Tab, das Ticket-Projekt-Dropdown in den Listen-Einstellungen und den /// Kontextmenü-Eintrag zum Importieren. /// /// Pausiert am 2026-08-27 (API unter dem Hostnamen nicht erreichbar), reaktiviert am /// 2026-09-01: die API läuft unter http://192.168.45.161 und alle vier Routen sind /// live verifiziert. Base-URL ist deshalb eine IP, kein Hostname — bandelapis.fb-tuning.local /// lehnt die Verbindung weiterhin ab. /// public const bool FeatureEnabled = true; private readonly IWorkerClient _worker; private readonly IDbContextFactory _dbFactory; [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") : ""; /// Azure DevOps PAT being typed; never shows the stored one. [ObservableProperty] private string _azdoToken = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(AzdoTokenPlaceholder))] private bool _azdoTokenSet; [ObservableProperty] private string _azdoStatusMessage = ""; public string AzdoTokenPlaceholder => AzdoTokenSet ? Loc.T("settings.tickets.tokenSet") : ""; public ObservableCollection FallbackLists { get; } = new(); [ObservableProperty] private FallbackListOption? _selectedFallbackList; // Only a successful load may write the fallback back — otherwise an offline load would clear it. private bool _fallbackLoaded; public TicketSettingsTabViewModel(IWorkerClient worker, IDbContextFactory dbFactory) { _worker = worker; _dbFactory = dbFactory; } public async Task LoadAsync() { AzdoTokenSet = (await _worker.GetAzureDevOpsSettingsAsync())?.TokenSet ?? false; var dto = await _worker.GetTicketSettingsAsync(); if (dto is null) { StatusMessage = Loc.T("vm.tickets.workerOffline"); return; } ApiBaseUrl = dto.ApiBaseUrl ?? ""; TokenSet = dto.TokenSet; await using var ctx = await _dbFactory.CreateDbContextAsync(); var lists = await ctx.Lists.AsNoTracking() .OrderBy(l => l.SortOrder).ThenBy(l => l.Name) .Select(l => new FallbackListOption(l.Id, l.Name)) .ToListAsync(); FallbackLists.Clear(); FallbackLists.Add(new FallbackListOption(null, Loc.T("settings.tickets.fallbackNone"))); foreach (var option in lists) FallbackLists.Add(option); SelectedFallbackList = FallbackLists.FirstOrDefault(o => o.Id == dto.FallbackListId) ?? FallbackLists[0]; _fallbackLoaded = true; } public async Task SaveAsync() { await _worker.SetTicketApiBaseUrlAsync(ApiBaseUrl); if (!string.IsNullOrWhiteSpace(Token)) { await _worker.SetTicketTokenAsync(Token); Token = ""; TokenSet = true; } if (_fallbackLoaded) await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id); if (!string.IsNullOrWhiteSpace(AzdoToken)) { await _worker.SetAzureDevOpsTokenAsync(AzdoToken); AzdoToken = ""; AzdoTokenSet = true; } } [RelayCommand] private async Task TestAzdoConnection() { IsBusy = true; AzdoStatusMessage = ""; try { await SaveAsync(); var result = await _worker.TestAzureDevOpsConnectionAsync(); AzdoStatusMessage = result is { Ok: true } ? Loc.T("vm.tickets.azdoConnectionOk", result.Repo!, result.DefaultBranch!) : result?.Error ?? Loc.T("vm.tickets.workerOffline"); } finally { IsBusy = false; } } [RelayCommand] private async Task ClearAzdoToken() { await _worker.ClearAzureDevOpsTokenAsync(); AzdoToken = ""; AzdoTokenSet = false; AzdoStatusMessage = Loc.T("vm.tickets.tokenCleared"); } [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", ]; }